Compare commits
71 Commits
89d8ecec82
..
uat
| Author | SHA1 | Date | |
|---|---|---|---|
| 88ef615bc0 | |||
| 7cd53ac420 | |||
| f79bdac889 | |||
| 3d1d325203 | |||
| 80dc45f528 | |||
| 2af0025c3e | |||
| 062c044295 | |||
| 4b54f71902 | |||
| 13e87110ae | |||
| 6ba671d2c3 | |||
| 1cc23a0836 | |||
| 0ec868c949 | |||
| 446dbf8ae0 | |||
| a303415857 | |||
| d4dbb1a10f | |||
| 38c13ec50e | |||
| 0b999eebb0 | |||
| d76ecb15d6 | |||
| f3fe7d4f2d | |||
| ceafc873c7 | |||
| 625645dad4 | |||
| a762a5bb4c | |||
| a062889a11 | |||
| 2fec2108ca | |||
| 0cde794c85 | |||
| 7f17dc826e | |||
| 01412a0a94 | |||
| 671933d960 | |||
| 6b3cd288b1 | |||
| d3ef22f06e | |||
| 068708bbd7 | |||
| f99c44f716 | |||
| 860ad7cbd8 | |||
| 3c2b68bbc3 | |||
| 4c7152ff50 | |||
| 10574845f6 | |||
| 6b9fef1d69 | |||
| c01aef48bd | |||
| 2ed582faf0 | |||
| f96652a8aa | |||
| 5d434ead6f | |||
| 9d224e2fcd | |||
| 81abb4b220 | |||
| c3d34c4660 | |||
| d528358867 | |||
| 60ad9a04cd | |||
| 2d56df940d | |||
| fdd1ae0929 | |||
| 19bf317d85 | |||
| 221a8d7108 | |||
| 09a7fe7708 | |||
| dc11ef90fd | |||
| e4b730c8db | |||
| c5413644f9 | |||
| 625d0baf7d | |||
| c5d3ac01ca | |||
| 71742da600 | |||
| 3173525a2e | |||
| 0a7dcbb6b8 | |||
| 5cec2101af | |||
| 262f946a4e | |||
| cfdbaf1095 | |||
| 277a484f60 | |||
| 7299128a34 | |||
| 12b2cdbc02 | |||
| 8d3cce814a | |||
| 3ae141ba56 | |||
| 4dac4bc751 | |||
| 6b684f8600 | |||
| 9405406c47 | |||
| c76346294a |
+14
-2
@@ -1,6 +1,5 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
@@ -35,4 +34,17 @@ backend/pb_migrations.bak/
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
.cache/
|
||||
.cache/
|
||||
|
||||
# Test screenshots and Playwright data
|
||||
*.png
|
||||
.playwright-mcp/
|
||||
|
||||
# PocketBase UAT data
|
||||
backend/pb_data_uat/
|
||||
|
||||
# Electron update build artifacts
|
||||
electron-update/
|
||||
|
||||
# Docs plans (working drafts)
|
||||
docs/plans/
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 项目概述
|
||||
|
||||
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库、投票、积分竞猜、账本和资产。
|
||||
|
||||
## 开发命令
|
||||
|
||||
```bash
|
||||
# 构建前端(先 vue-tsc 类型检查,再 vite build。类型错误会阻断构建)
|
||||
# 注意:npm run build 默认加载 .env.dev 的变量。Docker 构建时通过 ARG 注入空 VITE_PB_URL,由 nginx 代理处理
|
||||
# 如需本地测试 UAT 构建设置,使用:cd frontend && npm run build -- --mode uat
|
||||
cd frontend && npm run build
|
||||
|
||||
# 本地 vite dev server(一般不用,用 Docker 部署代替)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# 部署脚本(根目录)
|
||||
./deploy-dev.sh # 构建 + 部署 Dev 全套 (PB + LiveKit + 前端, 端口 7033/8090)
|
||||
./deploy-uat.sh # 构建 + 部署 UAT 全套 (PB + LiveKit + 前端, 端口 7034/8712)
|
||||
./stop-all.sh # 停止所有服务
|
||||
|
||||
# Electron 桌面端
|
||||
cd electron && npm install
|
||||
cd electron && npm run start # 启动桌面端(默认连接 Dev)
|
||||
cd electron && npm run start:dev # 同上,显式指定 dev 环境
|
||||
cd electron && npm run start:uat # 连接 UAT 环境
|
||||
cd electron && npm run build # 打包 Windows 可执行文件
|
||||
|
||||
# 查看日志
|
||||
docker logs -f gamegroup-pb # Dev PocketBase
|
||||
docker logs -f gamegroup-pb-uat # UAT PocketBase
|
||||
docker logs -f gamegroup-livekit-dev # Dev LiveKit
|
||||
docker logs -f gamegroup-livekit-uat # UAT LiveKit
|
||||
docker logs -f gamegroup-voice-token-dev # Dev Voice Token
|
||||
docker logs -f gamegroup-voice-token-uat # UAT Voice Token
|
||||
docker logs -f gamegroup-frontend-dev # Dev 前端
|
||||
docker logs -f gamegroup-frontend-uat # UAT 前端
|
||||
```
|
||||
|
||||
**重要**: 不要在本地启动 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 包),localStorage 持久化认证
|
||||
- **实时通信**: PocketBase realtime subscriptions
|
||||
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
|
||||
- **语音通话**: LiveKit server v1.10 + `livekit-client` + 独立 Express token 服务
|
||||
- **桌面端**: Electron 35 + `electron-store` + `electron-builder`
|
||||
|
||||
## 环境与端口
|
||||
|
||||
| 服务 | Dev | UAT |
|
||||
|------|-----|-----|
|
||||
| 前端 (nginx) | 7033 | 7034 |
|
||||
| PocketBase | 8090 | 8712 |
|
||||
| LiveKit | 7880/7881/7882(udp) | 7890/7891/7892(udp) |
|
||||
| Voice Token | 7883 | 7893 |
|
||||
|
||||
Docker Compose 文件:`docker-compose.dev.yml`(Dev 全套)、`docker-compose.uat.yml`(UAT 全套),共享 `gamegroup-net` 网络。每个环境独立运行完整的 PB + LiveKit + Voice Token + 前端服务。
|
||||
|
||||
前端 `.env` 文件:`frontend/.env.dev` 和 `frontend/.env.uat`。每个 env 包含四个变量:`VITE_PB_URL`(PocketBase 地址)、`VITE_PORT`(dev server 端口)、`VITE_LIVEKIT_URL`(LiveKit WebSocket 地址)、`VITE_VOICE_TOKEN_URL`(Voice Token 服务地址)。Docker 构建时 `VITE_PB_URL` 被覆盖为空,由 nginx 反向代理处理。
|
||||
|
||||
## 架构
|
||||
|
||||
### 前端核心流程
|
||||
|
||||
```
|
||||
pocketbase.ts (PB 客户端初始化)
|
||||
→ router guards (isAuthenticated 检查)
|
||||
→ stores (user/group/team/notification/poll/ledger/asset/memory)
|
||||
→ api/ (PocketBase CRUD 封装,每个领域一个文件)
|
||||
→ components + views
|
||||
```
|
||||
|
||||
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()`
|
||||
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`, `polls.ts`, `bets.ts`, `points.ts`, `ledgers.ts`, `assets.ts`, `memories.ts`, `notifications.ts`, `gameBlacklist.ts`, `playerBlacklist.ts`, `voice.ts`, `bulletins.ts`, `events.ts`)
|
||||
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`)。共 11 个 store(user/group/team/event/poll/ledger/asset/memory/notification/bulletin),`index.ts` 仅导出前 3 个,其余直接从文件导入
|
||||
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
|
||||
- **`composables/useVoiceRoom.ts`** — LiveKit 语音房间封装,处理连接/断开/麦克风/扬声器控制
|
||||
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap`、`TeamStatusMap`)
|
||||
- **路径别名**: `@/` 映射到 `src/`,在 `vite.config.ts` 和 `tsconfig.json` 中配置
|
||||
|
||||
### 认证流程
|
||||
|
||||
- 注册:用户输入中文昵称存 `name` 字段,`username` 自动生成 ASCII 标识(`'u' + Date.now().toString(36) + random`)
|
||||
- 登录:支持昵称/邮箱/username 登录。输入不含 `@` 时查询 `users` collection 的 `name`/`username` 字段,获取 `username` 后调用 `authWithPassword(username, password)`
|
||||
- 路由守卫:`requiresAuth` 跳转登录页,`requiresGuest` 跳转首页
|
||||
|
||||
### 路由结构
|
||||
|
||||
Layout (`/`) 下所有认证页面为子路由:Home, GroupView (`/group/:id`), LedgerView (`/group/:groupId/ledger`), AssetView (`/group/:groupId/assets`), BlacklistView (`/group/:groupId/blacklist`), VoiceRoom (`/group/:groupId/voice/:sessionId`), GamesLibrary, Profile, Settings, Changelog。Login/Register 为独立路由。JoinGroup (`/join/group/:groupId`) 和 JoinTeam (`/join/team/:sessionId`) 为无需认证的独立页面,用于外部邀请链接。
|
||||
|
||||
### Vite 代理 vs Nginx
|
||||
|
||||
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。Voice Token 服务通过 `/voice-api/` 路径由 nginx 代理到 voice-token 端口(Dev: 7883, UAT: 7893)。
|
||||
|
||||
前端 nginx 配置有两份:`nginx.conf`(dev,代理到 8090)和 `nginx.uat.conf`(代理到 8712),Docker 构建时通过 `NGINX_CONF` build arg 选择。静态资源缓存一年,HTML 不缓存。
|
||||
|
||||
### 语音通话架构
|
||||
|
||||
组队会话 (`team_sessions`) 支持语音房间。语音流程:
|
||||
1. 用户点击语音按钮 → 路由跳转到 `VoiceRoom.vue`
|
||||
2. `useVoiceRoom.connect(sessionId)` 调用 `api/voice.ts` 的 `fetchVoiceToken`
|
||||
3. `fetchVoiceToken` 向后端 `/voice-api/voice-token/:sessionId` 请求 token
|
||||
4. Voice Token 服务验证用户 PB token → 查询 `team_sessions` 确认成员身份 → 签发 LiveKit JWT
|
||||
5. 前端用 token 连接 LiveKit server(Dev: `ws://192.168.1.14:7880`, UAT: `ws://192.168.1.14:7890`)
|
||||
|
||||
HTTP 环境下 `navigator.mediaDevices` 受限,已在 `useVoiceRoom.ts` 中给出明确的 Chrome flags 引导错误提示。
|
||||
|
||||
### Electron 桌面端
|
||||
|
||||
- `electron/main.js` — 主进程,加载远程 URL(dev/uat 可切换),窗口大小 1280x800
|
||||
- `electron/preload.js` — 预加载脚本(当前为空壳,保留扩展点)
|
||||
- `electron/package.json` — 独立包,依赖 `electron-store`(用于本地配置持久化)
|
||||
- 构建产物为 Windows portable 可执行文件
|
||||
|
||||
### 实时订阅管理
|
||||
|
||||
所有 PocketBase 实时订阅统一通过 `useRealtime.ts` composable 管理。组件使用时在 `onMounted` 中调用订阅方法,`onUnmounted` 时自动调用 `unsubscribeAll` 清理。不要直接在组件中创建孤立的 `pb.collection().subscribe()` 而不做清理。
|
||||
|
||||
### 数据模型(PocketBase Collections)
|
||||
|
||||
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
|
||||
- **groups** — owner + members 关系,支持审核加入(requireApproval)
|
||||
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved。含 `voiceRoom` 和 `voiceActive` 字段
|
||||
- **invitations** — 组队邀请,pending/accepted/rejected
|
||||
- **games** — 游戏库,归属 group,含平台、标签、封面
|
||||
- **game_comments** / **game_favorites** — 评论和收藏
|
||||
- **join_requests** — 入群申请
|
||||
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
|
||||
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
|
||||
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
|
||||
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group。nginx 配置 `client_max_body_size 500m` 支持大文件上传
|
||||
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
|
||||
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
|
||||
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
|
||||
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
|
||||
- **notifications** — 站内通知(投票/组队/入群等事件)
|
||||
- **bulletin_posts** / **bulletin_reads** — 群组信息公示板,支持置顶/优先级/已读追踪/过期时间
|
||||
|
||||
### PocketBase 注意事项
|
||||
|
||||
- `users` collection 的 `listRule`/`viewRule` 设为空字符串(公开),以支持登录页查询用户
|
||||
- Auth collection 的 `email` 字段不对未认证请求暴露,登录查找用 `username` 替代
|
||||
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
|
||||
- PocketBase 管理面板:`admin@example.com` / `admin123456`
|
||||
- API 调用添加 `$autoCancel: false` 避免 PocketBase SDK 自动取消请求
|
||||
|
||||
## 编码约束
|
||||
|
||||
- **TypeScript 严格模式**: `strict: true` + `noUnusedLocals` + `noUnusedParameters`,未使用的变量/参数会导致构建失败
|
||||
- **无测试框架**: 项目当前没有测试文件和测试配置,不要尝试运行测试
|
||||
- **无 Linter 配置**: 没有 ESLint/Prettier 配置文件
|
||||
@@ -87,7 +87,7 @@ migrate((db) => {
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"updateRule": "owner = @request.auth.id",
|
||||
"updateRule": "@request.auth.id != \"\"",
|
||||
"deleteRule": "owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\""
|
||||
collection.viewRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
collection.listRule = "owner = @request.auth.id || members.id = @request.auth.id"
|
||||
collection.viewRule = "owner = @request.auth.id || members.id = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
collection.updateRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
collection.updateRule = "owner = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
// add
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sf_approval",
|
||||
"name": "requireApproval",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
// remove
|
||||
collection.schema.removeField("sf_approval")
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "ezklls35klxregi",
|
||||
"created": "2026-04-17 16:30:48.222Z",
|
||||
"updated": "2026-04-17 16:30:48.222Z",
|
||||
"name": "join_requests",
|
||||
"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_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_status",
|
||||
"name": "status",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_reason",
|
||||
"name": "rejectReason",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\"",
|
||||
"viewRule": "@request.auth.id != \"\"",
|
||||
"createRule": "@request.auth.id != \"\"",
|
||||
"updateRule": "group.owner = @request.auth.id",
|
||||
"deleteRule": "group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("ezklls35klxregi");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\""
|
||||
collection.viewRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = "id = @request.auth.id"
|
||||
collection.viewRule = "id = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("sac8t6o9rspld8p")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\""
|
||||
collection.viewRule = "@request.auth.id != \"\""
|
||||
collection.updateRule = "@request.auth.id != \"\""
|
||||
collection.deleteRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("sac8t6o9rspld8p")
|
||||
|
||||
collection.listRule = "sourceGroup.owner = @request.auth.id || sourceGroup.members.id = @request.auth.id"
|
||||
collection.viewRule = "sourceGroup.owner = @request.auth.id || sourceGroup.members.id = @request.auth.id"
|
||||
collection.updateRule = "sourceGroup.owner = @request.auth.id"
|
||||
collection.deleteRule = "sourceGroup.owner = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.options = {
|
||||
"allowEmailAuth": true,
|
||||
"allowOAuth2Auth": true,
|
||||
"allowUsernameAuth": true,
|
||||
"exceptEmailDomains": null,
|
||||
"manageRule": null,
|
||||
"minPasswordLength": 6,
|
||||
"onlyEmailDomains": null,
|
||||
"onlyVerified": false,
|
||||
"requireEmail": false
|
||||
}
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.options = {
|
||||
"allowEmailAuth": true,
|
||||
"allowOAuth2Auth": true,
|
||||
"allowUsernameAuth": true,
|
||||
"exceptEmailDomains": null,
|
||||
"manageRule": null,
|
||||
"minPasswordLength": 8,
|
||||
"onlyEmailDomains": null,
|
||||
"onlyVerified": false,
|
||||
"requireEmail": false
|
||||
}
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = ""
|
||||
collection.viewRule = ""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\""
|
||||
collection.viewRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = ""
|
||||
collection.viewRule = ""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\""
|
||||
collection.viewRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(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)
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu")
|
||||
|
||||
collection.createRule = "@request.auth.id != \"\""
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("s63vtbeeqlv1xzu")
|
||||
|
||||
collection.createRule = "@request.auth.id != \"\" && user = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "ledgers_col",
|
||||
"created": "2026-04-18 10:00:01.000Z",
|
||||
"updated": "2026-04-18 10:00:01.000Z",
|
||||
"name": "ledgers",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_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": "lgr_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": "lgr_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"income",
|
||||
"expense"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_amount",
|
||||
"name": "amount",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 0.01,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_category",
|
||||
"name": "category",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"gaming",
|
||||
"food",
|
||||
"equipment",
|
||||
"transport",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_members",
|
||||
"name": "relatedMembers",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": null,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "lgr_occurred",
|
||||
"name": "occurredAt",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"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("ledgers_col");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "assets_col",
|
||||
"created": "2026-04-18 10:00:02.000Z",
|
||||
"updated": "2026-04-18 10:00:02.000Z",
|
||||
"name": "assets",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_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": "ast_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": "ast_name",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 100,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"game_account",
|
||||
"console",
|
||||
"equipment",
|
||||
"accessory",
|
||||
"other"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_holder",
|
||||
"name": "currentHolder",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "ast_image",
|
||||
"name": "image",
|
||||
"type": "file",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"mimeTypes": ["image/*"],
|
||||
"thumbs": ["200x200"],
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"protected": 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": "@request.auth.id != \"\" && group.members ~ @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("assets_col");
|
||||
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("ledgers_col")
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "lgr_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("ledgers_col")
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "lgr_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "gblacklist_col",
|
||||
"created": "2026-04-18 21:00:01.000Z",
|
||||
"updated": "2026-04-18 21:00:01.000Z",
|
||||
"name": "game_blacklist",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_reporter",
|
||||
"name": "reporter",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_game",
|
||||
"name": "game",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"options": {
|
||||
"collectionId": "x5adjlc0txf16r8",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_gamename",
|
||||
"name": "gameName",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_reason",
|
||||
"name": "reason",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["behavior", "cheating", "abandonment", "toxic", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "gb_severity",
|
||||
"name": "severity",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["mild", "medium", "severe"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"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": null,
|
||||
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("gblacklist_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "bets_col",
|
||||
"created": "2026-04-18 21:00:02.000Z",
|
||||
"updated": "2026-04-18 21:00:02.000Z",
|
||||
"name": "bets",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_title",
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 1000,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_minstake",
|
||||
"name": "minStake",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_maxstake",
|
||||
"name": "maxStake",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_status",
|
||||
"name": "status",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["open", "closed", "settled"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_result",
|
||||
"name": "resultOption",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"options": {
|
||||
"collectionId": "betopts_col",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_deadline",
|
||||
"name": "deadline",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bt_settledat",
|
||||
"name": "settledAt",
|
||||
"type": "date",
|
||||
"required": 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("bets_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "betopts_col",
|
||||
"created": "2026-04-18 21:00:03.000Z",
|
||||
"updated": "2026-04-18 21:00:03.000Z",
|
||||
"name": "bet_options",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "bo_bet",
|
||||
"name": "bet",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "bets_col",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bo_content",
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "bo_order",
|
||||
"name": "order",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"updateRule": "bet.creator = @request.auth.id",
|
||||
"deleteRule": "bet.creator = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("betopts_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "betentries_col",
|
||||
"created": "2026-04-18 21:00:04.000Z",
|
||||
"updated": "2026-04-18 21:00:04.000Z",
|
||||
"name": "bet_entries",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_bet",
|
||||
"name": "bet",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "bets_col",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_user",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_option",
|
||||
"name": "option",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "betopts_col",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_stake",
|
||||
"name": "stake",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "be_won",
|
||||
"name": "won",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"options": {}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id && bet.group.members ~ @request.auth.id",
|
||||
"updateRule": "bet.creator = @request.auth.id",
|
||||
"deleteRule": "user = @request.auth.id && bet.status = \"open\"",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("betentries_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s")
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sd27cbh8",
|
||||
"name": "action",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"vote",
|
||||
"team",
|
||||
"memory",
|
||||
"bet",
|
||||
"settle"
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "iszqa13h",
|
||||
"name": "points",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
}))
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "pnipfzbd",
|
||||
"name": "relatedId",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s")
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sd27cbh8",
|
||||
"name": "action",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"vote",
|
||||
"team",
|
||||
"memory"
|
||||
]
|
||||
}
|
||||
}))
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "iszqa13h",
|
||||
"name": "points",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
}))
|
||||
|
||||
// update
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "pnipfzbd",
|
||||
"name": "relatedId",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": null,
|
||||
"pattern": ""
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,124 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "pblacklist_col",
|
||||
"created": "2026-04-19 10:00:01.000Z",
|
||||
"updated": "2026-04-19 10:00:01.000Z",
|
||||
"name": "player_blacklist",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_reporter",
|
||||
"name": "reporter",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_playerid",
|
||||
"name": "playerId",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_platform",
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_tags",
|
||||
"name": "tags",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 5,
|
||||
"values": ["afk", "feeder", "toxic", "cheater", "quitter", "noob", "fragile", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_customtag",
|
||||
"name": "customTag",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 50,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_severity",
|
||||
"name": "severity",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["mild", "medium", "severe"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"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": null,
|
||||
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("pblacklist_col");
|
||||
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("gblacklist_col")
|
||||
|
||||
collection.updateRule = "reporter = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("gblacklist_col")
|
||||
|
||||
collection.updateRule = null
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
// add admins field
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sf_admins",
|
||||
"name": "admins",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": null,
|
||||
"displayFields": null
|
||||
}
|
||||
}))
|
||||
|
||||
// add allowMemberManage field
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "sf_allowmm",
|
||||
"name": "allowMemberManage",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("es63bkyiblpnxdf")
|
||||
|
||||
collection.schema.removeField("sf_admins")
|
||||
collection.schema.removeField("sf_allowmm")
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "sf_events_001",
|
||||
"created": "2026-04-21 00:00:00.000Z",
|
||||
"updated": "2026-04-21 00:00:00.000Z",
|
||||
"name": "events",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_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_e_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_title",
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 2000,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_location",
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_start",
|
||||
"name": "startTime",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_end",
|
||||
"name": "endTime",
|
||||
"type": "date",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": "",
|
||||
"max": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_status",
|
||||
"name": "status",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"upcoming",
|
||||
"ongoing",
|
||||
"completed",
|
||||
"cancelled"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_e_max",
|
||||
"name": "maxParticipants",
|
||||
"type": "number",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": null,
|
||||
"noDecimal": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"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("sf_events_001");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "sf_ec_001",
|
||||
"created": "2026-04-21 00:00:00.000Z",
|
||||
"updated": "2026-04-21 00:00:00.000Z",
|
||||
"name": "event_comments",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_ec_event",
|
||||
"name": "event",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "sf_events_001",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_ec_user",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_ec_content",
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"updateRule": "user = @request.auth.id",
|
||||
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("sf_ec_001");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "sf_er_001",
|
||||
"created": "2026-04-21 00:00:00.000Z",
|
||||
"updated": "2026-04-21 00:00:00.000Z",
|
||||
"name": "event_rsvps",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_er_event",
|
||||
"name": "event",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "sf_events_001",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_er_user",
|
||||
"name": "user",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_er_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": [
|
||||
"going",
|
||||
"interested",
|
||||
"maybe"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_er_comment",
|
||||
"name": "comment",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE UNIQUE INDEX idx_event_user ON event_rsvps (event, user)"
|
||||
],
|
||||
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||
"updateRule": "user = @request.auth.id",
|
||||
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("sf_er_001");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
// remove old url cover field
|
||||
collection.schema.removeField("cover_field")
|
||||
|
||||
// add new file cover field
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "cover_file",
|
||||
"name": "cover",
|
||||
"type": "file",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"mimeTypes": ["image/jpeg","image/png","image/gif","image/webp","image/svg+xml"],
|
||||
"thumbs": null,
|
||||
"protected": false
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
// remove file cover field
|
||||
collection.schema.removeField("cover_file")
|
||||
|
||||
// restore url cover field
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "cover_field",
|
||||
"name": "cover",
|
||||
"type": "url",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"exceptDomains": null,
|
||||
"onlyDomains": null
|
||||
}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
collection.listRule = "@request.auth.id != \"\" && group.members ~ @request.auth.id"
|
||||
collection.viewRule = "@request.auth.id != \"\" && group.members ~ @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
collection.listRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
|
||||
collection.viewRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
// add aliases field (json array of strings)
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "aliases_field",
|
||||
"name": "aliases",
|
||||
"type": "json",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {
|
||||
"maxSize": 5000
|
||||
}
|
||||
}))
|
||||
|
||||
// update rules: allow group admins to update/delete
|
||||
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id || group.admins ~ @request.auth.id"
|
||||
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id || group.admins ~ @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
|
||||
|
||||
// remove aliases field
|
||||
collection.schema.removeField("aliases_field")
|
||||
|
||||
// restore original rules
|
||||
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
|
||||
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --production
|
||||
COPY server.js .
|
||||
EXPOSE 7882
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "gamegroup-voice-token",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"livekit-server-sdk": "^2.0",
|
||||
"express": "^4.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import express from 'express'
|
||||
import { AccessToken } from 'livekit-server-sdk'
|
||||
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
|
||||
const API_KEY = process.env.LIVEKIT_API_KEY || 'APIyxZGQjM2'
|
||||
const API_SECRET = process.env.LIVEKIT_API_SECRET || 'secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi'
|
||||
const PB_URL = process.env.PB_URL || 'http://gamegroup-pb:8090'
|
||||
const PORT = process.env.PORT || 7882
|
||||
|
||||
app.post('/api/voice-token/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params
|
||||
const authHeader = req.headers.authorization
|
||||
console.log('Voice token request:', { sessionId, authHeader: authHeader ? authHeader.slice(0, 20) + '...' : null })
|
||||
|
||||
if (!authHeader) {
|
||||
console.log('Missing auth header')
|
||||
return res.status(401).json({ error: '未登录' })
|
||||
}
|
||||
|
||||
// 验证用户 token — 调用 PocketBase
|
||||
const pbRefreshUrl = `${PB_URL}/api/collections/users/auth-refresh`
|
||||
console.log('Calling PB auth-refresh:', pbRefreshUrl)
|
||||
const pbRes = await fetch(pbRefreshUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
})
|
||||
console.log('PB auth-refresh status:', pbRes.status)
|
||||
if (!pbRes.ok) {
|
||||
const pbBody = await pbRes.text().catch(() => 'unknown')
|
||||
console.log('PB auth-refresh error body:', pbBody)
|
||||
return res.status(401).json({ error: '认证失败', detail: pbBody })
|
||||
}
|
||||
const userData = await pbRes.json()
|
||||
console.log('PB auth-refresh success, userId:', userData.record?.id)
|
||||
const userId = userData.record?.id
|
||||
const userName = userData.record?.name || userData.record?.username || userId
|
||||
if (!userId) {
|
||||
return res.status(401).json({ error: '无效用户' })
|
||||
}
|
||||
|
||||
// 获取 session 并验证成员
|
||||
const sessionRes = await fetch(`${PB_URL}/api/collections/team_sessions/records/${sessionId}`, {
|
||||
headers: { Authorization: authHeader },
|
||||
})
|
||||
if (!sessionRes.ok) {
|
||||
return res.status(404).json({ error: '未找到临时小组' })
|
||||
}
|
||||
const session = await sessionRes.json()
|
||||
const members = session.members || []
|
||||
if (!members.includes(userId)) {
|
||||
return res.status(403).json({ error: '你不是该小队的成员' })
|
||||
}
|
||||
|
||||
// 签发 LiveKit token
|
||||
const at = new AccessToken(API_KEY, API_SECRET, {
|
||||
identity: userId,
|
||||
name: userName,
|
||||
})
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: `team-${sessionId}`,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
})
|
||||
|
||||
const token = await at.toJwt()
|
||||
res.json({ token })
|
||||
} catch (err) {
|
||||
console.error('Voice token error:', err)
|
||||
res.status(500).json({ error: '服务器错误' })
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Voice token service listening on :${PORT}`)
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 部署 PocketBase 后端
|
||||
|
||||
echo "🚀 部署 PocketBase 后端..."
|
||||
|
||||
docker compose -f docker-compose.backend.yml up -d --force-recreate
|
||||
|
||||
echo "✅ PocketBase 已启动"
|
||||
echo "📡 API 地址: http://192.168.1.14:8711/api/"
|
||||
echo "🔧 管理面板: http://192.168.1.14:8711/_/"
|
||||
+11
-3
@@ -1,12 +1,20 @@
|
||||
#!/bin/bash
|
||||
# 部署 Dev 前端
|
||||
# 部署 Dev 全套环境(PocketBase + LiveKit + voice-token + 前端)
|
||||
|
||||
echo "🚀 部署 Dev 前端..."
|
||||
echo "🚀 部署 Dev 环境..."
|
||||
|
||||
# 确保网络存在
|
||||
docker network create gamegroup-net 2>/dev/null || true
|
||||
|
||||
# 先停掉旧容器(含已改名的 livekit / voice-token)
|
||||
docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true
|
||||
|
||||
docker compose -f docker-compose.dev.yml up -d --build --force-recreate
|
||||
|
||||
echo ""
|
||||
echo "✅ Dev 环境已启动"
|
||||
echo "🌐 访问地址: http://192.168.1.14:7033"
|
||||
echo "🌐 前端: http://192.168.1.14:7033"
|
||||
echo "📡 PB API: http://192.168.1.14:8090/api/"
|
||||
echo "🔧 PB 管理面板: http://192.168.1.14:8090/_/"
|
||||
echo "🎙️ LiveKit: ws://192.168.1.14:7880"
|
||||
echo "🔑 Token: http://192.168.1.14:7883"
|
||||
|
||||
+11
-3
@@ -1,12 +1,20 @@
|
||||
#!/bin/bash
|
||||
# 部署 UAT 前端
|
||||
# 部署 UAT 全套环境(PocketBase + LiveKit + voice-token + 前端)
|
||||
|
||||
echo "🚀 部署 UAT 前端..."
|
||||
echo "🚀 部署 UAT 环境..."
|
||||
|
||||
# 确保网络存在
|
||||
docker network create gamegroup-net 2>/dev/null || true
|
||||
|
||||
# 先停掉旧容器(含已改名的 livekit / voice-token)
|
||||
docker rm -f gamegroup-livekit gamegroup-voice-token 2>/dev/null || true
|
||||
|
||||
docker compose -f docker-compose.uat.yml up -d --build --force-recreate
|
||||
|
||||
echo ""
|
||||
echo "✅ UAT 环境已启动"
|
||||
echo "🌐 访问地址: http://192.168.1.14:7034"
|
||||
echo "🌐 前端: http://192.168.1.14:7034"
|
||||
echo "📡 PB API: http://192.168.1.14:8712/api/"
|
||||
echo "🔧 PB 管理面板: http://192.168.1.14:8712/_/"
|
||||
echo "🎙️ LiveKit: ws://192.168.1.14:7890"
|
||||
echo "🔑 Token: http://192.168.1.14:7893"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
services:
|
||||
pocketbase:
|
||||
image: ghcr.io/muchobien/pocketbase:0.22.4
|
||||
container_name: gamegroup-pb
|
||||
ports:
|
||||
- "8711:8090"
|
||||
volumes:
|
||||
- ./backend/pb_data:/pb_data
|
||||
- ./backend/pb_migrations:/pb_migrations
|
||||
- ./backend/pb_hooks:/pb_hooks
|
||||
environment:
|
||||
- GO_ENV=production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
networks:
|
||||
gamegroup-net:
|
||||
driver: bridge
|
||||
@@ -1,4 +1,55 @@
|
||||
services:
|
||||
pocketbase-dev:
|
||||
image: ghcr.io/muchobien/pocketbase:0.22.4
|
||||
container_name: gamegroup-pb
|
||||
ports:
|
||||
- "8090:8090"
|
||||
volumes:
|
||||
- ./backend/pb_data:/pb_data
|
||||
- ./backend/pb_migrations:/pb_migrations
|
||||
- ./backend/pb_hooks:/pb_hooks
|
||||
environment:
|
||||
- GO_ENV=production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
livekit-dev:
|
||||
image: livekit/livekit-server:v1.10
|
||||
container_name: gamegroup-livekit-dev
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
- "7882:7882/udp"
|
||||
environment:
|
||||
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev --node-ip 192.168.1.14
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
voice-token-dev:
|
||||
build:
|
||||
context: ./backend/voice-token-service
|
||||
container_name: gamegroup-voice-token-dev
|
||||
ports:
|
||||
- "7883:7882"
|
||||
environment:
|
||||
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||
- PB_URL=http://gamegroup-pb:8090
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase-dev
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
frontend-dev:
|
||||
build:
|
||||
context: ./frontend
|
||||
@@ -6,9 +57,13 @@ services:
|
||||
container_name: gamegroup-frontend-dev
|
||||
ports:
|
||||
- "7033:80"
|
||||
volumes:
|
||||
- ./electron-update/dev:/usr/share/nginx/html/electron-update
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase-dev
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
|
||||
@@ -1,14 +1,71 @@
|
||||
services:
|
||||
pocketbase-uat:
|
||||
image: ghcr.io/muchobien/pocketbase:0.22.4
|
||||
container_name: gamegroup-pb-uat
|
||||
ports:
|
||||
- "8712:8090"
|
||||
volumes:
|
||||
- ./backend/pb_data_uat:/pb_data
|
||||
- ./backend/pb_migrations:/pb_migrations
|
||||
- ./backend/pb_hooks:/pb_hooks
|
||||
environment:
|
||||
- GO_ENV=production
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8090/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
livekit-uat:
|
||||
image: livekit/livekit-server:v1.10
|
||||
container_name: gamegroup-livekit-uat
|
||||
ports:
|
||||
- "7890:7880"
|
||||
- "7891:7881/udp"
|
||||
- "7892:7882/udp"
|
||||
environment:
|
||||
LIVEKIT_KEYS: "APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev --node-ip 192.168.1.14
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
voice-token-uat:
|
||||
build:
|
||||
context: ./backend/voice-token-service
|
||||
container_name: gamegroup-voice-token-uat
|
||||
ports:
|
||||
- "7893:7882"
|
||||
environment:
|
||||
- LIVEKIT_API_KEY=APIyxZGQjM2
|
||||
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
|
||||
- PB_URL=http://gamegroup-pb-uat:8090
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase-uat
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
frontend-uat:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NGINX_CONF: nginx.uat.conf
|
||||
container_name: gamegroup-frontend-uat
|
||||
ports:
|
||||
- "7034:80"
|
||||
volumes:
|
||||
- ./electron-update/uat:/usr/share/nginx/html/electron-update
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- pocketbase-uat
|
||||
networks:
|
||||
- gamegroup-net
|
||||
|
||||
|
||||
@@ -159,6 +159,19 @@ NAS 服务器
|
||||
icon: string
|
||||
awardedAt: date
|
||||
}
|
||||
|
||||
// memories - 多媒体记忆(音视频等)
|
||||
{
|
||||
id: string
|
||||
groupId: string
|
||||
uploader: string (userId)
|
||||
title: string
|
||||
description: string
|
||||
file: string (PocketBase file field)
|
||||
fileType: "image" | "video" | "audio" | "other"
|
||||
size: number (bytes)
|
||||
createdAt: date
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 三期数据集合
|
||||
@@ -398,11 +411,12 @@ NAS 服务器
|
||||
|
||||
### 第二期
|
||||
|
||||
**目标**: 预约 + 积分 + 荣誉
|
||||
**目标**: 预约 + 积分 + 荣誉 + 记忆
|
||||
|
||||
- [ ] 预约系统(简化投票)
|
||||
- [ ] 积分系统(获取/记录)
|
||||
- [ ] 荣誉墙(自动授予)
|
||||
- [ ] 多媒体记忆(支持音视频等多媒体文件的上传、下载与在线预览)
|
||||
|
||||
### 第三期
|
||||
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# Game Group V2 — 三期功能设计
|
||||
|
||||
## 功能总览
|
||||
|
||||
| 优先级 | 功能 | 说明 |
|
||||
|--------|------|------|
|
||||
| P0 | 账目管理 | 群组费用流水记录,纯记录不分摊 |
|
||||
| P0 | 资产管理 | 群组公共资产清单,自由标记持有人 |
|
||||
|
||||
---
|
||||
|
||||
## P0: 账目管理
|
||||
|
||||
### 定位
|
||||
|
||||
群组内费用流水记录。成员记录每笔收入/支出(聚餐、游戏充值、设备采购等),纯记录,不做分摊和结算。
|
||||
|
||||
### 数据模型
|
||||
|
||||
**`ledgers` Collection:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| group | relation → groups | 所属群组 |
|
||||
| creator | relation → users | 记录人 |
|
||||
| type | select: `income` / `expense` | 收入/支出 |
|
||||
| amount | number | 金额 |
|
||||
| category | select: `gaming` / `food` / `equipment` / `transport` / `other` | 分类 |
|
||||
| description | text | 描述 |
|
||||
| relatedMembers | relation → users (multiple) | 相关成员 |
|
||||
| occurredAt | date | 发生时间 |
|
||||
|
||||
### 业务规则
|
||||
|
||||
- 所有群成员可记录和查看
|
||||
- 只有记录人可编辑/删除
|
||||
- 收入用绿色,支出用红色,直观区分
|
||||
|
||||
### 前端页面
|
||||
|
||||
路由:`/group/:groupId/ledger`
|
||||
|
||||
```
|
||||
components/ledger/
|
||||
├── LedgerList.vue # 账目列表(收入/支出分色显示)
|
||||
├── LedgerCard.vue # 单条账目卡片
|
||||
├── CreateLedgerDialog.vue # 新建账目弹窗
|
||||
└── LedgerSummary.vue # 汇总面板(总收入/总支出/余额)
|
||||
```
|
||||
|
||||
### API 层 (`api/ledgers.ts`)
|
||||
|
||||
- `createLedger(groupId, data)` — 新建记录
|
||||
- `listLedgers(groupId, filter?)` — 列表(支持按月筛选)
|
||||
- `updateLedger(ledgerId, data)` — 更新(仅记录人)
|
||||
- `deleteLedger(ledgerId)` — 删除(仅记录人)
|
||||
- `getLedgerSummary(groupId)` — 汇总统计
|
||||
|
||||
---
|
||||
|
||||
## P0: 资产管理
|
||||
|
||||
### 定位
|
||||
|
||||
群组公共资产清单。登记游戏账号、主机、手柄等物品,任何成员可自由标记当前持有人。空持有人表示资产在库。
|
||||
|
||||
### 数据模型
|
||||
|
||||
**`assets` Collection:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| group | relation → groups | 所属群组 |
|
||||
| creator | relation → users | 登记人 |
|
||||
| name | text | 资产名称 |
|
||||
| type | select: `game_account` / `console` / `equipment` / `accessory` / `other` | 类型 |
|
||||
| description | text, 可选 | 描述备注 |
|
||||
| currentHolder | relation → users, 可选 | 当前持有人(空=在库) |
|
||||
| image | file, 可选 | 资产照片 |
|
||||
|
||||
### 业务规则
|
||||
|
||||
- 所有群成员可登记和查看
|
||||
- 任何成员可更新持有人(标记自己拿走/放回)
|
||||
- 只有登记人可编辑/删除资产信息
|
||||
|
||||
### 前端页面
|
||||
|
||||
路由:`/group/:groupId/assets`
|
||||
|
||||
```
|
||||
components/asset/
|
||||
├── AssetList.vue # 资产列表(卡片网格)
|
||||
├── AssetCard.vue # 资产卡片(显示持有人头像)
|
||||
├── CreateAssetDialog.vue # 登记资产弹窗
|
||||
└── TransferAssetDialog.vue # 转移持有人弹窗(选择成员)
|
||||
```
|
||||
|
||||
### API 层 (`api/assets.ts`)
|
||||
|
||||
- `createAsset(groupId, data)` — 登记资产
|
||||
- `listAssets(groupId)` — 资产列表
|
||||
- `updateAsset(assetId, data)` — 更新信息
|
||||
- `deleteAsset(assetId)` — 删除
|
||||
- `transferAsset(assetId, userId?)` — 更新持有人(null=放回在库)
|
||||
|
||||
---
|
||||
|
||||
## 导航入口
|
||||
|
||||
GroupView 群组操作菜单中添加:
|
||||
- "账目"按钮 → 跳转 `/group/:groupId/ledger`
|
||||
- "资产"按钮 → 跳转 `/group/:groupId/assets`
|
||||
|
||||
---
|
||||
|
||||
## 实现文件变更总览
|
||||
|
||||
### 新增文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend/src/api/ledgers.ts` | 账目 API |
|
||||
| `frontend/src/api/assets.ts` | 资产 API |
|
||||
| `frontend/src/components/ledger/*.vue` | 账目组件 (4 个) |
|
||||
| `frontend/src/components/asset/*.vue` | 资产组件 (4 个) |
|
||||
| `frontend/src/views/LedgerView.vue` | 账目页面 |
|
||||
| `frontend/src/views/AssetView.vue` | 资产页面 |
|
||||
| `backend/pb_migrations/xxxx_create_ledgers.js` | 账目表迁移 |
|
||||
| `backend/pb_migrations/xxxx_create_assets.js` | 资产表迁移 |
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/types/index.ts` | 新增 Ledger、Asset 类型定义 |
|
||||
| `frontend/src/router/index.ts` | 新增 ledger、asset 路由 |
|
||||
| `frontend/src/views/GroupView.vue` | 添加账目/资产入口按钮 |
|
||||
|
||||
### 建议实现顺序
|
||||
|
||||
1. 数据库迁移(ledgers → assets)
|
||||
2. 类型定义 (types/index.ts)
|
||||
3. API 层 (ledgers.ts → assets.ts)
|
||||
4. 账目页面(组件 + 路由 + 导航入口)
|
||||
5. 资产页面(组件 + 路由 + 导航入口)
|
||||
@@ -0,0 +1,920 @@
|
||||
# Phase 3: Ledger + Asset Management 实施计划
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 为群组添加账目流水记录和公共资产清单功能,两个独立页面。
|
||||
|
||||
**Architecture:** 新增两个 PocketBase Collection(ledgers、assets),对应 API 层、类型定义、Pinia Store、Vue 组件和路由。通过 GroupView 操作入口跳转到独立页面。纯前端实现,无自定义后端逻辑。
|
||||
|
||||
**Tech Stack:** Vue 3 + TypeScript + Pinia + Element Plus + Tailwind CSS + PocketBase JS SDK
|
||||
|
||||
---
|
||||
|
||||
## Task 1: PocketBase 数据库迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/pb_migrations/1776510001_created_ledgers.js`
|
||||
- Create: `backend/pb_migrations/1776510002_created_assets.js`
|
||||
|
||||
**Step 1: 创建 ledgers 迁移文件**
|
||||
|
||||
```javascript
|
||||
// backend/pb_migrations/1776510001_created_ledgers.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "ledgers_col",
|
||||
"name": "ledgers",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["income", "expense"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_amount",
|
||||
"name": "amount",
|
||||
"type": "number",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 0.01,
|
||||
"max": null,
|
||||
"noDecimal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_category",
|
||||
"name": "category",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["gaming", "food", "equipment", "transport", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_members",
|
||||
"name": "relatedMembers",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": null,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_occurred",
|
||||
"name": "occurredAt",
|
||||
"type": "date",
|
||||
"required": true,
|
||||
"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("ledgers_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: 创建 assets 迁移文件**
|
||||
|
||||
```javascript
|
||||
// backend/pb_migrations/1776510002_created_assets.js
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "assets_col",
|
||||
"name": "assets",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_creator",
|
||||
"name": "creator",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_name",
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_type",
|
||||
"name": "type",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["game_account", "console", "equipment", "accessory", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_holder",
|
||||
"name": "currentHolder",
|
||||
"type": "relation",
|
||||
"required": false,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": false,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "sf_image",
|
||||
"name": "image",
|
||||
"type": "file",
|
||||
"required": false,
|
||||
"options": {
|
||||
"mimeTypes": ["image/*"],
|
||||
"thumbs": ["200x200"],
|
||||
"maxSelect": 1,
|
||||
"maxSize": 5242880,
|
||||
"protected": 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": "@request.auth.id != \"\" && group.members ~ @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("assets_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
```
|
||||
|
||||
**Step 3: 部署后端并验证迁移**
|
||||
|
||||
Run: `./deploy-backend.sh`(或在 UAT 环境部署后检查 PocketBase 管理面板,确认 ledgers 和 assets collections 已创建)
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/pb_migrations/1776510001_created_ledgers.js backend/pb_migrations/1776510002_created_assets.js
|
||||
git commit -m "feat(phase3): add ledgers and assets PocketBase migrations"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: TypeScript 类型定义
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/index.ts`
|
||||
|
||||
**Step 1: 在 types/index.ts 末尾(`displayName` 函数之前)添加账目和资产类型**
|
||||
|
||||
```typescript
|
||||
// 账目类型
|
||||
export type LedgerType = 'income' | 'expense'
|
||||
export type LedgerCategory = 'gaming' | 'food' | 'equipment' | 'transport' | 'other'
|
||||
|
||||
export const LedgerTypeMap: Record<LedgerType, string> = {
|
||||
income: '收入',
|
||||
expense: '支出'
|
||||
}
|
||||
|
||||
export const LedgerCategoryMap: Record<LedgerCategory, string> = {
|
||||
gaming: '游戏',
|
||||
food: '聚餐',
|
||||
equipment: '设备',
|
||||
transport: '交通',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
export interface Ledger {
|
||||
id: string
|
||||
group: string
|
||||
creator: string
|
||||
type: LedgerType
|
||||
amount: number
|
||||
category: LedgerCategory
|
||||
description: string
|
||||
relatedMembers: string[]
|
||||
occurredAt: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
creator?: User
|
||||
group?: Group
|
||||
relatedMembers?: User[]
|
||||
}
|
||||
}
|
||||
|
||||
// 资产类型
|
||||
export type AssetType = 'game_account' | 'console' | 'equipment' | 'accessory' | 'other'
|
||||
|
||||
export const AssetTypeMap: Record<AssetType, string> = {
|
||||
game_account: '游戏账号',
|
||||
console: '主机',
|
||||
equipment: '设备',
|
||||
accessory: '配件',
|
||||
other: '其他'
|
||||
}
|
||||
|
||||
export interface Asset {
|
||||
id: string
|
||||
group: string
|
||||
creator: string
|
||||
name: string
|
||||
type: AssetType
|
||||
description?: string
|
||||
currentHolder?: string
|
||||
image?: string
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
creator?: User
|
||||
group?: Group
|
||||
currentHolder?: User
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/index.ts
|
||||
git commit -m "feat(phase3): add Ledger and Asset type definitions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: API 层 — 账目
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/ledgers.ts`
|
||||
|
||||
**Step 1: 创建 ledgers API**
|
||||
|
||||
遵循 `api/memories.ts` 的模式:导入 pb 实例、类型、async 函数、expand 关联数据、subscribe 实时订阅。
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/ledgers.ts
|
||||
import { pb } from './pocketbase'
|
||||
import type { Ledger } from '@/types'
|
||||
|
||||
export async function createLedger(data: {
|
||||
group: string
|
||||
type: 'income' | 'expense'
|
||||
amount: number
|
||||
category: string
|
||||
description: string
|
||||
relatedMembers?: string[]
|
||||
occurredAt: string
|
||||
}): Promise<Ledger> {
|
||||
const user = pb.authStore.model
|
||||
const record = await pb.collection('ledgers').create({
|
||||
group: data.group,
|
||||
creator: user?.id,
|
||||
type: data.type,
|
||||
amount: data.amount,
|
||||
category: data.category,
|
||||
description: data.description,
|
||||
relatedMembers: data.relatedMembers || [],
|
||||
occurredAt: data.occurredAt,
|
||||
})
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function listLedgers(
|
||||
groupId: string,
|
||||
options?: {
|
||||
page?: number
|
||||
limit?: number
|
||||
type?: string
|
||||
category?: string
|
||||
month?: string // "2026-04" 格式
|
||||
}
|
||||
): Promise<{ items: Ledger[]; total: number }> {
|
||||
const { page = 1, limit = 30, type, category, month } = options || {}
|
||||
const filters = [`group="${groupId}"`]
|
||||
if (type) filters.push(`type="${type}"`)
|
||||
if (category) filters.push(`category="${category}"`)
|
||||
if (month) {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const start = new Date(y, m - 1, 1).toISOString().slice(0, 10)
|
||||
const end = new Date(y, m, 1).toISOString().slice(0, 10)
|
||||
filters.push(`occurredAt >= "${start}"`)
|
||||
filters.push(`occurredAt < "${end}"`)
|
||||
}
|
||||
|
||||
const result = await pb.collection('ledgers').getList(page, limit, {
|
||||
filter: filters.join(' && '),
|
||||
sort: '-occurredAt',
|
||||
expand: 'creator,relatedMembers'
|
||||
})
|
||||
return { items: result.items as unknown as Ledger[], total: result.totalItems }
|
||||
}
|
||||
|
||||
export async function updateLedger(
|
||||
ledgerId: string,
|
||||
data: Partial<Pick<Ledger, 'type' | 'amount' | 'category' | 'description' | 'relatedMembers' | 'occurredAt'>>
|
||||
): Promise<Ledger> {
|
||||
const record = await pb.collection('ledgers').update(ledgerId, data)
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function deleteLedger(ledgerId: string): Promise<void> {
|
||||
await pb.collection('ledgers').delete(ledgerId)
|
||||
}
|
||||
|
||||
export async function getLedgerSummary(groupId: string, month?: string): Promise<{
|
||||
totalIncome: number
|
||||
totalExpense: number
|
||||
balance: number
|
||||
}> {
|
||||
const filters = [`group="${groupId}"`]
|
||||
if (month) {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const start = new Date(y, m - 1, 1).toISOString().slice(0, 10)
|
||||
const end = new Date(y, m, 1).toISOString().slice(0, 10)
|
||||
filters.push(`occurredAt >= "${start}"`)
|
||||
filters.push(`occurredAt < "${end}"`)
|
||||
}
|
||||
|
||||
let totalIncome = 0
|
||||
let totalExpense = 0
|
||||
let page = 1
|
||||
const batchSize = 500
|
||||
let hasMore = true
|
||||
|
||||
while (hasMore) {
|
||||
const result = await pb.collection('ledgers').getList(page, batchSize, {
|
||||
filter: filters.join(' && '),
|
||||
fields: 'type,amount'
|
||||
})
|
||||
for (const item of result.items as any[]) {
|
||||
if (item.type === 'income') totalIncome += item.amount || 0
|
||||
else totalExpense += item.amount || 0
|
||||
}
|
||||
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
|
||||
page++
|
||||
}
|
||||
|
||||
return { totalIncome, totalExpense, balance: totalIncome - totalExpense }
|
||||
}
|
||||
|
||||
export function subscribeLedgers(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
) {
|
||||
return pb.collection('ledgers').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/ledgers.ts
|
||||
git commit -m "feat(phase3): add ledgers API layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: API 层 — 资产
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/assets.ts`
|
||||
|
||||
**Step 1: 创建 assets API**
|
||||
|
||||
```typescript
|
||||
// frontend/src/api/assets.ts
|
||||
import { pb } from './pocketbase'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
export async function createAsset(data: {
|
||||
group: string
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
image?: File
|
||||
}): Promise<Asset> {
|
||||
const user = pb.authStore.model
|
||||
const formData = new FormData()
|
||||
formData.append('group', data.group)
|
||||
formData.append('creator', user?.id || '')
|
||||
formData.append('name', data.name)
|
||||
formData.append('type', data.type)
|
||||
if (data.description) formData.append('description', data.description)
|
||||
if (data.image) formData.append('image', data.image)
|
||||
|
||||
const record = await pb.collection('assets').create(formData)
|
||||
return record as unknown as Asset
|
||||
}
|
||||
|
||||
export async function listAssets(groupId: string): Promise<Asset[]> {
|
||||
const result = await pb.collection('assets').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: 'created',
|
||||
expand: 'creator,currentHolder'
|
||||
})
|
||||
return result as unknown as Asset[]
|
||||
}
|
||||
|
||||
export async function updateAsset(
|
||||
assetId: string,
|
||||
data: Partial<Pick<Asset, 'name' | 'type' | 'description'>>,
|
||||
image?: File
|
||||
): Promise<Asset> {
|
||||
const formData = new FormData()
|
||||
if (data.name) formData.append('name', data.name)
|
||||
if (data.type) formData.append('type', data.type)
|
||||
if (data.description !== undefined) formData.append('description', data.description)
|
||||
if (image) formData.append('image', image)
|
||||
|
||||
const record = await pb.collection('assets').update(assetId, formData)
|
||||
return record as unknown as Asset
|
||||
}
|
||||
|
||||
export async function transferAsset(assetId: string, userId: string | null): Promise<Asset> {
|
||||
const record = await pb.collection('assets').update(assetId, {
|
||||
currentHolder: userId || ''
|
||||
})
|
||||
return record as unknown as Asset
|
||||
}
|
||||
|
||||
export async function deleteAsset(assetId: string): Promise<void> {
|
||||
await pb.collection('assets').delete(assetId)
|
||||
}
|
||||
|
||||
export function subscribeAssets(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
) {
|
||||
return pb.collection('assets').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
|
||||
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
|
||||
return pb.files.getURL({ id: assetId, collectionName: 'assets' }, filename, { thumb })
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/assets.ts
|
||||
git commit -m "feat(phase3): add assets API layer"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Pinia Store — 账目
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/stores/ledger.ts`
|
||||
|
||||
**Step 1: 创建 ledger store**
|
||||
|
||||
遵循 `stores/poll.ts` 的模式。
|
||||
|
||||
```typescript
|
||||
// frontend/src/stores/ledger.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Ledger } from '@/types'
|
||||
import { listLedgers, getLedgerSummary, subscribeLedgers, createLedger, updateLedger, deleteLedger } from '@/api/ledgers'
|
||||
|
||||
export const useLedgerStore = defineStore('ledger', () => {
|
||||
const ledgers = ref<Ledger[]>([])
|
||||
const loading = ref(false)
|
||||
const summary = ref({ totalIncome: 0, totalExpense: 0, balance: 0 })
|
||||
const currentMonth = ref(new Date().toISOString().slice(0, 7))
|
||||
|
||||
async function loadLedgers(groupId: string, month?: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
const m = month || currentMonth.value
|
||||
const result = await listLedgers(groupId, { month: m, limit: 200 })
|
||||
ledgers.value = result.items
|
||||
} catch (error) {
|
||||
console.error('加载账目列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary(groupId: string, month?: string) {
|
||||
try {
|
||||
const m = month || currentMonth.value
|
||||
summary.value = await getLedgerSummary(groupId, m)
|
||||
} catch (error) {
|
||||
console.error('加载账目汇总失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function addLedger(data: Parameters<typeof createLedger>[0]) {
|
||||
const ledger = await createLedger(data)
|
||||
return ledger
|
||||
}
|
||||
|
||||
async function editLedger(ledgerId: string, data: Parameters<typeof updateLedger>[1]) {
|
||||
return updateLedger(ledgerId, data)
|
||||
}
|
||||
|
||||
async function removeLedger(ledgerId: string) {
|
||||
await deleteLedger(ledgerId)
|
||||
}
|
||||
|
||||
async function startSubscription(groupId: string) {
|
||||
return subscribeLedgers(groupId, () => {
|
||||
loadLedgers(groupId)
|
||||
loadSummary(groupId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ledgers, loading, summary, currentMonth,
|
||||
loadLedgers, loadSummary, addLedger, editLedger, removeLedger, startSubscription
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/ledger.ts
|
||||
git commit -m "feat(phase3): add ledger Pinia store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Pinia Store — 资产
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/stores/asset.ts`
|
||||
|
||||
**Step 1: 创建 asset store**
|
||||
|
||||
```typescript
|
||||
// frontend/src/stores/asset.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Asset } from '@/types'
|
||||
import { listAssets, createAsset, updateAsset, transferAsset, deleteAsset as deleteAssetApi, subscribeAssets } from '@/api/assets'
|
||||
|
||||
export const useAssetStore = defineStore('asset', () => {
|
||||
const assets = ref<Asset[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadAssets(groupId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
assets.value = await listAssets(groupId)
|
||||
} catch (error) {
|
||||
console.error('加载资产列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addAsset(data: Parameters<typeof createAsset>[0]) {
|
||||
return createAsset(data)
|
||||
}
|
||||
|
||||
async function editAsset(assetId: string, data: Parameters<typeof updateAsset>[1], image?: File) {
|
||||
return updateAsset(assetId, data, image)
|
||||
}
|
||||
|
||||
async function transfer(assetId: string, userId: string | null) {
|
||||
return transferAsset(assetId, userId)
|
||||
}
|
||||
|
||||
async function removeAsset(assetId: string) {
|
||||
await deleteAssetApi(assetId)
|
||||
}
|
||||
|
||||
async function startSubscription(groupId: string) {
|
||||
return subscribeAssets(groupId, () => {
|
||||
loadAssets(groupId)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
assets, loading,
|
||||
loadAssets, addAsset, editAsset, transfer, removeAsset, startSubscription
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/stores/asset.ts
|
||||
git commit -m "feat(phase3): add asset Pinia store"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: 账目组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/ledger/LedgerSummary.vue`
|
||||
- Create: `frontend/src/components/ledger/LedgerCard.vue`
|
||||
- Create: `frontend/src/components/ledger/LedgerList.vue`
|
||||
- Create: `frontend/src/components/ledger/CreateLedgerDialog.vue`
|
||||
|
||||
**Step 1: 创建 LedgerSummary 组件**
|
||||
|
||||
汇总面板:显示当月总收入、总支出、余额。Element Plus 的 `el-statistic` 或自定义卡片。
|
||||
|
||||
参考项目中 `GroupStatsPanel.vue` 的卡片样式,使用 `var(--gg-*)` CSS 变量。
|
||||
|
||||
**Step 2: 创建 LedgerCard 组件**
|
||||
|
||||
单条账目卡片:类型标签(收入绿/支出红)、金额、分类、描述、相关成员头像、发生日期、操作按钮(编辑/删除,仅记录人可见)。
|
||||
|
||||
**Step 3: 创建 LedgerList 组件**
|
||||
|
||||
账目列表:顶部月份选择器 + 类型/分类筛选 + 新建按钮;下方按日期分组展示 LedgerCard 列表。
|
||||
|
||||
**Step 4: 创建 CreateLedgerDialog 组件**
|
||||
|
||||
新建/编辑弹窗:类型选择、金额输入、分类选择、描述输入、相关成员多选、日期选择器。使用 `el-dialog` + `el-form`。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/ledger/
|
||||
git commit -m "feat(phase3): add ledger components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: 资产组件
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/asset/AssetCard.vue`
|
||||
- Create: `frontend/src/components/asset/AssetList.vue`
|
||||
- Create: `frontend/src/components/asset/CreateAssetDialog.vue`
|
||||
- Create: `frontend/src/components/asset/TransferAssetDialog.vue`
|
||||
|
||||
**Step 1: 创建 AssetCard 组件**
|
||||
|
||||
资产卡片(网格布局):资产图片(有则显示缩略图,无则显示类型图标)、名称、类型标签、当前持有人头像和名称、点击可操作。
|
||||
|
||||
**Step 2: 创建 AssetList 组件**
|
||||
|
||||
资产列表:顶部类型筛选 + 新建按钮;下方网格展示 AssetCard。
|
||||
|
||||
**Step 3: 创建 CreateAssetDialog 组件**
|
||||
|
||||
登记/编辑弹窗:名称输入、类型选择、描述输入、图片上传。使用 `el-dialog` + `el-form` + `el-upload`。
|
||||
|
||||
**Step 4: 创建 TransferAssetDialog 组件**
|
||||
|
||||
转移持有人弹窗:显示资产名称,选择群组成员作为新持有人(含"放回在库"选项)。使用 `el-dialog` + 群成员列表。
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/asset/
|
||||
git commit -m "feat(phase3): add asset components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: 页面与路由
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/LedgerView.vue`
|
||||
- Create: `frontend/src/views/AssetView.vue`
|
||||
- Modify: `frontend/src/router/index.ts` — 新增两条路由
|
||||
|
||||
**Step 1: 创建 LedgerView.vue**
|
||||
|
||||
独立页面:顶部返回群组按钮 + 群组名;LedgerSummary 汇总面板;LedgerList 账目列表。
|
||||
|
||||
加载数据、订阅实时更新、页面卸载时取消订阅。参考 GroupView.vue 的订阅管理模式。
|
||||
|
||||
```typescript
|
||||
// 核心结构
|
||||
const route = useRoute()
|
||||
const groupId = route.params.groupId as string
|
||||
const groupStore = useGroupStore()
|
||||
const ledgerStore = useLedgerStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
await Promise.all([
|
||||
ledgerStore.loadLedgers(groupId),
|
||||
ledgerStore.loadSummary(groupId)
|
||||
])
|
||||
unsubFns.push(await ledgerStore.startSubscription(groupId))
|
||||
})
|
||||
```
|
||||
|
||||
**Step 2: 创建 AssetView.vue**
|
||||
|
||||
独立页面:顶部返回群组按钮 + 群组名;AssetList 资产列表。
|
||||
|
||||
**Step 3: 在 router/index.ts 中添加路由**
|
||||
|
||||
在 Layout children 数组中添加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: 'group/:groupId/ledger',
|
||||
name: 'LedgerView',
|
||||
component: () => import('@/views/LedgerView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/assets',
|
||||
name: 'AssetView',
|
||||
component: () => import('@/views/AssetView.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/LedgerView.vue frontend/src/views/AssetView.vue frontend/src/router/index.ts
|
||||
git commit -m "feat(phase3): add ledger and asset pages with routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: GroupView 导航入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/views/GroupView.vue` — 添加账目/资产入口按钮
|
||||
|
||||
**Step 1: 在 GroupView 的 header-meta 区域添加入口按钮**
|
||||
|
||||
在群组信息条中添加"账目"和"资产"两个链接按钮,点击跳转到对应页面。使用 `router.push` 跳转。
|
||||
|
||||
参考现有样式,使用 Element Plus 的 `Wallet` 和 `Box` 图标(或 `Coin`、`Present` 等)。
|
||||
|
||||
```html
|
||||
<!-- 在 header-meta 中添加 -->
|
||||
<span class="meta-divider">|</span>
|
||||
<router-link :to="`/group/${groupId}/ledger`" class="meta-link">
|
||||
<el-icon><Wallet /></el-icon> 账目
|
||||
</router-link>
|
||||
<span class="meta-divider">|</span>
|
||||
<router-link :to="`/group/${groupId}/assets`" class="meta-link">
|
||||
<el-icon><Box /></el-icon> 资产
|
||||
</router-link>
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/GroupView.vue
|
||||
git commit -m "feat(phase3): add ledger and asset navigation in GroupView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: 构建验证与部署
|
||||
|
||||
**Step 1: 构建前端验证无报错**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
|
||||
**Step 2: 部署到 Dev 环境测试**
|
||||
|
||||
Run: `./deploy-dev.sh`
|
||||
|
||||
**Step 3: 在 Dev 环境手动测试**
|
||||
|
||||
访问 `http://192.168.1.14:7033`,测试:
|
||||
- 群组页面能看到"账目"和"资产"入口
|
||||
- 点击进入账目页面,创建收入/支出记录,查看汇总
|
||||
- 点击进入资产页面,登记资产,转移持有人
|
||||
- 其他成员能看到记录变化(实时订阅)
|
||||
|
||||
**Step 4: 最终 Commit**
|
||||
|
||||
如有修复则提交。
|
||||
@@ -0,0 +1,74 @@
|
||||
# 语音房间功能设计
|
||||
|
||||
## 概述
|
||||
|
||||
在组队功能中加入实时语音通话,基于 LiveKit(开源 WebRTC SFU),独立语音房间页面。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Vue 前端 (VoiceRoom) ←→ PocketBase (token 签发 hook) ←→ LiveKit (音视频 SFU)
|
||||
```
|
||||
|
||||
## 技术选型
|
||||
|
||||
- **LiveKit Server** — 开源 WebRTC SFU,Docker 部署
|
||||
- **livekit-client** — 前端核心 SDK
|
||||
- **@livekit/components-vue** — Vue 组件封装
|
||||
- **livekit-server-sdk (Node.js)** — PocketBase hook 中签发 token
|
||||
|
||||
## 数据模型
|
||||
|
||||
team_sessions 新增字段:
|
||||
- `voiceRoom: string` — LiveKit 房间名
|
||||
- `voiceActive: boolean` — 语音房间是否活跃
|
||||
|
||||
## Token 签发
|
||||
|
||||
PocketBase JS hook (`/api/voice-token/{sessionId}`):
|
||||
1. 验证请求者是 session 成员
|
||||
2. 用 LiveKit API key/secret 签发 JWT
|
||||
3. room = `team-{sessionId}`,identity = userId
|
||||
4. 返回 `{ token: "..." }`
|
||||
|
||||
## 前端
|
||||
|
||||
### 路由
|
||||
|
||||
`/group/:groupId/voice/:sessionId` — 独立语音房间页面
|
||||
|
||||
### 页面结构 (VoiceRoom.vue)
|
||||
|
||||
- 顶部:房间名 + 离开按钮
|
||||
- 中部:成员头像网格,说话时绿圈动画
|
||||
- 底部:麦克风开关、扬声器开关、成员列表
|
||||
|
||||
### 入口
|
||||
|
||||
GroupView 组队卡片上加"语音"按钮,recruiting/playing 状态时显示。
|
||||
|
||||
## 文件变更
|
||||
|
||||
### 新增
|
||||
|
||||
- `frontend/src/views/VoiceRoom.vue`
|
||||
- `frontend/src/api/voice.ts`
|
||||
- `frontend/src/composables/useVoiceRoom.ts`
|
||||
- `frontend/src/components/voice/VoiceMemberGrid.vue`
|
||||
- `frontend/src/components/voice/VoiceControls.vue`
|
||||
- `backend/pb_hooks/voice-token.pb.js`
|
||||
- `backend/pb_hooks/package.json`
|
||||
|
||||
### 修改
|
||||
|
||||
- `frontend/src/types/index.ts` — TeamSession 加字段
|
||||
- `frontend/src/router/index.ts` — 加路由
|
||||
- `frontend/src/views/GroupView.vue` — 加入口按钮
|
||||
- `docker-compose.backend.yml` — 加 LiveKit 容器
|
||||
- `docker-compose.uat.yml` — 同步加 LiveKit
|
||||
|
||||
## 部署
|
||||
|
||||
- PocketBase 启用 JS hooks
|
||||
- LiveKit 端口:7880 (HTTP)、7881 (UDP/RTC)
|
||||
- 局域网无需 TURN 服务器
|
||||
@@ -0,0 +1,924 @@
|
||||
# 语音房间功能 Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 在组队功能中集成 LiveKit 实时语音通话,提供独立语音房间页面。
|
||||
|
||||
**Architecture:** LiveKit Server (Docker) 提供 WebRTC SFU 服务。PocketBase JS hook 签发 LiveKit token。前端用 livekit-client + @livekit/components-vue 实现语音房间 UI。
|
||||
|
||||
**Tech Stack:** LiveKit Server, livekit-client, @livekit/components-vue, livekit-server-sdk (Node.js)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Docker 基础设施 — 添加 LiveKit 容器
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.backend.yml`
|
||||
- Modify: `docker-compose.uat.yml`
|
||||
|
||||
**Step 1: 在 docker-compose.backend.yml 添加 LiveKit 服务**
|
||||
|
||||
在 pocketbase 服务之后、networks 之前添加:
|
||||
|
||||
```yaml
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.7
|
||||
container_name: gamegroup-livekit
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
environment:
|
||||
- LIVEKIT_KEYS="APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
```
|
||||
|
||||
**Step 2: 在 docker-compose.uat.yml 同步添加**
|
||||
|
||||
在 pocketbase-uat 服务之后、frontend-uat 之前添加同样的 LiveKit 服务,端口相同(UAT 和 Dev 共享同一台机器的 Docker 网络)。
|
||||
|
||||
**Step 3: 启动验证**
|
||||
|
||||
Run: `docker compose -f docker-compose.backend.yml up -d livekit`
|
||||
Expected: 容器正常启动,`docker logs gamegroup-livekit` 显示 LiveKit listening on :7880
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.backend.yml docker-compose.uat.yml
|
||||
git commit -m "feat(voice): add LiveKit server container to docker-compose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 安装前端 LiveKit 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
|
||||
**Step 1: 安装依赖**
|
||||
|
||||
Run: `cd frontend && npm install livekit-client @livekit/components-vue`
|
||||
|
||||
**Step 2: 验证安装**
|
||||
|
||||
Run: `cd frontend && npm ls livekit-client @livekit/components-vue`
|
||||
Expected: 两个包正常列出
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "feat(voice): add livekit-client and components-vue dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端 API 层 — voice.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/voice.ts`
|
||||
|
||||
**Step 1: 创建 voice.ts API 封装**
|
||||
|
||||
```typescript
|
||||
// src/api/voice.ts
|
||||
import { pb } from './pocketbase'
|
||||
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
|
||||
|
||||
export function getLiveKitUrl(): string {
|
||||
return LIVEKIT_URL
|
||||
}
|
||||
|
||||
export async function fetchVoiceToken(sessionId: string): Promise<string> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 先验证用户是该 session 的成员
|
||||
const session = await pb.collection('team_sessions').getOne(sessionId)
|
||||
const members: string[] = (session as any).members || []
|
||||
if (!members.includes(user.id)) {
|
||||
throw new Error('你不是该小队的成员')
|
||||
}
|
||||
|
||||
// 调用 PocketBase hook 获取 token
|
||||
// 如果 hook 未部署,用前端 fallback 方案(仅开发用)
|
||||
try {
|
||||
const res = await pb.send(`/api/voice-token/${sessionId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return res.token
|
||||
} catch {
|
||||
// Hook 不可用时,提示用户
|
||||
throw new Error('语音服务暂不可用,请检查 LiveKit 配置')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 添加 .env 配置**
|
||||
|
||||
在 `frontend/.env.dev` 和 `frontend/.env.uat` 中添加:
|
||||
```
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/voice.ts frontend/.env.dev frontend/.env.uat
|
||||
git commit -m "feat(voice): add voice API layer with LiveKit token fetch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 类型更新 — TeamSession 加语音字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/index.ts:80-94`
|
||||
|
||||
**Step 1: 在 TeamSession interface 中添加字段**
|
||||
|
||||
在 `updated: string` 之后、`expand?` 之前添加:
|
||||
|
||||
```typescript
|
||||
voiceRoom?: string
|
||||
voiceActive?: boolean
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/index.ts
|
||||
git commit -m "feat(voice): add voiceRoom and voiceActive fields to TeamSession type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: composable — useVoiceRoom.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useVoiceRoom.ts`
|
||||
|
||||
**Step 1: 创建 useVoiceRoom composable**
|
||||
|
||||
```typescript
|
||||
// src/composables/useVoiceRoom.ts
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { Room, RoomEvent, Track, RemoteParticipant, LocalParticipant } from 'livekit-client'
|
||||
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
|
||||
|
||||
export interface VoiceParticipant {
|
||||
identity: string
|
||||
name: string
|
||||
isSpeaking: boolean
|
||||
isMuted: boolean
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export function useVoiceRoom() {
|
||||
const room = ref<Room | null>(null)
|
||||
const connected = ref(false)
|
||||
const participants = ref<Map<string, VoiceParticipant>>(new Map())
|
||||
const micEnabled = ref(true)
|
||||
const speakerEnabled = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function connect(sessionId: string, userId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const token = await fetchVoiceToken(sessionId)
|
||||
const livekitUrl = getLiveKitUrl()
|
||||
|
||||
const newRoom = new Room()
|
||||
|
||||
newRoom.on(RoomEvent.TrackSubscribed, (_track, pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
||||
participants.value.delete(participant.identity)
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
const speakerIds = new Set(speakers.map(s => s.identity))
|
||||
for (const [id, p] of participants.value) {
|
||||
p.isSpeaking = speakerIds.has(id)
|
||||
}
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackMuted, (pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackUnmuted, (pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
await newRoom.connect(livekitUrl, token)
|
||||
|
||||
// 添加本地参与者
|
||||
updateParticipant(newRoom.localParticipant)
|
||||
|
||||
// 添加已有远端参与者
|
||||
for (const p of newRoom.remoteParticipants.values()) {
|
||||
updateParticipant(p)
|
||||
}
|
||||
|
||||
// 发布本地麦克风
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(true)
|
||||
|
||||
room.value = newRoom
|
||||
connected.value = true
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '连接语音房间失败'
|
||||
console.error('Voice room connect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticipant(participant: LocalParticipant | RemoteParticipant) {
|
||||
const isLocal = participant instanceof LocalParticipant
|
||||
const audioTrack = isLocal
|
||||
? participant.audioTrackPublications.values().next().value
|
||||
: participant.audioTrackPublications.values().next().value
|
||||
|
||||
const vp: VoiceParticipant = {
|
||||
identity: participant.identity,
|
||||
name: participant.name || participant.identity,
|
||||
isSpeaking: participant.isSpeaking,
|
||||
isMuted: audioTrack?.isMuted ?? true,
|
||||
}
|
||||
|
||||
participants.value.set(participant.identity, vp)
|
||||
participants.value = new Map(participants.value)
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
if (!room.value) return
|
||||
const enabled = !micEnabled.value
|
||||
await room.value.localParticipant.setMicrophoneEnabled(enabled)
|
||||
micEnabled.value = enabled
|
||||
updateParticipant(room.value.localParticipant)
|
||||
}
|
||||
|
||||
async function toggleSpeaker() {
|
||||
if (!room.value) return
|
||||
speakerEnabled.value = !speakerEnabled.value
|
||||
for (const p of room.value.remoteParticipants.values()) {
|
||||
for (const pub of p.audioTrackPublications.values()) {
|
||||
if (pub.track) {
|
||||
pub.track.enabled = speakerEnabled.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (room.value) {
|
||||
await room.value.disconnect()
|
||||
room.value = null
|
||||
}
|
||||
connected.value = false
|
||||
participants.value = new Map()
|
||||
micEnabled.value = true
|
||||
speakerEnabled.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
room,
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useVoiceRoom.ts
|
||||
git commit -m "feat(voice): add useVoiceRoom composable with LiveKit connection management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 语音组件 — VoiceMemberGrid + VoiceControls
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/voice/VoiceMemberGrid.vue`
|
||||
- Create: `frontend/src/components/voice/VoiceControls.vue`
|
||||
|
||||
**Step 1: 创建 VoiceMemberGrid.vue**
|
||||
|
||||
成员头像网格,说话时有绿圈呼吸动画。
|
||||
|
||||
```vue
|
||||
<!-- src/components/voice/VoiceMemberGrid.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
|
||||
import { displayName } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
participants: Map<string, VoiceParticipant>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-member-grid">
|
||||
<div
|
||||
v-for="[id, p] of participants"
|
||||
:key="id"
|
||||
class="voice-member"
|
||||
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
|
||||
>
|
||||
<div class="avatar-ring">
|
||||
<img
|
||||
:src="p.avatar || '/default-avatar.svg'"
|
||||
:alt="p.name"
|
||||
class="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span v-if="p.isMuted" class="mic-icon muted">🎤✕</span>
|
||||
<span v-else-if="p.isSpeaking" class="mic-icon active">🎤</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-member-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.voice-member {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
border: 2px solid var(--gg-border);
|
||||
transition: border-color 0.2s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.speaking .avatar-ring {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
|
||||
animation: pulse-ring 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.muted .avatar-ring {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
|
||||
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
text-align: center;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mic-icon.active {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.mic-icon.muted {
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 2: 创建 VoiceControls.vue**
|
||||
|
||||
底部控制栏。
|
||||
|
||||
```vue
|
||||
<!-- src/components/voice/VoiceControls.vue -->
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
micEnabled: boolean
|
||||
speakerEnabled: boolean
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleMic: []
|
||||
toggleSpeaker: []
|
||||
leave: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-controls">
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: micEnabled, off: !micEnabled }"
|
||||
@click="emit('toggleMic')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
|
||||
<span class="ctrl-label">麦克风</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: speakerEnabled, off: !speakerEnabled }"
|
||||
@click="emit('toggleSpeaker')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
|
||||
<span class="ctrl-label">扬声器</span>
|
||||
</button>
|
||||
|
||||
<button class="ctrl-btn leave-btn" @click="emit('leave')">
|
||||
<span class="ctrl-icon">🚪</span>
|
||||
<span class="ctrl-label">离开</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--gg-bg-card);
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 24px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ctrl-btn.off {
|
||||
background: var(--gg-bg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ctrl-btn.active {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.leave-btn {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.leave-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: var(--gg-danger);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/voice/VoiceMemberGrid.vue frontend/src/components/voice/VoiceControls.vue
|
||||
git commit -m "feat(voice): add VoiceMemberGrid and VoiceControls components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 语音房间页面 — VoiceRoom.vue
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/VoiceRoom.vue`
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
|
||||
**Step 1: 创建 VoiceRoom.vue**
|
||||
|
||||
```vue
|
||||
<!-- src/views/VoiceRoom.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useVoiceRoom } from '@/composables/useVoiceRoom'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import VoiceMemberGrid from '@/components/voice/VoiceMemberGrid.vue'
|
||||
import VoiceControls from '@/components/voice/VoiceControls.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const teamStore = useTeamStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const sessionId = route.params.sessionId as string
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const session = computed(() => teamStore.currentSession)
|
||||
const gameName = computed(() => session.value?.gameName || '语音房间')
|
||||
|
||||
const {
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
} = useVoiceRoom()
|
||||
|
||||
onMounted(async () => {
|
||||
// 加载 session 数据
|
||||
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
|
||||
await teamStore.loadActiveSession()
|
||||
}
|
||||
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await connect(sessionId, userId)
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
await disconnect()
|
||||
})
|
||||
|
||||
async function handleLeave() {
|
||||
await disconnect()
|
||||
router.replace(`/group/${groupId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-room">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="voice-header">
|
||||
<button class="back-btn" @click="handleLeave">
|
||||
← 返回
|
||||
</button>
|
||||
<h1 class="room-title">{{ gameName }}</h1>
|
||||
<div class="conn-status" :class="{ on: connected }">
|
||||
{{ connected ? '已连接' : '连接中...' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-banner">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- 成员区域 -->
|
||||
<main class="voice-body">
|
||||
<VoiceMemberGrid :participants="participants" />
|
||||
<div v-if="participants.size === 0" class="empty-hint">
|
||||
正在连接语音...
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<VoiceControls
|
||||
:mic-enabled="micEnabled"
|
||||
:speaker-enabled="speakerEnabled"
|
||||
:connected="connected"
|
||||
@toggle-mic="toggleMic"
|
||||
@toggle-speaker="toggleSpeaker"
|
||||
@leave="handleLeave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--gg-bg);
|
||||
}
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border-bottom: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.conn-status {
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.conn-status.on {
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 16px 24px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--gg-danger);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
color: var(--gg-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.voice-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 2: 添加路由**
|
||||
|
||||
在 `frontend/src/router/index.ts` 中,在 `blacklist` 路由之后、`games` 路由之前添加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: 'group/:groupId/voice/:sessionId',
|
||||
name: 'VoiceRoom',
|
||||
component: () => import('@/views/VoiceRoom.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/VoiceRoom.vue frontend/src/router/index.ts
|
||||
git commit -m "feat(voice): add VoiceRoom page and route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 入口按钮 — TeamSessionPanel 加语音入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/team/TeamSessionPanel.vue:110-120`
|
||||
|
||||
**Step 1: 在 TeamSessionPanel 中添加语音按钮**
|
||||
|
||||
在 `end-game-btn` 按钮之后、`dissolve-btn` 之前(约第 116 行区域),添加语音房间入口:
|
||||
|
||||
在 `<script setup>` 中导入 useRoute:
|
||||
```typescript
|
||||
import { useRoute } from 'vue-router'
|
||||
const route = useRoute()
|
||||
```
|
||||
|
||||
在 template 中,`start-game-btn` 按钮之后、`end-game-btn` 之前添加:
|
||||
|
||||
```html
|
||||
<router-link
|
||||
v-if="session.status === 'recruiting' || session.status === 'playing'"
|
||||
:to="`/group/${route.params.id}/voice/${session.id}`"
|
||||
class="voice-btn"
|
||||
>
|
||||
语音房间
|
||||
</router-link>
|
||||
```
|
||||
|
||||
添加对应样式:
|
||||
|
||||
```css
|
||||
.voice-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--gg-primary);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.voice-btn:hover {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/team/TeamSessionPanel.vue
|
||||
git commit -m "feat(voice): add voice room entry button to TeamSessionPanel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: PocketBase hook — 签发 LiveKit token
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/pb_hooks/main.js`
|
||||
- Create: `backend/pb_hooks/package.json`
|
||||
|
||||
**注意:** PocketBase 0.22.4 muchobien 镜像可能不支持 JS hooks(参见现有 main.js 的注释)。如果 hook 不工作,前端 voice.ts 已经有 fallback 错误提示。此 Task 为可选增强。
|
||||
|
||||
**Step 1: 创建 package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "gamegroup-pb-hooks",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"livekit-server-sdk": "^2.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cd backend/pb_hooks && npm install`
|
||||
|
||||
**Step 2: 更新 main.js — 添加 token 签发路由**
|
||||
|
||||
在现有注释之后添加:
|
||||
|
||||
```javascript
|
||||
// LiveKit voice token endpoint
|
||||
routerAdd("POST", "/api/voice-token/{sessionId}", (c) => {
|
||||
const sessionId = c.pathParam("sessionId")
|
||||
const user = c.authRecord
|
||||
if (!user) {
|
||||
throw new BadRequestError("未登录")
|
||||
}
|
||||
|
||||
// 验证用户是 session 成员
|
||||
const session = $app.dao().findRecordById("team_sessions", sessionId)
|
||||
const members = session.getStringSlice("members")
|
||||
if (!members.includes(user.id)) {
|
||||
throw new ForbiddenError("你不是该小队的成员")
|
||||
}
|
||||
|
||||
// LiveKit token 签发
|
||||
// 注意:需要 livekit-server-sdk,如果 PocketBase JS VM 不支持 npm 模块,
|
||||
// 可以改用外部 API 或 PocketBase Go middleware
|
||||
const { AccessToken } = require("livekit-server-sdk")
|
||||
|
||||
const apiKey = "APIyxZGQjM2"
|
||||
const apiSecret = "secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
|
||||
const at = new AccessToken(apiKey, apiSecret, {
|
||||
identity: user.id,
|
||||
name: user.getString("name") || user.getString("username"),
|
||||
})
|
||||
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: `team-${sessionId}`,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
})
|
||||
|
||||
const token = at.toJwt()
|
||||
|
||||
return c.json(200, { token: token })
|
||||
}, $apis.requireAuth())
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/pb_hooks/main.js backend/pb_hooks/package.json backend/pb_hooks/package-lock.json
|
||||
git commit -m "feat(voice): add PocketBase hook for LiveKit token issuance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 构建验证 + 部署测试
|
||||
|
||||
**Files:** 无新文件
|
||||
|
||||
**Step 1: 前端构建**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: 构建成功,无 TypeScript 错误
|
||||
|
||||
**Step 2: 部署 Dev 环境**
|
||||
|
||||
Run: `./deploy-dev.sh`
|
||||
Expected: 前端容器构建并启动在 7033 端口
|
||||
|
||||
**Step 3: 功能验证**
|
||||
|
||||
打开 `http://192.168.1.14:7033`:
|
||||
1. 登录 → 进入群组 → 创建/查看临时小组
|
||||
2. 组队卡片中应显示"语音房间"按钮
|
||||
3. 点击进入语音房间页面(此时因 LiveKit 容器未启动会显示错误,属正常)
|
||||
4. 启动 LiveKit 容器后重试验证完整流程
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "feat(voice): voice room feature complete - v0.4.0"
|
||||
```
|
||||
@@ -0,0 +1,182 @@
|
||||
# 手机端前端(uat 重做)设计文档
|
||||
|
||||
> 日期:2026-06-18
|
||||
> 仓库:gamegroup2
|
||||
> 分支:`uat`(基于 `origin/uat`)
|
||||
> 状态:已确认,待实施
|
||||
> 背景:master 上已有 8 个移动端 commit(基于 6-15 设计),但基于**旧 master**,不知 uat 后续新增的 PC 端功能。本次在 uat 上**重新做**移动端:迁移 master 已写好的实现 + 适配 uat 的 PC 端变化 + 补全 uat 新增功能的移动端页面。
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
**目标**:在 uat 分支上产出完整、可用的手机端前端,覆盖 uat PC 端的全部功能(含信息公示板、事件/活动、游戏库增强、邀请落地页等 uat 独有功能),手机浏览器访问自动进入移动版。
|
||||
|
||||
**做法**(用户已定):
|
||||
- **选项 A**:在 uat 上重新做整套移动端(不是直接 cherry-pick 8 个 commit)
|
||||
- **"重新做"含义**:迁移 master 上已写好的 22 个移动端文件 + 基础设施,然后适配 uat 的 API/stores/types/router 变化,并补全 uat 独有功能的移动端页面。不从零写。
|
||||
- **新功能覆盖**:全部覆盖(公示板浏览+发帖、事件浏览+创建、游戏库详情+评论+收藏+添加+导入、邀请落地页 2 个)
|
||||
- **设备分流**:路由层分流(沿用 master 方案)
|
||||
- **实施节奏**:分阶段提交,每阶段可验证
|
||||
|
||||
## 2. 与 master 移动端的差异(迁移适配点)
|
||||
|
||||
### 2.1 uat 相对 master 新增的 PC 端功能域(移动端需补)
|
||||
|
||||
| 功能域 | PC 端位置 | 移动端处理 |
|
||||
|---|---|---|
|
||||
| 信息公示板 (bulletin) | `components/bulletin/*` (4) + `api/bulletins.ts` | 新增 `components-mobile/bulletin/*` + 群组详情新增"公示板" tab |
|
||||
| 事件/活动 (event) | `components/event/*` (3) + `api/events.ts` | 新增 `components-mobile/event/*` + 群组详情新增"活动" tab + 首页接入事件板 |
|
||||
| 游戏库增强 | `components/game/*` (AddGame/GameDetail/GameForm/ImportGames) | GamesLibraryMobile 增加添加/导入入口;游戏详情面板增加评论+收藏 |
|
||||
| 邀请落地页 | `views/JoinGroupPage.vue`、`JoinTeamPage.vue` | 新增 `views-mobile/JoinGroupPageMobile.vue`、`JoinTeamPageMobile.vue`,路由层分流 |
|
||||
|
||||
### 2.2 uat 相对 master 的基础设施变化(迁移要适配)
|
||||
|
||||
| 项 | master 移动端写法 | uat 现状 | 适配方式 |
|
||||
|---|---|---|---|
|
||||
| router/index.ts | 旧结构(无 JoinGroup/JoinTeam 路由) | 有 `JoinGroup`/`JoinTeam` 顶层路由 + Layout children 嵌套 | **在 uat router 基础上叠加**:每个路由 component 用 `isMobile()` 三元分流,保留 JoinGroup/JoinTeam |
|
||||
| package.json deps | 仅 vant | uat 已有 `livekit-client`、`tailwindcss`、`vue-tsc` 等 | 迁移时仅追加 `vant@^4` + `@vant/auto-import-resolver`,不动其他 |
|
||||
| vite.config.ts | master 已加 auto-import-resolver + 手动分包 | uat 是基础配置 | 迁移 master 的 vant 插件配置 + 在 uat 基础上追加 manualChunks(保留 uat 现有 proxy/alias) |
|
||||
| index.html viewport | master 改为含 `maximum-scale=1, viewport-fit=cover` | uat 基础版 | 采用 master 版本(适配键盘弹起、刘海屏) |
|
||||
| types/index.ts | 移动端引用的 Event/Bulletin 等类型 | uat 已新增这些类型 | 迁移代码直接复用 uat 的 types,无需改动 |
|
||||
|
||||
### 2.3 pocketbase 导出兼容性(已确认)
|
||||
|
||||
uat 的 `api/pocketbase.ts` 同时提供 **命名导出 `{ pb }`** 和 **默认导出 `pb`**。master 移动端代码用默认导入 `import pb from './pocketbase'` 兼容;bulletins.ts 用命名导入也兼容。**无需改动**。
|
||||
|
||||
## 3. 目录结构(uat 上最终形态)
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├─ api/ # 共享(uat 已有 events.ts/bulletins.ts/invitations.ts)
|
||||
├─ stores/ # 共享
|
||||
├─ types/ # 共享(uat 已含 Event/BulletinPost 等类型)
|
||||
├─ composables/ # 共享
|
||||
├─ assets/
|
||||
│ ├─ design.css # 共享
|
||||
│ └─ mobile.css # 迁移:Vant 主题映射
|
||||
├─ views/ # uat PC 端(不动)
|
||||
├─ components/ # uat PC 端(不动)
|
||||
├─ views-mobile/ # 迁移 + 新增
|
||||
│ ├─ LoginMobile.vue / RegisterMobile.vue / HomeMobile.vue / GroupsMobile.vue
|
||||
│ ├─ GroupViewMobile.vue / VoiceRoomMobile.vue
|
||||
│ ├─ GamesLibraryMobile.vue(增强:添加/导入入口)
|
||||
│ ├─ LedgerMobile.vue / AssetMobile.vue / BlacklistMobile.vue
|
||||
│ ├─ NotificationsMobile.vue / ProfileMobile.vue / SettingsMobile.vue / ChangelogMobile.vue
|
||||
│ ├─ Placeholder.vue
|
||||
│ └─ JoinGroupPageMobile.vue / JoinTeamPageMobile.vue # 新增
|
||||
├─ components-mobile/ # 迁移 + 新增
|
||||
│ ├─ bet/BetListMobile.vue
|
||||
│ ├─ poll/PollListMobile.vue
|
||||
│ ├─ memory/MemoryGridMobile.vue
|
||||
│ ├─ stats/StatsPanelMobile.vue
|
||||
│ ├─ group/ActivityFeedMobile.vue / MemberListMobile.vue
|
||||
│ ├─ game/GameDetailSheetMobile.vue # 新增(详情底部面板)
|
||||
│ ├─ game/AddGameSheetMobile.vue # 新增(添加游戏)
|
||||
│ ├─ game/ImportGamesSheetMobile.vue # 新增(导入游戏)
|
||||
│ ├─ bulletin/BulletinListMobile.vue # 新增(公示板列表)
|
||||
│ ├─ bulletin/BulletinPostSheetMobile.vue # 新增(发帖/详情)
|
||||
│ ├─ event/EventListMobile.vue # 新增(事件列表)
|
||||
│ └─ event/CreateEventSheetMobile.vue # 新增(创建事件)
|
||||
├─ mobile/
|
||||
│ ├─ MobileLayout.vue # 迁移
|
||||
│ ├─ useDevice.ts # 迁移
|
||||
│ └─ DeviceGuard.ts # 迁移(若 master 有)
|
||||
├─ router/index.ts # 在 uat 基础上叠加 isMobile() 分流
|
||||
└─ main.ts # 追加 vant 按需注册 + mobile.css 引入
|
||||
```
|
||||
|
||||
## 4. 实施阶段(11 个阶段,每阶段一次提交,可验证)
|
||||
|
||||
每个阶段完成后在 uat 提交,提交信息前缀 `feat(mobile): `。每阶段验证标准见各阶段说明。
|
||||
|
||||
### 阶段 1:基础设施迁移
|
||||
**迁移内容**:
|
||||
- `package.json`:追加 `vant@^4` + `@vant/auto-import-resolver`
|
||||
- `vite.config.ts`:在 uat 配置基础上追加 Vant 自动导入 resolver + manualChunks 分包
|
||||
- `src/assets/mobile.css`:迁移(Vant 主题变量映射到 `--gg-*`)
|
||||
- `src/mobile/useDevice.ts`:迁移
|
||||
- `src/mobile/MobileLayout.vue`:迁移(已去除 logout handler 的版本)
|
||||
- `index.html`:viewport 改为含 `maximum-scale=1, viewport-fit=cover`
|
||||
- `src/main.ts`:追加 `import 'vant/lib/index.css'` + `import './assets/mobile.css'` + vant 按需注册
|
||||
- `src/router/index.ts`:在 uat 现有 router 上叠加——每个业务路由 component 改为 `isMobile() ? mobile : desktop`,保留 JoinGroup/JoinTeam 路由(也加分流)
|
||||
- `src/views-mobile/Placeholder.vue`:迁移(占位页,让分流能跑起来)
|
||||
|
||||
**验证**:`npm install` + `npm run build` 通过;PC 浏览器访问仍是桌面版;移动 UA 访问看到 Placeholder。
|
||||
|
||||
### 阶段 2:认证
|
||||
迁移 `LoginMobile.vue` + `RegisterMobile.vue`,适配 uat 的 user store / pocketbase API(已确认兼容)。
|
||||
**验证**:手机 UA 访问 `/login`、`/register` 正常,登录注册全流程通。
|
||||
|
||||
### 阶段 3:首页 + 群组列表
|
||||
迁移 `HomeMobile.vue` + `GroupsMobile.vue`。
|
||||
**适配**:首页接入事件板(listEvents 调用 + 事件卡片展示),适配 uat 的 group store 字段变化。
|
||||
**验证**:首页显示状态/群组/热门游戏/事件板;群组列表创建/加入正常。
|
||||
|
||||
### 阶段 4:群组详情核心
|
||||
迁移 `GroupViewMobile.vue` + `components-mobile/group/ActivityFeedMobile.vue` + `MemberListMobile.vue`。
|
||||
**适配**:标签栏结构对照 uat PC GroupView 的 tab 列表,预留"活动"和"公示板"两个新 tab 位置(阶段 10/11 填充)。
|
||||
**验证**:群组详情 9 个原有标签可访问。
|
||||
|
||||
### 阶段 5:组队 + 语音房
|
||||
迁移 `VoiceRoomMobile.vue` + 适配 uat 的 team store / TeamSessionPanel 相关逻辑(GameSelectDialog/InviteButton 等的移动端交互通过 ActionSheet 实现)。
|
||||
**验证**:发起组队/邀请/接受/状态切换正常;语音房 UI 显示占位提示。
|
||||
|
||||
### 阶段 6:投票 + 竞猜
|
||||
迁移 `components-mobile/poll/PollListMobile.vue` + `bet/BetListMobile.vue`,适配 uat 的 poll/bet store。
|
||||
**验证**:创建/参与/结算正常。
|
||||
|
||||
### 阶段 7:游戏库增强
|
||||
迁移 `GamesLibraryMobile.vue` + 新增 `components-mobile/game/GameDetailSheetMobile.vue`(详情底部面板:封面/平台/标签/评论/收藏/发起组队)、`AddGameSheetMobile.vue`、`ImportGamesSheetMobile.vue`。
|
||||
**适配**:GamesLibraryMobile 增加添加/导入入口按钮,接 `api/games.ts` 的 create/import。
|
||||
**验证**:浏览/搜索/详情/评论/收藏/添加/导入正常。
|
||||
|
||||
### 阶段 8:账本 + 资产 + 黑名单
|
||||
迁移 `LedgerMobile.vue` + `AssetMobile.vue` + `BlacklistMobile.vue`。
|
||||
**验证**:增删改查正常,资产转移正常。
|
||||
|
||||
### 阶段 9:回忆 + 统计 + 个人/设置/更新日志
|
||||
迁移 `MemoryGridMobile.vue` + `StatsPanelMobile.vue` + `ProfileMobile.vue` + `SettingsMobile.vue` + `ChangelogMobile.vue`。
|
||||
**适配**:Profile/Changelog 对照 uat v0.3.5/0.3.6 新数据。
|
||||
**验证**:回忆上传/浏览正常;统计只读展示;个人/设置/日志正常。
|
||||
|
||||
### 阶段 10:信息公示板
|
||||
新增 `components-mobile/bulletin/BulletinListMobile.vue`(置顶帖+普通列表)+ `BulletinPostSheetMobile.vue`(发帖/查看详情)。
|
||||
集成:群组详情标签栏新增"公示板" tab(接 `api/bulletins.ts`)。
|
||||
**验证**:群内发帖/置顶/浏览正常。
|
||||
|
||||
### 阶段 11:事件 + 邀请落地页
|
||||
新增 `components-mobile/event/EventListMobile.vue` + `CreateEventSheetMobile.vue`;群组详情新增"活动" tab;首页事件板(阶段 3 已接)的事件卡片点击进事件详情。
|
||||
新增 `views-mobile/JoinGroupPageMobile.vue` + `JoinTeamPageMobile.vue`,路由层 `JoinGroup`/`JoinTeam` 加 `isMobile()` 分流。
|
||||
**验证**:事件创建/RSVP/浏览正常;邀请链接手机端访问显示移动版落地页。
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
- [ ] PC 浏览器访问 uat 仍是桌面版,行为不变
|
||||
- [ ] 手机浏览器访问 uat 自动进入移动版
|
||||
- [ ] 登录/注册/退出全流程正常
|
||||
- [ ] 首页:状态切换、群组入口、热门游戏、事件板正常
|
||||
- [ ] 群组详情:9 个原有标签 + 活动 + 公示板 = 11 个标签可访问
|
||||
- [ ] 组队/语音房占位 UI 正常
|
||||
- [ ] 投票/竞猜:创建/参与/结算正常
|
||||
- [ ] 游戏库:浏览/搜索/详情/评论/收藏/添加/导入正常
|
||||
- [ ] 账本/资产/黑名单:增删改查 + 资产转移正常
|
||||
- [ ] 回忆上传/浏览、统计只读正常
|
||||
- [ ] 信息公示板:发帖/置顶/浏览正常
|
||||
- [ ] 事件:创建/RSVP/浏览正常
|
||||
- [ ] 邀请落地页:手机端访问 `/join/group/:id` 和 `/join/team/:id` 显示移动版
|
||||
- [ ] `npm run build` 通过,无 TS 错误
|
||||
|
||||
## 6. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| uat 的 stores/types 与 master 移动端代码假设不符 | 阶段 1-9 迁移时逐阶段核对 `git diff master..HEAD` 的 stores/types 改动,按报错适配 |
|
||||
| Vant + Element Plus 样式冲突 | 路由分流隔离,不同时挂载;mobile.css 已映射主题变量 |
|
||||
| 邀请落地页在未登录态下访问,移动版守卫逻辑 | JoinGroup/JoinTeam 路由 meta 不加 requiresAuth(沿用 uat),分流只换组件 |
|
||||
| 构建体积 | Vant 按需引入 + manualChunks 分包(master 已验证) |
|
||||
|
||||
## 7. 不在本次范围内
|
||||
|
||||
- 语音 WebRTC 实际接入(移动端仅 UI + 占位)
|
||||
- PWA / 离线支持
|
||||
- 原生推送通知
|
||||
- 桌面端代码改动
|
||||
@@ -0,0 +1 @@
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
@@ -0,0 +1,195 @@
|
||||
const { app, BrowserWindow, session, dialog, ipcMain } = require('electron')
|
||||
const { autoUpdater } = require('electron-updater')
|
||||
const path = require('path')
|
||||
const Store = require('electron-store')
|
||||
|
||||
const store = new Store()
|
||||
|
||||
// 同步 IPC:供 preload 读写持久化数据
|
||||
ipcMain.on('store-get-sync', (event, key) => {
|
||||
event.returnValue = store.get(key)
|
||||
})
|
||||
ipcMain.on('store-set-sync', (event, key, value) => {
|
||||
store.set(key, value)
|
||||
event.returnValue = true
|
||||
})
|
||||
ipcMain.on('store-delete-sync', (event, key) => {
|
||||
store.delete(key)
|
||||
event.returnValue = true
|
||||
})
|
||||
|
||||
const ENV_URLS = {
|
||||
dev: 'http://192.168.1.14:7033',
|
||||
uat: 'http://nas.wjl-work.top:7034',
|
||||
}
|
||||
|
||||
function getWindowUrl() {
|
||||
const envArg = process.argv.find(a => a.startsWith('--env='))
|
||||
if (envArg) {
|
||||
const env = envArg.split('=')[1]
|
||||
return ENV_URLS[env] || ENV_URLS.dev
|
||||
}
|
||||
return ENV_URLS.dev
|
||||
}
|
||||
|
||||
function getEnvName() {
|
||||
const url = getWindowUrl()
|
||||
if (url.includes('7034')) return 'uat'
|
||||
return 'dev'
|
||||
}
|
||||
|
||||
// 在 Chromium 启动前将 HTTP 内网地址标记为安全源,
|
||||
// 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空
|
||||
const insecureOrigins = Object.values(ENV_URLS).join(',')
|
||||
app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins)
|
||||
|
||||
let mainWindow = null
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
title: 'Game Group',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
allowRunningInsecureContent: true,
|
||||
},
|
||||
})
|
||||
|
||||
mainWindow = win
|
||||
|
||||
const url = getWindowUrl()
|
||||
win.loadURL(url)
|
||||
|
||||
// 页面标题同步
|
||||
win.on('page-title-updated', (event, title) => {
|
||||
event.preventDefault()
|
||||
win.setTitle(`Game Group - ${title}`)
|
||||
})
|
||||
|
||||
win.on('closed', () => {
|
||||
mainWindow = null
|
||||
})
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
// 自动更新配置
|
||||
function setupAutoUpdater(win) {
|
||||
const env = getEnvName()
|
||||
const feedUrl = `${ENV_URLS[env]}/electron-update/`
|
||||
|
||||
autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl })
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = false
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
console.log('[updater] Checking for update at', feedUrl)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
console.log('[updater] Update available:', info.version)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('update-available', info)
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: '发现新版本',
|
||||
message: `发现新版本 ${info.version},是否立即下载更新?`,
|
||||
buttons: ['立即下载', '稍后再说'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
}).then(({ response }) => {
|
||||
if (response === 0) {
|
||||
autoUpdater.downloadUpdate().catch((err) => {
|
||||
console.error('[updater] Download failed:', err.message)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
console.log('[updater] No update available')
|
||||
})
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
console.error('[updater] Error:', err.message)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('update-error', err.message)
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
console.log(`[updater] Download progress: ${progress.percent.toFixed(1)}%`)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('download-progress', progress)
|
||||
}
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
console.log('[updater] Update downloaded:', info.version)
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('update-downloaded', info)
|
||||
dialog.showMessageBox(win, {
|
||||
type: 'info',
|
||||
title: '更新就绪',
|
||||
message: `新版本 ${info.version} 已下载完成,是否立即重启安装?`,
|
||||
buttons: ['立即重启', '稍后'],
|
||||
defaultId: 0,
|
||||
}).then(({ response }) => {
|
||||
if (response === 0) {
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 手动触发检查更新(供前端调用)
|
||||
ipcMain.on('check-for-updates', () => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error('[updater] Manual check failed:', err.message)
|
||||
})
|
||||
})
|
||||
|
||||
ipcMain.on('quit-and-install', () => {
|
||||
autoUpdater.quitAndInstall()
|
||||
})
|
||||
|
||||
// 启动后延迟 5 秒检查更新(等窗口稳定后再检查)
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error('[updater] Initial check failed:', err.message)
|
||||
})
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
// 自动批准麦克风/摄像头权限请求,无需用户手动确认
|
||||
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
if (permission === 'media' || permission === 'microphone' || permission === 'camera') {
|
||||
callback(true)
|
||||
} else {
|
||||
callback(false)
|
||||
}
|
||||
})
|
||||
|
||||
// 绕过权限检查,确保 mediaDevices 可用
|
||||
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
|
||||
if (permission === 'media' || permission === 'microphone' || permission === 'camera') {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const win = createWindow()
|
||||
setupAutoUpdater(win)
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "gamegroup-electron",
|
||||
"version": "0.3.3",
|
||||
"description": "Game Group V2 桌面客户端",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
"start:dev": "electron . --env=dev",
|
||||
"start:uat": "electron . --env=uat",
|
||||
"bump": "node scripts/bump-version.js",
|
||||
"bump:minor": "node scripts/bump-version.js minor",
|
||||
"bump:major": "node scripts/bump-version.js major",
|
||||
"build:dev": "node scripts/bump-version.js && electron-builder --win --config scripts/build.dev.json && node scripts/copy-to-nas.js dev",
|
||||
"build:uat": "node scripts/bump-version.js && electron-builder --win && node scripts/copy-to-nas.js uat",
|
||||
"build:portable": "node scripts/bump-version.js && electron-builder --win portable && node scripts/copy-to-nas.js uat",
|
||||
"copy-to-nas": "node scripts/copy-to-nas.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-store": "^8.2",
|
||||
"electron-updater": "^6.8.3",
|
||||
"png-to-ico": "^3.0.1",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^35.0",
|
||||
"electron-builder": "^26.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.gamegroup.v2",
|
||||
"productName": "GameGroup",
|
||||
"directories": {
|
||||
"output": "dist"
|
||||
},
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "build/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true,
|
||||
"shortcutName": "GameGroup"
|
||||
},
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "http://nas.wjl-work.top:7034/electron-update/",
|
||||
"channel": "latest"
|
||||
},
|
||||
"files": [
|
||||
"main.js",
|
||||
"preload.js",
|
||||
"build/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
platform: process.platform,
|
||||
|
||||
// 自动更新相关 IPC
|
||||
onUpdateAvailable: (callback) => ipcRenderer.on('update-available', (_event, info) => callback(info)),
|
||||
onUpdateError: (callback) => ipcRenderer.on('update-error', (_event, message) => callback(message)),
|
||||
onDownloadProgress: (callback) => ipcRenderer.on('download-progress', (_event, progress) => callback(progress)),
|
||||
onUpdateDownloaded: (callback) => ipcRenderer.on('update-downloaded', (_event, info) => callback(info)),
|
||||
|
||||
checkForUpdates: () => ipcRenderer.send('check-for-updates'),
|
||||
quitAndInstall: () => ipcRenderer.send('quit-and-install'),
|
||||
|
||||
// 持久化存储(用于记住登录状态)
|
||||
storeGet: (key) => ipcRenderer.sendSync('store-get-sync', key),
|
||||
storeSet: (key, value) => ipcRenderer.sendSync('store-set-sync', key, value),
|
||||
storeDelete: (key) => ipcRenderer.sendSync('store-delete-sync', key),
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "http://192.168.1.14:7033/electron-update/",
|
||||
"channel": "latest"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const type = process.argv[2] || 'patch' // patch | minor | major
|
||||
const pkgPath = path.join(__dirname, '..', 'package.json')
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
||||
|
||||
let [major, minor, patch] = pkg.version.split('.').map(Number)
|
||||
|
||||
switch (type) {
|
||||
case 'major':
|
||||
major++
|
||||
minor = 0
|
||||
patch = 0
|
||||
break
|
||||
case 'minor':
|
||||
minor++
|
||||
patch = 0
|
||||
break
|
||||
case 'patch':
|
||||
default:
|
||||
patch++
|
||||
break
|
||||
}
|
||||
|
||||
pkg.version = `${major}.${minor}.${patch}`
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
|
||||
console.log(`[bump-version] ${type} -> ${pkg.version}`)
|
||||
@@ -0,0 +1,63 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const env = process.argv[2] || 'uat'
|
||||
const srcDir = path.join(__dirname, '..', 'dist')
|
||||
// 项目根目录下的 electron-update/{env},用于 docker volume 挂载给 nginx
|
||||
const localUpdateDir = path.join(__dirname, '..', '..', 'electron-update', env)
|
||||
// NAS 共享路径
|
||||
const nasDir = path.join('\\\\JIULUGNAS\\personal_folder\\CodeSpace\\GameGroup2\\electron-update', env)
|
||||
|
||||
function ensureDir(dir) {
|
||||
try {
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(`[copy-to-nas] failed to create dir ${dir}:`, err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function copyFilesTo(destDir, label) {
|
||||
if (!fs.existsSync(srcDir)) {
|
||||
console.log(`[copy-to-nas] dist not found, skipping ${label}`)
|
||||
return
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(srcDir).filter((f) => {
|
||||
const ext = path.extname(f).toLowerCase()
|
||||
return (
|
||||
ext === '.exe' ||
|
||||
ext === '.yml' ||
|
||||
ext === '.blockmap' ||
|
||||
ext === '.nupkg' ||
|
||||
ext === '.yaml'
|
||||
)
|
||||
})
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log(`[copy-to-nas] no update files found in dist, skipping ${label}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!ensureDir(destDir)) return
|
||||
|
||||
for (const file of files) {
|
||||
const src = path.join(srcDir, file)
|
||||
const dest = path.join(destDir, file)
|
||||
try {
|
||||
fs.copyFileSync(src, dest)
|
||||
console.log(`[copy-to-nas] copied ${file} -> ${destDir}`)
|
||||
} catch (err) {
|
||||
console.error(`[copy-to-nas] failed to copy ${file} to ${label}:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 复制到本地目录(供 docker volume 挂载)
|
||||
copyFilesTo(localUpdateDir, 'local')
|
||||
|
||||
// 复制到 NAS
|
||||
copyFilesTo(nasDir, 'NAS')
|
||||
@@ -1,3 +1,5 @@
|
||||
# Dev Environment
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7033
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7883
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# UAT Environment
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7034
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7890
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7893
|
||||
|
||||
+3
-2
@@ -22,8 +22,9 @@ FROM nginx:alpine
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制 nginx 配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
# 通过 NGINX_CONF 参数选择配置文件(默认 dev)
|
||||
ARG NGINX_CONF=nginx.conf
|
||||
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||
<title>Game Group V2</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎮</text></svg>">
|
||||
</head>
|
||||
|
||||
+38
-2
@@ -10,10 +10,46 @@ server {
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# SSE realtime 连接(必须在 /api/ 之前)
|
||||
location /api/realtime {
|
||||
proxy_pass http://192.168.1.14:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
gzip off;
|
||||
}
|
||||
|
||||
# Voice token service proxy
|
||||
location /voice-api/ {
|
||||
proxy_pass http://192.168.1.14:7883/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Electron 自动更新文件托管
|
||||
# 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录
|
||||
location /electron-update/ {
|
||||
alias /usr/share/nginx/html/electron-update/;
|
||||
autoindex on;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# 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;
|
||||
@@ -25,8 +61,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,68 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 开启 gzip 压缩
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
add_header Expires "0";
|
||||
}
|
||||
|
||||
# SSE realtime 连接(必须在 /api/ 之前)
|
||||
location /api/realtime {
|
||||
proxy_pass http://192.168.1.14:8712;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 86400s;
|
||||
gzip off;
|
||||
}
|
||||
|
||||
# Voice token service proxy
|
||||
location /voice-api/ {
|
||||
proxy_pass http://192.168.1.14:7893/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
# Electron 自动更新文件托管
|
||||
# 部署时将 electron/dist/latest.yml 和 electron/dist/*.exe 放到此目录
|
||||
location /electron-update/ {
|
||||
alias /usr/share/nginx/html/electron-update/;
|
||||
autoindex on;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
# API 代理到局域网 PocketBase
|
||||
location /api/ {
|
||||
proxy_pass http://192.168.1.14:8712;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 静态资源缓存(排除 /api/ 路径,避免拦截 PB 文件请求)
|
||||
location ~* ^/(?!api/|voice-api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
Generated
+2709
File diff suppressed because it is too large
Load Diff
+12
-10
@@ -10,20 +10,22 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.6.3",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"pocketbase": "^0.21.1"
|
||||
"element-plus": "^2.6.3",
|
||||
"livekit-client": "^2.18.3",
|
||||
"pinia": "^2.1.7",
|
||||
"pocketbase": "^0.21.1",
|
||||
"vant": "^4.9.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vue-tsc": "^2.0.11",
|
||||
"vite": "^5.2.8",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38"
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.8",
|
||||
"vue-tsc": "^2.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect fill="#e8f5e9" width="64" height="64" rx="32"/>
|
||||
<text x="32" y="40" text-anchor="middle" fill="#059669" font-size="28">👤</text>
|
||||
<defs>
|
||||
<linearGradient id="avBg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ecfdf5"/>
|
||||
<stop offset="100%" stop-color="#d1fae5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect fill="url(#avBg)" width="64" height="64" rx="32"/>
|
||||
<circle cx="32" cy="25" r="10" fill="none" stroke="#059669" stroke-width="2.5"/>
|
||||
<path d="M14 54c0-9 8-15 18-15s18 6 18 15" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 547 B |
@@ -1,5 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="260" viewBox="0 0 200 260">
|
||||
<rect fill="#e8f5e9" width="200" height="260"/>
|
||||
<text x="100" y="120" text-anchor="middle" fill="#059669" font-size="48">🎮</text>
|
||||
<text x="100" y="155" text-anchor="middle" fill="#6b7280" font-size="14">暂无封面</text>
|
||||
<defs>
|
||||
<linearGradient id="gBg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#f0fdf4"/>
|
||||
<stop offset="100%" stop-color="#dcfce7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect fill="url(#gBg)" width="200" height="260"/>
|
||||
<!-- 手柄主体 -->
|
||||
<rect x="44" y="108" width="112" height="58" rx="20" fill="none" stroke="#059669" stroke-width="3.5"/>
|
||||
<!-- 左侧十字方向 -->
|
||||
<path d="M70 137v-10M65 132h10" stroke="#059669" stroke-width="3.5" stroke-linecap="round"/>
|
||||
<!-- 右侧按钮 -->
|
||||
<circle cx="125" cy="128" r="3.2" fill="#10b981"/>
|
||||
<circle cx="138" cy="142" r="3.2" fill="#10b981"/>
|
||||
<text x="100" y="200" text-anchor="middle" fill="#059669" font-size="13" font-family="system-ui,sans-serif" font-weight="500">暂无封面</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 874 B |
@@ -0,0 +1,83 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { Asset } from '@/types'
|
||||
|
||||
export interface CreateAssetData {
|
||||
group: string
|
||||
name: string
|
||||
type: string
|
||||
description?: string
|
||||
image?: File
|
||||
}
|
||||
|
||||
export interface UpdateAssetData {
|
||||
name?: string
|
||||
type?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export async function createAsset(data: CreateAssetData): Promise<Asset> {
|
||||
const user = pb.authStore.model
|
||||
const formData = new FormData()
|
||||
formData.append('group', data.group)
|
||||
formData.append('creator', user?.id || '')
|
||||
formData.append('name', data.name)
|
||||
formData.append('type', data.type)
|
||||
if (data.description) formData.append('description', data.description)
|
||||
if (data.image) formData.append('image', data.image)
|
||||
|
||||
return pb.collection('assets').create(formData, { $autoCancel: false }) as Promise<Asset>
|
||||
}
|
||||
|
||||
export async function listAssets(groupId: string): Promise<Asset[]> {
|
||||
const result = await pb.collection('assets').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: 'created',
|
||||
expand: 'creator,currentHolder',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as Asset[]
|
||||
}
|
||||
|
||||
export async function updateAsset(
|
||||
assetId: string,
|
||||
data: UpdateAssetData,
|
||||
image?: File
|
||||
): Promise<Asset> {
|
||||
if (image) {
|
||||
const formData = new FormData()
|
||||
if (data.name) formData.append('name', data.name)
|
||||
if (data.type) formData.append('type', data.type)
|
||||
if (data.description !== undefined) formData.append('description', data.description)
|
||||
formData.append('image', image)
|
||||
|
||||
return pb.collection('assets').update(assetId, formData, { $autoCancel: false }) as Promise<Asset>
|
||||
}
|
||||
|
||||
return pb.collection('assets').update(assetId, data, { $autoCancel: false }) as Promise<Asset>
|
||||
}
|
||||
|
||||
export async function transferAsset(assetId: string, userId: string): Promise<Asset> {
|
||||
return pb.collection('assets').update(assetId, {
|
||||
currentHolder: userId
|
||||
}, { $autoCancel: false }) as Promise<Asset>
|
||||
}
|
||||
|
||||
export async function deleteAsset(assetId: string): Promise<void> {
|
||||
await pb.collection('assets').delete(assetId, { $autoCancel: false })
|
||||
}
|
||||
|
||||
export function subscribeAssets(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => Promise<void>> {
|
||||
return pb.collection('assets').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getAssetImageUrl(assetId: string, filename: string, thumb?: string): string {
|
||||
const record = { id: assetId, image: filename }
|
||||
return pb.files.getUrl(record, filename, thumb ? { thumb } : undefined)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
|
||||
export async function createBet(data: {
|
||||
group: string
|
||||
title: string
|
||||
description?: string
|
||||
options: string[]
|
||||
minStake?: number
|
||||
maxStake?: number
|
||||
deadline: string
|
||||
}): Promise<Bet> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const bet = await pb.collection('bets').create({
|
||||
group: data.group,
|
||||
creator: user.id,
|
||||
title: data.title,
|
||||
description: data.description || '',
|
||||
minStake: data.minStake || 1,
|
||||
maxStake: data.maxStake || 10,
|
||||
status: 'open',
|
||||
deadline: data.deadline,
|
||||
})
|
||||
|
||||
for (let i = 0; i < data.options.length; i++) {
|
||||
await pb.collection('bet_options').create({
|
||||
bet: bet.id,
|
||||
content: data.options[i],
|
||||
order: i + 1,
|
||||
})
|
||||
}
|
||||
|
||||
return bet as unknown as Bet
|
||||
}
|
||||
|
||||
export async function listBets(groupId: string, status?: string): Promise<Bet[]> {
|
||||
let filter = `group="${groupId}"`
|
||||
if (status) filter += ` && status="${status}"`
|
||||
|
||||
const result = await pb.collection('bets').getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
expand: 'creator',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as Bet[]
|
||||
}
|
||||
|
||||
export async function getBet(betId: string): Promise<Bet> {
|
||||
const result = await pb.collection('bets').getOne(betId, {
|
||||
expand: 'creator,resultOption',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as Bet
|
||||
}
|
||||
|
||||
export async function getBetOptions(betId: string): Promise<BetOption[]> {
|
||||
const result = await pb.collection('bet_options').getFullList({
|
||||
filter: `bet="${betId}"`,
|
||||
sort: 'order',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BetOption[]
|
||||
}
|
||||
|
||||
export async function getBetEntries(betId: string): Promise<BetEntry[]> {
|
||||
const result = await pb.collection('bet_entries').getFullList({
|
||||
filter: `bet="${betId}"`,
|
||||
expand: 'user,option',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BetEntry[]
|
||||
}
|
||||
|
||||
export async function placeBet(betId: string, optionId: string, stake: number): Promise<BetEntry> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 验证竞猜状态和下注范围 (H1)
|
||||
const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false })
|
||||
if ((betData as any).status !== 'open') throw new Error('竞猜已关闭,无法下注')
|
||||
if (stake < (betData as any).minStake || stake > (betData as any).maxStake) {
|
||||
throw new Error(`下注积分需在 ${(betData as any).minStake}~${(betData as any).maxStake} 之间`)
|
||||
}
|
||||
|
||||
// 防止重复下注
|
||||
const existing = await pb.collection('bet_entries').getList(1, 1, {
|
||||
filter: `bet="${betId}" && user="${user.id}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
if (existing.items.length > 0) throw new Error('你已经下注过了')
|
||||
|
||||
// 重新读取最新积分缓解 TOCTOU (C2)
|
||||
const currentUser = await pb.collection('users').getOne(user.id, { $autoCancel: false })
|
||||
const currentPoints = (currentUser as any).points || 0
|
||||
if (currentPoints < stake) throw new Error('积分不足')
|
||||
|
||||
await pb.collection('users').update(user.id, {
|
||||
points: currentPoints - stake,
|
||||
})
|
||||
|
||||
const entry = await pb.collection('bet_entries').create({
|
||||
bet: betId,
|
||||
user: user.id,
|
||||
option: optionId,
|
||||
stake,
|
||||
})
|
||||
|
||||
await pb.collection('point_logs').create({
|
||||
user: user.id,
|
||||
action: 'bet',
|
||||
points: -stake,
|
||||
relatedId: entry.id,
|
||||
})
|
||||
|
||||
return entry as unknown as BetEntry
|
||||
}
|
||||
|
||||
export async function closeBet(betId: string): Promise<Bet> {
|
||||
const record = await pb.collection('bets').update(betId, { status: 'closed' })
|
||||
return record as unknown as Bet
|
||||
}
|
||||
|
||||
export async function settleBet(betId: string, resultOptionId: string): Promise<void> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 先标记为 settled 防止双重结算 (C1)
|
||||
const betData = await pb.collection('bets').getOne(betId, { $autoCancel: false })
|
||||
if ((betData as any).status === 'settled') throw new Error('竞猜已结算,请勿重复操作')
|
||||
if ((betData as any).creator !== user.id) throw new Error('只有发起人可以开奖') // (H2)
|
||||
|
||||
const now = new Date().toISOString().replace('T', ' ').slice(0, 19) + '.000Z'
|
||||
await pb.collection('bets').update(betId, {
|
||||
status: 'settled',
|
||||
resultOption: resultOptionId,
|
||||
settledAt: now,
|
||||
})
|
||||
|
||||
const entries = await getBetEntries(betId)
|
||||
const totalPool = entries.reduce((sum, e) => sum + e.stake, 0)
|
||||
|
||||
const winnerEntries = entries.filter(e => e.option === resultOptionId)
|
||||
const loserEntries = entries.filter(e => e.option !== resultOptionId)
|
||||
|
||||
// 并行标记输赢
|
||||
const markPromises: Promise<void>[] = []
|
||||
|
||||
if (winnerEntries.length === 0) {
|
||||
// 无人猜中,退还所有积分
|
||||
for (const entry of entries) {
|
||||
markPromises.push((async () => {
|
||||
const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false })
|
||||
await pb.collection('users').update(entry.user, {
|
||||
points: ((entryUser as any).points || 0) + entry.stake,
|
||||
})
|
||||
await pb.collection('point_logs').create({
|
||||
user: entry.user,
|
||||
action: 'bet',
|
||||
points: entry.stake,
|
||||
relatedId: entry.id,
|
||||
})
|
||||
await pb.collection('bet_entries').update(entry.id, { won: false })
|
||||
})())
|
||||
}
|
||||
} else {
|
||||
const winnerTotalStake = winnerEntries.reduce((sum, e) => sum + e.stake, 0)
|
||||
let distributed = 0
|
||||
|
||||
for (const entry of winnerEntries) {
|
||||
const winAmount = Math.floor((entry.stake / winnerTotalStake) * totalPool)
|
||||
distributed += winAmount
|
||||
|
||||
markPromises.push((async () => {
|
||||
const entryUser = await pb.collection('users').getOne(entry.user, { $autoCancel: false })
|
||||
await pb.collection('users').update(entry.user, {
|
||||
points: ((entryUser as any).points || 0) + winAmount,
|
||||
})
|
||||
await pb.collection('point_logs').create({
|
||||
user: entry.user,
|
||||
action: 'bet',
|
||||
points: winAmount,
|
||||
relatedId: entry.id,
|
||||
})
|
||||
await pb.collection('bet_entries').update(entry.id, { won: true })
|
||||
})())
|
||||
}
|
||||
|
||||
// 余数分给最后一个赢家而非庄家 (C3)
|
||||
const remainder = totalPool - distributed
|
||||
if (remainder > 0 && winnerEntries.length > 0) {
|
||||
const lastWinner = winnerEntries[winnerEntries.length - 1]
|
||||
markPromises.push((async () => {
|
||||
const entryUser = await pb.collection('users').getOne(lastWinner.user, { $autoCancel: false })
|
||||
await pb.collection('users').update(lastWinner.user, {
|
||||
points: ((entryUser as any).points || 0) + remainder,
|
||||
})
|
||||
})())
|
||||
}
|
||||
|
||||
for (const entry of loserEntries) {
|
||||
markPromises.push(pb.collection('bet_entries').update(entry.id, { won: false }).then(() => {}))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(markPromises)
|
||||
}
|
||||
|
||||
export async function subscribeBets(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
return pb.collection('bets').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { BulletinPost, BulletinRead } from '@/types'
|
||||
|
||||
export async function createBulletin(data: {
|
||||
group: string
|
||||
title: string
|
||||
content: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned?: boolean
|
||||
expiresAt?: string
|
||||
}): Promise<BulletinPost> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const post = await pb.collection('bulletin_posts').create({
|
||||
group: data.group,
|
||||
creator: user.id,
|
||||
title: data.title,
|
||||
content: data.content,
|
||||
priority: data.priority || 'normal',
|
||||
pinned: data.pinned || false,
|
||||
expiresAt: data.expiresAt || '',
|
||||
})
|
||||
return post as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function listBulletins(groupId: string): Promise<BulletinPost[]> {
|
||||
const result = await pb.collection('bulletin_posts').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: '-pinned,-created',
|
||||
expand: 'creator',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BulletinPost[]
|
||||
}
|
||||
|
||||
export async function getBulletin(postId: string): Promise<BulletinPost> {
|
||||
const result = await pb.collection('bulletin_posts').getOne(postId, {
|
||||
expand: 'creator',
|
||||
})
|
||||
return result as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function updateBulletin(
|
||||
postId: string,
|
||||
data: {
|
||||
title?: string
|
||||
content?: string
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||
pinned?: boolean
|
||||
expiresAt?: string
|
||||
}
|
||||
): Promise<BulletinPost> {
|
||||
const payload: Record<string, any> = {}
|
||||
if (data.title !== undefined) payload.title = data.title
|
||||
if (data.content !== undefined) payload.content = data.content
|
||||
if (data.priority !== undefined) payload.priority = data.priority
|
||||
if (data.pinned !== undefined) payload.pinned = data.pinned
|
||||
if (data.expiresAt !== undefined) payload.expiresAt = data.expiresAt
|
||||
|
||||
const result = await pb.collection('bulletin_posts').update(postId, payload)
|
||||
return result as unknown as BulletinPost
|
||||
}
|
||||
|
||||
export async function deleteBulletin(postId: string): Promise<void> {
|
||||
await pb.collection('bulletin_posts').delete(postId)
|
||||
}
|
||||
|
||||
// 已读追踪
|
||||
export async function markBulletinAsRead(postId: string): Promise<BulletinRead | null> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return null
|
||||
|
||||
// 检查是否已存在
|
||||
const existing = await pb.collection('bulletin_reads').getList(1, 1, {
|
||||
filter: `post="${postId}" && user="${user.id}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
if (existing.items.length > 0) return existing.items[0] as unknown as BulletinRead
|
||||
|
||||
const result = await pb.collection('bulletin_reads').create({
|
||||
post: postId,
|
||||
user: user.id,
|
||||
})
|
||||
return result as unknown as BulletinRead
|
||||
}
|
||||
|
||||
export async function listMyReads(): Promise<BulletinRead[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
const result = await pb.collection('bulletin_reads').getFullList({
|
||||
filter: `user="${user.id}"`,
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BulletinRead[]
|
||||
}
|
||||
|
||||
// 实时订阅
|
||||
export async function subscribeBulletins(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
await pb.collection('bulletin_posts').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
pb.collection('bulletin_posts').unsubscribe('*')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import pb from './pocketbase'
|
||||
import type { Event, EventComment, EventRSVP, RSVPType } from '@/types'
|
||||
|
||||
// 获取群组的活动列表
|
||||
export async function listEvents(groupId: string): Promise<Event[]> {
|
||||
const result = await pb.collection('events').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: '-startTime',
|
||||
expand: 'creator',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as Event[]
|
||||
}
|
||||
|
||||
// 获取活动详情
|
||||
export async function getEvent(eventId: string): Promise<Event> {
|
||||
return pb.collection('events').getOne(eventId, {
|
||||
expand: 'creator,group',
|
||||
$autoCancel: false
|
||||
}) as unknown as Event
|
||||
}
|
||||
|
||||
// 创建活动
|
||||
export async function createEvent(data: {
|
||||
group: string
|
||||
title: string
|
||||
description?: string
|
||||
location?: string
|
||||
startTime: string
|
||||
endTime?: string
|
||||
maxParticipants?: number
|
||||
}): Promise<Event> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
return pb.collection('events').create({
|
||||
...data,
|
||||
creator: user.id,
|
||||
status: 'upcoming'
|
||||
}) as unknown as Event
|
||||
}
|
||||
|
||||
// 更新活动
|
||||
export async function updateEvent(eventId: string, data: Partial<Event>): Promise<Event> {
|
||||
return pb.collection('events').update(eventId, data) as unknown as Event
|
||||
}
|
||||
|
||||
// 删除活动
|
||||
export async function deleteEvent(eventId: string) {
|
||||
return pb.collection('events').delete(eventId)
|
||||
}
|
||||
|
||||
// 获取活动的评论
|
||||
export async function listEventComments(eventId: string): Promise<EventComment[]> {
|
||||
const result = await pb.collection('event_comments').getFullList({
|
||||
filter: `event="${eventId}"`,
|
||||
sort: '-created',
|
||||
expand: 'user',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as EventComment[]
|
||||
}
|
||||
|
||||
// 创建评论
|
||||
export async function createEventComment(eventId: string, content: string): Promise<EventComment> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
return pb.collection('event_comments').create({
|
||||
event: eventId,
|
||||
user: user.id,
|
||||
content
|
||||
}) as unknown as EventComment
|
||||
}
|
||||
|
||||
// 删除评论
|
||||
export async function deleteEventComment(commentId: string) {
|
||||
return pb.collection('event_comments').delete(commentId)
|
||||
}
|
||||
|
||||
// 获取活动的 RSVP 列表
|
||||
export async function listEventRSVPs(eventId: string): Promise<EventRSVP[]> {
|
||||
const result = await pb.collection('event_rsvps').getFullList({
|
||||
filter: `event="${eventId}"`,
|
||||
sort: '-created',
|
||||
expand: 'user',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as EventRSVP[]
|
||||
}
|
||||
|
||||
// 创建/更新 RSVP
|
||||
export async function setEventRSVP(
|
||||
eventId: string,
|
||||
type: RSVPType,
|
||||
comment?: string
|
||||
): Promise<EventRSVP> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 检查是否已有 RSVP
|
||||
const existing = await pb.collection('event_rsvps').getList(1, 1, {
|
||||
filter: `event="${eventId}" && user="${user.id}"`
|
||||
})
|
||||
|
||||
if (existing.items.length > 0) {
|
||||
return pb.collection('event_rsvps').update(existing.items[0].id, {
|
||||
type,
|
||||
comment
|
||||
}) as unknown as EventRSVP
|
||||
}
|
||||
|
||||
return pb.collection('event_rsvps').create({
|
||||
event: eventId,
|
||||
user: user.id,
|
||||
type,
|
||||
comment
|
||||
}) as unknown as EventRSVP
|
||||
}
|
||||
|
||||
// 取消 RSVP
|
||||
export async function cancelEventRSVP(eventId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const existing = await pb.collection('event_rsvps').getList(1, 1, {
|
||||
filter: `event="${eventId}" && user="${user.id}"`
|
||||
})
|
||||
|
||||
if (existing.items.length > 0) {
|
||||
return pb.collection('event_rsvps').delete(existing.items[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
// 订阅活动变更
|
||||
export function subscribeEvents(groupId: string, callback: () => void) {
|
||||
return pb.collection('events').subscribe('*', (payload) => {
|
||||
const record = payload.record as any
|
||||
if (record.group === groupId) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 订阅活动评论变更
|
||||
export function subscribeEventComments(eventId: string, callback: () => void) {
|
||||
return pb.collection('event_comments').subscribe('*', (payload) => {
|
||||
const record = payload.record as any
|
||||
if (record.event === eventId) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 订阅 RSVP 变更
|
||||
export function subscribeEventRSVPs(eventId: string, callback: () => void) {
|
||||
return pb.collection('event_rsvps').subscribe('*', (payload) => {
|
||||
const record = payload.record as any
|
||||
if (record.event === eventId) {
|
||||
callback()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { BlacklistEntry } from '@/types'
|
||||
|
||||
export async function createBlacklistEntry(data: {
|
||||
group: string
|
||||
game?: string
|
||||
gameName: string
|
||||
reason: string
|
||||
description: string
|
||||
severity: string
|
||||
}): Promise<BlacklistEntry> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
group: data.group,
|
||||
reporter: user.id,
|
||||
gameName: data.gameName,
|
||||
reason: data.reason,
|
||||
description: data.description,
|
||||
severity: data.severity,
|
||||
}
|
||||
if (data.game) payload.game = data.game
|
||||
|
||||
const record = await pb.collection('game_blacklist').create(payload)
|
||||
return record as unknown as BlacklistEntry
|
||||
}
|
||||
|
||||
export async function listBlacklist(
|
||||
groupId: string,
|
||||
options?: { reason?: string; severity?: string }
|
||||
): Promise<BlacklistEntry[]> {
|
||||
let filter = `group="${groupId}"`
|
||||
if (options?.reason) filter += ` && reason="${options.reason}"`
|
||||
if (options?.severity) filter += ` && severity="${options.severity}"`
|
||||
|
||||
const result = await pb.collection('game_blacklist').getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
expand: 'reporter,game',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as BlacklistEntry[]
|
||||
}
|
||||
|
||||
export async function deleteBlacklistEntry(entryId: string): Promise<void> {
|
||||
await pb.collection('game_blacklist').delete(entryId)
|
||||
}
|
||||
|
||||
export async function subscribeBlacklist(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
return pb.collection('game_blacklist').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
+66
-23
@@ -1,6 +1,12 @@
|
||||
import pb from './pocketbase'
|
||||
import type { Game, GamePlatform, GameComment } from '@/types'
|
||||
|
||||
// 生成游戏封面 URL
|
||||
export function getGameCoverUrl(game: Game, thumb?: string): string {
|
||||
if (!game.cover) return ''
|
||||
return pb.files.getUrl(game, game.cover, thumb ? { thumb } : undefined) as string
|
||||
}
|
||||
|
||||
// 获取群组的游戏列表
|
||||
export async function getGroupGames(groupId: string, options?: {
|
||||
page?: number
|
||||
@@ -11,11 +17,12 @@ export async function getGroupGames(groupId: string, options?: {
|
||||
const { page = 1, limit = 50, platform, search } = options || {}
|
||||
let filter = `group="${groupId}"`
|
||||
if (platform) filter += ` && platform="${platform}"`
|
||||
if (search) filter += ` && name ~ "${search}"`
|
||||
if (search) filter += ` && (name ~ "${search}" || aliases ~ "${search}")`
|
||||
|
||||
const result = await pb.collection('games').getList(page, limit, {
|
||||
filter,
|
||||
sort: '-created'
|
||||
sort: '-created',
|
||||
$autoCancel: false
|
||||
})
|
||||
return { items: result.items as unknown as Game[], total: result.totalItems }
|
||||
}
|
||||
@@ -23,30 +30,61 @@ export async function getGroupGames(groupId: string, options?: {
|
||||
// 添加游戏到群组
|
||||
export async function addGame(groupId: string, data: {
|
||||
name: string
|
||||
aliases?: string[]
|
||||
platform?: GamePlatform
|
||||
tags?: string[]
|
||||
cover?: string
|
||||
coverFile?: File
|
||||
}) {
|
||||
const user = pb.authStore.model
|
||||
return pb.collection('games').create({
|
||||
...data,
|
||||
group: groupId,
|
||||
addedBy: user?.id,
|
||||
popularCount: 0
|
||||
})
|
||||
const formData = new FormData()
|
||||
formData.append('name', data.name)
|
||||
formData.append('group', groupId)
|
||||
formData.append('addedBy', user?.id || '')
|
||||
formData.append('popularCount', '0')
|
||||
if (data.platform) formData.append('platform', data.platform)
|
||||
if (data.aliases && data.aliases.length > 0) formData.append('aliases', JSON.stringify(data.aliases))
|
||||
if (data.tags && data.tags.length > 0) formData.append('tags', JSON.stringify(data.tags))
|
||||
if (data.coverFile) formData.append('cover', data.coverFile)
|
||||
|
||||
return pb.collection('games').create(formData, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 更新游戏
|
||||
export async function updateGame(gameId: string, data: {
|
||||
name?: string
|
||||
aliases?: string[]
|
||||
platform?: GamePlatform | ''
|
||||
tags?: string[]
|
||||
coverFile?: File
|
||||
}) {
|
||||
const formData = new FormData()
|
||||
if (data.name !== undefined) formData.append('name', data.name)
|
||||
if (data.aliases && data.aliases.length > 0) {
|
||||
formData.append('aliases', JSON.stringify(data.aliases))
|
||||
} else {
|
||||
formData.append('aliases', '[]')
|
||||
}
|
||||
if (data.platform !== undefined) formData.append('platform', data.platform || '')
|
||||
if (data.tags && data.tags.length > 0) {
|
||||
formData.append('tags', JSON.stringify(data.tags))
|
||||
} else {
|
||||
formData.append('tags', '[]')
|
||||
}
|
||||
if (data.coverFile) formData.append('cover', data.coverFile)
|
||||
|
||||
return pb.collection('games').update(gameId, formData, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 删除游戏
|
||||
export async function deleteGame(gameId: string) {
|
||||
return pb.collection('games').delete(gameId)
|
||||
return pb.collection('games').delete(gameId, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 导入游戏(批量添加)
|
||||
// 导入游戏(批量添加,无封面文件)
|
||||
export async function importGames(groupId: string, games: Array<{
|
||||
name: string
|
||||
platform?: GamePlatform
|
||||
tags?: string[]
|
||||
cover?: string
|
||||
}>) {
|
||||
const results = []
|
||||
for (const game of games) {
|
||||
@@ -64,7 +102,8 @@ export async function importGames(groupId: string, games: Array<{
|
||||
export async function exportGames(groupId: string): Promise<Game[]> {
|
||||
const result = await pb.collection('games').getFullList({
|
||||
filter: `group="${groupId}"`,
|
||||
sort: '-created'
|
||||
sort: '-created',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result as unknown as Game[]
|
||||
}
|
||||
@@ -75,17 +114,18 @@ export async function toggleFavorite(gameId: string): Promise<boolean> {
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const existing = await pb.collection('game_favorites').getList(1, 1, {
|
||||
filter: `game="${gameId}" && user="${user.id}"`
|
||||
filter: `game="${gameId}" && user="${user.id}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
|
||||
if (existing.items.length > 0) {
|
||||
await pb.collection('game_favorites').delete(existing.items[0].id)
|
||||
await pb.collection('game_favorites').delete(existing.items[0].id, { $autoCancel: false })
|
||||
return false
|
||||
} else {
|
||||
await pb.collection('game_favorites').create({
|
||||
game: gameId,
|
||||
user: user.id
|
||||
})
|
||||
}, { $autoCancel: false })
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -96,7 +136,8 @@ export async function isFavorite(gameId: string): Promise<boolean> {
|
||||
if (!user) return false
|
||||
|
||||
const result = await pb.collection('game_favorites').getList(1, 1, {
|
||||
filter: `game="${gameId}" && user="${user.id}"`
|
||||
filter: `game="${gameId}" && user="${user.id}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items.length > 0
|
||||
}
|
||||
@@ -111,7 +152,7 @@ export async function addComment(gameId: string, content: string, rating?: numbe
|
||||
author: user.id,
|
||||
content,
|
||||
rating
|
||||
})
|
||||
}, { $autoCancel: false })
|
||||
}
|
||||
|
||||
// 获取游戏评论
|
||||
@@ -119,7 +160,8 @@ export async function getGameComments(gameId: string): Promise<GameComment[]> {
|
||||
const result = await pb.collection('game_comments').getList(1, 50, {
|
||||
filter: `game="${gameId}"`,
|
||||
sort: '-created',
|
||||
expand: 'author'
|
||||
expand: 'author',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as GameComment[]
|
||||
}
|
||||
@@ -132,12 +174,13 @@ export function getAllPlatforms(): GamePlatform[] {
|
||||
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
|
||||
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
|
||||
if (!query.trim()) return []
|
||||
let filter = `name ~ "${query}"`
|
||||
let filter = `(name ~ "${query}" || aliases ~ "${query}")`
|
||||
if (groupId) filter += ` && group="${groupId}"`
|
||||
|
||||
const result = await pb.collection('games').getList(1, limit, {
|
||||
filter,
|
||||
sort: '-popularCount'
|
||||
sort: '-popularCount',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as Game[]
|
||||
}
|
||||
@@ -147,9 +190,9 @@ export async function getPopularGames(limit = 10): Promise<Game[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
// 获取用户所在群组的游戏
|
||||
const result = await pb.collection('games').getList(1, limit, {
|
||||
sort: '-popularCount'
|
||||
sort: '-popularCount',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as Game[]
|
||||
}
|
||||
|
||||
+203
-9
@@ -1,6 +1,6 @@
|
||||
// src/api/groups.ts
|
||||
import pb from './pocketbase'
|
||||
import type { Group } from '@/types'
|
||||
import type { Group, JoinRequest } from '@/types'
|
||||
|
||||
// 创建群组
|
||||
export async function createGroup(data: {
|
||||
@@ -14,7 +14,8 @@ export async function createGroup(data: {
|
||||
return pb.collection('groups').create({
|
||||
...data,
|
||||
owner: user.id,
|
||||
members: [user.id]
|
||||
members: [user.id],
|
||||
requireApproval: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -23,20 +24,35 @@ export async function getUserGroups(): Promise<Group[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
// 通过 members 字段过滤
|
||||
return pb.collection('groups').getList(1, 50, {
|
||||
filter: `members ~ "${user.id}"`
|
||||
filter: `members ~ "${user.id}"`,
|
||||
$autoCancel: false
|
||||
}).then(res => res.items as unknown as Group[])
|
||||
}
|
||||
|
||||
// 获取群组详情
|
||||
export async function getGroup(groupId: string): Promise<Group> {
|
||||
return pb.collection('groups').getOne(groupId, {
|
||||
expand: 'members'
|
||||
expand: 'members',
|
||||
$autoCancel: false
|
||||
}) as unknown as Group
|
||||
}
|
||||
|
||||
// 加入群组
|
||||
// 按名称搜索群组
|
||||
export async function searchGroups(keyword: string): Promise<Group[]> {
|
||||
if (!keyword.trim()) return []
|
||||
|
||||
const user = pb.authStore.model
|
||||
const filter = `name ~ "${keyword.trim()}" && id != "${user?.id}"`
|
||||
|
||||
const result = await pb.collection('groups').getList(1, 20, {
|
||||
filter,
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as Group[]
|
||||
}
|
||||
|
||||
// 直接加入群组(无需审核时调用)
|
||||
export async function joinGroup(groupId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
@@ -57,6 +73,96 @@ export async function joinGroup(groupId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
// 提交加入申请
|
||||
export async function createJoinRequest(groupId: string): Promise<JoinRequest> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 检查是否已有待审核的申请
|
||||
const existing = await pb.collection('join_requests').getList(1, 1, {
|
||||
filter: `group="${groupId}" && user="${user.id}" && status="pending"`
|
||||
})
|
||||
if (existing.items.length > 0) {
|
||||
throw new Error('已提交过申请,请等待审核')
|
||||
}
|
||||
|
||||
return pb.collection('join_requests').create({
|
||||
group: groupId,
|
||||
user: user.id,
|
||||
status: 'pending'
|
||||
}) as unknown as JoinRequest
|
||||
}
|
||||
|
||||
// 获取群组的待审核申请(群主用)
|
||||
export async function getGroupJoinRequests(groupId: string): Promise<JoinRequest[]> {
|
||||
const result = await pb.collection('join_requests').getList(1, 50, {
|
||||
filter: `group="${groupId}" && status="pending"`,
|
||||
sort: '-created',
|
||||
expand: 'user',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as JoinRequest[]
|
||||
}
|
||||
|
||||
// 获取我作为群主的所有群组的待审核申请
|
||||
export async function getMyGroupsJoinRequests(): Promise<JoinRequest[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
const result = await pb.collection('join_requests').getList(1, 50, {
|
||||
filter: `group.owner="${user.id}" && status="pending"`,
|
||||
sort: '-created',
|
||||
expand: 'user,group',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as JoinRequest[]
|
||||
}
|
||||
|
||||
// 获取当前用户的所有入群申请(包括所有状态)
|
||||
export async function getMyJoinRequests(): Promise<JoinRequest[]> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return []
|
||||
|
||||
const result = await pb.collection('join_requests').getList(1, 50, {
|
||||
filter: `user="${user.id}"`,
|
||||
sort: '-created',
|
||||
expand: 'group',
|
||||
$autoCancel: false
|
||||
})
|
||||
return result.items as unknown as JoinRequest[]
|
||||
}
|
||||
|
||||
// 审批加入申请
|
||||
export async function respondJoinRequest(
|
||||
requestId: string,
|
||||
status: 'approved' | 'rejected',
|
||||
rejectReason?: string
|
||||
) {
|
||||
const updateData: Record<string, unknown> = { status }
|
||||
if (status === 'rejected' && rejectReason) {
|
||||
updateData.rejectReason = rejectReason
|
||||
}
|
||||
|
||||
const request = await pb.collection('join_requests').update(requestId, updateData) as any
|
||||
|
||||
// 如果同意,将用户加入群组
|
||||
if (status === 'approved') {
|
||||
const group = await pb.collection('groups').getOne(request.group) as any
|
||||
const members: string[] = group.members || []
|
||||
if (!members.includes(request.user)) {
|
||||
members.push(request.user)
|
||||
await pb.collection('groups').update(request.group, { members })
|
||||
}
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
// 更新群组审核设置
|
||||
export async function updateGroupApproval(groupId: string, requireApproval: boolean) {
|
||||
return pb.collection('groups').update(groupId, { requireApproval })
|
||||
}
|
||||
|
||||
// 退出群组
|
||||
export async function leaveGroup(groupId: string) {
|
||||
const user = pb.authStore.model
|
||||
@@ -97,14 +203,102 @@ export function subscribeGroup(groupId: string, callback: (group: Group) => void
|
||||
})
|
||||
}
|
||||
|
||||
// 转让群主
|
||||
// 安全提示:PB groups updateRule 允许认证用户更新,前端校验是唯一防线。
|
||||
// 如需服务端强制保护,需添加 PocketBase JS hooks 或改用更严格的 updateRule。
|
||||
export async function transferGroupOwnership(groupId: string, newOwnerId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const group = await pb.collection('groups').getOne(groupId)
|
||||
if (group.owner !== user.id) {
|
||||
throw new Error('只有群主可以转让群组')
|
||||
}
|
||||
|
||||
const admins = (group.admins as string[]) || []
|
||||
|
||||
// 将原群主加入 admins,新群主从 admins 中移除
|
||||
const newAdmins = [...new Set([...admins, user.id])].filter(id => id !== newOwnerId)
|
||||
|
||||
return pb.collection('groups').update(groupId, {
|
||||
owner: newOwnerId,
|
||||
admins: newAdmins
|
||||
})
|
||||
}
|
||||
|
||||
// 添加管理员
|
||||
export async function addGroupAdmin(groupId: string, userId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const group = await pb.collection('groups').getOne(groupId)
|
||||
if (group.owner !== user.id) {
|
||||
throw new Error('只有群主可以设置管理员')
|
||||
}
|
||||
|
||||
const admins = (group.admins as string[]) || []
|
||||
if (admins.includes(userId)) {
|
||||
throw new Error('该用户已是管理员')
|
||||
}
|
||||
|
||||
return pb.collection('groups').update(groupId, {
|
||||
admins: [...admins, userId]
|
||||
})
|
||||
}
|
||||
|
||||
// 移除管理员
|
||||
export async function removeGroupAdmin(groupId: string, userId: string) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const group = await pb.collection('groups').getOne(groupId)
|
||||
if (group.owner !== user.id) {
|
||||
throw new Error('只有群主可以移除管理员')
|
||||
}
|
||||
|
||||
const admins = (group.admins as string[]) || []
|
||||
return pb.collection('groups').update(groupId, {
|
||||
admins: admins.filter(id => id !== userId)
|
||||
})
|
||||
}
|
||||
|
||||
// 更新全员管理设置
|
||||
export async function updateGroupMemberManage(groupId: string, allow: boolean) {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const group = await pb.collection('groups').getOne(groupId)
|
||||
if (group.owner !== user.id) {
|
||||
throw new Error('只有群主可以修改此设置')
|
||||
}
|
||||
|
||||
return pb.collection('groups').update(groupId, { allowMemberManage: allow })
|
||||
}
|
||||
|
||||
// 订阅加入申请变更
|
||||
export function subscribeJoinRequests(groupId: string, callback: (request: JoinRequest) => void) {
|
||||
return pb.collection('join_requests').subscribe('*', (payload) => {
|
||||
const record = payload.record as any
|
||||
if (record.group === groupId) {
|
||||
callback(record as unknown as JoinRequest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取群组成员
|
||||
export async function getGroupMembers(groupId: string) {
|
||||
const group = await getGroup(groupId)
|
||||
const members = group.members as string[]
|
||||
|
||||
if (group.expand?.members) {
|
||||
return group.expand.members
|
||||
}
|
||||
|
||||
const members = group.members as string[]
|
||||
if (!members || members.length === 0) return []
|
||||
|
||||
// 批量获取用户信息
|
||||
const users = await pb.collection('users').getList(1, 50, {
|
||||
filter: members.map(id => `id="${id}"`).join(' || ')
|
||||
filter: members.map(id => `id="${id}"`).join(' || '),
|
||||
$autoCancel: false
|
||||
})
|
||||
|
||||
return users.items
|
||||
|
||||
@@ -77,20 +77,21 @@ export async function respondInvitation(
|
||||
updateData.rejectReason = rejectReason
|
||||
}
|
||||
|
||||
// 更新邀请状态
|
||||
await pb.collection('invitations').update(invitationId, updateData)
|
||||
|
||||
// 接受邀请:前端处理加入 team session + 更新用户状态
|
||||
// 接受邀请:先加入 team session,再更新邀请状态
|
||||
if (response === 'accepted') {
|
||||
const user = pb.authStore.model
|
||||
if (!user) return
|
||||
|
||||
// 获取邀请详情以找到 team session
|
||||
const invitation = await pb.collection('invitations').getOne(invitationId) as any
|
||||
const invitation = await pb.collection('invitations').getOne(invitationId, {
|
||||
$autoCancel: false
|
||||
}) as any
|
||||
const teamSessionId = invitation.teamSession
|
||||
|
||||
// 加入 team session
|
||||
const session = await pb.collection('team_sessions').getOne(teamSessionId) as any
|
||||
const session = await pb.collection('team_sessions').getOne(teamSessionId, {
|
||||
$autoCancel: false
|
||||
}) as any
|
||||
const members: string[] = session.members || []
|
||||
if (!members.includes(user.id)) {
|
||||
members.push(user.id)
|
||||
@@ -99,6 +100,37 @@ export async function respondInvitation(
|
||||
|
||||
// 更新用户状态为 in_team
|
||||
await pb.collection('users').update(user.id, { status: 'in_team' })
|
||||
|
||||
// 同步更新本地 userStore
|
||||
const { useUserStore } = await import('@/stores/user')
|
||||
const userStore = useUserStore()
|
||||
if (userStore.user) {
|
||||
userStore.user.status = 'in_team'
|
||||
}
|
||||
}
|
||||
|
||||
// 更新邀请状态
|
||||
await pb.collection('invitations').update(invitationId, updateData)
|
||||
|
||||
// 通知邀请发起人
|
||||
try {
|
||||
const invitation = await pb.collection('invitations').getOne(invitationId, {
|
||||
expand: 'teamSession',
|
||||
$autoCancel: false,
|
||||
}) as any
|
||||
const { createNotification } = await import('./notifications')
|
||||
await createNotification({
|
||||
user: invitation.from,
|
||||
type: response === 'rejected' ? 'team_invite' : 'team_invite',
|
||||
title: response === 'rejected' ? '邀请被拒绝' : '邀请已接受',
|
||||
content: response === 'rejected'
|
||||
? (rejectReason || '对方拒绝了组队邀请')
|
||||
: '对方已接受组队邀请',
|
||||
relatedId: invitation.teamSession,
|
||||
relatedType: 'team',
|
||||
})
|
||||
} catch {
|
||||
// 通知失败不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// src/api/ledgers.ts
|
||||
import { pb } from './pocketbase'
|
||||
import type { Ledger } from '@/types'
|
||||
|
||||
interface CreateLedgerData {
|
||||
group: string
|
||||
type: string
|
||||
amount: number
|
||||
category: string
|
||||
description?: string
|
||||
relatedMembers?: string[]
|
||||
occurredAt: string
|
||||
}
|
||||
|
||||
interface ListLedgersOptions {
|
||||
page?: number
|
||||
limit?: number
|
||||
type?: string
|
||||
category?: string
|
||||
month?: string
|
||||
}
|
||||
|
||||
interface LedgerSummary {
|
||||
totalIncome: number
|
||||
totalExpense: number
|
||||
balance: number
|
||||
}
|
||||
|
||||
export async function createLedger(data: CreateLedgerData): Promise<Ledger> {
|
||||
const user = pb.authStore.model
|
||||
const record = await pb.collection('ledgers').create({
|
||||
...data,
|
||||
creator: user?.id || '',
|
||||
relatedMembers: data.relatedMembers || []
|
||||
}, { $autoCancel: false })
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function listLedgers(
|
||||
groupId: string,
|
||||
options?: ListLedgersOptions
|
||||
): Promise<{ items: Ledger[]; total: number }> {
|
||||
const { page = 1, limit = 30, type, category, month } = options || {}
|
||||
let filter = `group="${groupId}"`
|
||||
if (type) filter += ` && type="${type}"`
|
||||
if (category) filter += ` && category="${category}"`
|
||||
if (month) {
|
||||
const start = `${month}-01 00:00:00`
|
||||
const year = parseInt(month.slice(0, 4))
|
||||
const mon = parseInt(month.slice(5, 7))
|
||||
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
|
||||
const end = `${nextMonth}-01 00:00:00`
|
||||
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
|
||||
}
|
||||
|
||||
const result = await pb.collection('ledgers').getList(page, limit, {
|
||||
filter,
|
||||
sort: '-occurredAt',
|
||||
expand: 'creator,relatedMembers',
|
||||
$autoCancel: false
|
||||
})
|
||||
return { items: result.items as unknown as Ledger[], total: result.totalItems }
|
||||
}
|
||||
|
||||
export async function updateLedger(
|
||||
ledgerId: string,
|
||||
data: Partial<CreateLedgerData>
|
||||
): Promise<Ledger> {
|
||||
const record = await pb.collection('ledgers').update(ledgerId, data, { $autoCancel: false })
|
||||
return record as unknown as Ledger
|
||||
}
|
||||
|
||||
export async function deleteLedger(ledgerId: string): Promise<void> {
|
||||
await pb.collection('ledgers').delete(ledgerId, { $autoCancel: false })
|
||||
}
|
||||
|
||||
export async function getLedgerSummary(
|
||||
groupId: string,
|
||||
month?: string
|
||||
): Promise<LedgerSummary> {
|
||||
let totalIncome = 0
|
||||
let totalExpense = 0
|
||||
let page = 1
|
||||
const batchSize = 500
|
||||
let hasMore = true
|
||||
|
||||
let filter = `group="${groupId}"`
|
||||
if (month) {
|
||||
const start = `${month}-01 00:00:00`
|
||||
const year = parseInt(month.slice(0, 4))
|
||||
const mon = parseInt(month.slice(5, 7))
|
||||
const nextMonth = mon === 12 ? `${year + 1}-01` : `${year}-${String(mon + 1).padStart(2, '0')}`
|
||||
const end = `${nextMonth}-01 00:00:00`
|
||||
filter += ` && occurredAt>="${start}" && occurredAt<"${end}"`
|
||||
}
|
||||
|
||||
while (hasMore) {
|
||||
const result = await pb.collection('ledgers').getList(page, batchSize, {
|
||||
filter,
|
||||
fields: 'type,amount',
|
||||
$autoCancel: false
|
||||
})
|
||||
|
||||
for (const item of result.items as any[]) {
|
||||
if (item.type === 'income') {
|
||||
totalIncome += item.amount || 0
|
||||
} else if (item.type === 'expense') {
|
||||
totalExpense += item.amount || 0
|
||||
}
|
||||
}
|
||||
|
||||
hasMore = result.items.length === batchSize && page * batchSize < result.totalItems
|
||||
page++
|
||||
}
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpense,
|
||||
balance: totalIncome - totalExpense
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeLedgers(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
) {
|
||||
return pb.collection('ledgers').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) {
|
||||
callback(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,59 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { PlayerBlacklistEntry } from '@/types'
|
||||
|
||||
export async function createPlayerBlacklistEntry(data: {
|
||||
group: string
|
||||
playerId: string
|
||||
platform: string
|
||||
tags: string[]
|
||||
customTag?: string
|
||||
description: string
|
||||
severity: string
|
||||
}): Promise<PlayerBlacklistEntry> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
group: data.group,
|
||||
reporter: user.id,
|
||||
playerId: data.playerId,
|
||||
platform: data.platform,
|
||||
tags: data.tags,
|
||||
description: data.description,
|
||||
severity: data.severity,
|
||||
}
|
||||
if (data.customTag) payload.customTag = data.customTag
|
||||
|
||||
const record = await pb.collection('player_blacklist').create(payload)
|
||||
return record as unknown as PlayerBlacklistEntry
|
||||
}
|
||||
|
||||
export async function listPlayerBlacklist(
|
||||
groupId: string,
|
||||
options?: { tag?: string; severity?: string }
|
||||
): Promise<PlayerBlacklistEntry[]> {
|
||||
let filter = `group="${groupId}"`
|
||||
if (options?.tag) filter += ` && tags~"${options.tag}"`
|
||||
if (options?.severity) filter += ` && severity="${options.severity}"`
|
||||
|
||||
const result = await pb.collection('player_blacklist').getFullList({
|
||||
filter,
|
||||
sort: '-created',
|
||||
expand: 'reporter',
|
||||
$autoCancel: false,
|
||||
})
|
||||
return result as unknown as PlayerBlacklistEntry[]
|
||||
}
|
||||
|
||||
export async function deletePlayerBlacklistEntry(entryId: string): Promise<void> {
|
||||
await pb.collection('player_blacklist').delete(entryId)
|
||||
}
|
||||
|
||||
export async function subscribePlayerBlacklist(
|
||||
groupId: string,
|
||||
callback: (data: any) => void
|
||||
): Promise<() => void> {
|
||||
return pb.collection('player_blacklist').subscribe('*', (data) => {
|
||||
if (data.record?.group === groupId) callback(data)
|
||||
})
|
||||
}
|
||||
@@ -5,13 +5,28 @@ const pbUrl = import.meta.env.VITE_PB_URL || window.location.origin
|
||||
|
||||
export const pb = new PocketBase(pbUrl)
|
||||
|
||||
// 认证状态持久化
|
||||
pb.authStore.loadFromCookie(document.cookie)
|
||||
// SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
|
||||
|
||||
// 保存认证状态到 cookie
|
||||
pb.authStore.onChange(() => {
|
||||
document.cookie = pb.authStore.exportToCookie({ httpOnly: false })
|
||||
})
|
||||
// Electron 环境下使用 electron-store 做更持久的备份
|
||||
const eAPI = (window as any).electronAPI
|
||||
if (eAPI?.storeGet) {
|
||||
// 若 localStorage 无有效认证,尝试从 electron-store 恢复
|
||||
if (!pb.authStore.isValid) {
|
||||
const saved = eAPI.storeGet('pb_auth')
|
||||
if (saved?.token && saved?.model) {
|
||||
pb.authStore.save(saved.token, saved.model)
|
||||
}
|
||||
}
|
||||
|
||||
// 认证状态变化时同步到 electron-store
|
||||
pb.authStore.onChange((token: string, model: any) => {
|
||||
if (token && model) {
|
||||
eAPI.storeSet('pb_auth', { token, model })
|
||||
} else {
|
||||
eAPI.storeDelete('pb_auth')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
export function getCurrentUser() {
|
||||
@@ -26,6 +41,10 @@ export function isAuthenticated(): boolean {
|
||||
// 登出
|
||||
export function logout() {
|
||||
pb.authStore.clear()
|
||||
const eAPI = (window as any).electronAPI
|
||||
if (eAPI?.storeDelete) {
|
||||
eAPI.storeDelete('pb_auth')
|
||||
}
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { pb } from './pocketbase'
|
||||
import type { PointLog, PointAction } from '@/types'
|
||||
|
||||
const POINT_MAP: Record<PointAction, number> = {
|
||||
vote: 1,
|
||||
team: 2,
|
||||
memory: 1,
|
||||
bet: 0 // 竞猜积分变动不固定,通过 bets.ts 直接操作
|
||||
}
|
||||
|
||||
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,242 @@
|
||||
import { pb } from './pocketbase'
|
||||
import { awardPoints, deductPoints } from './points'
|
||||
import { createNotification } from './notifications'
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// 给同群组其他成员发送通知
|
||||
try {
|
||||
const group = await pb.collection('groups').getOne(data.group)
|
||||
const typeLabel = data.type === 'rollcall' ? '接龙报名' : '投票'
|
||||
const otherMembers = (group.members || []).filter((id: string) => id !== user.id)
|
||||
await Promise.all(
|
||||
otherMembers.map((memberId: string) =>
|
||||
createNotification({
|
||||
user: memberId,
|
||||
type: 'poll_new',
|
||||
title: `新${typeLabel}`,
|
||||
content: `${data.title}`,
|
||||
relatedId: poll.id,
|
||||
relatedType: 'poll',
|
||||
})
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
// 通知发送失败不影响主流程
|
||||
}
|
||||
|
||||
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('*')
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,14 @@ export async function getGroupTeamSessions(groupId: string): Promise<TeamSession
|
||||
return result.items as unknown as TeamSession[]
|
||||
}
|
||||
|
||||
// 获取单个临时小组详情
|
||||
export async function getTeamSession(sessionId: string): Promise<TeamSession> {
|
||||
return pb.collection('team_sessions').getOne(sessionId, {
|
||||
expand: 'sourceGroup',
|
||||
$autoCancel: false
|
||||
}) as unknown as TeamSession
|
||||
}
|
||||
|
||||
// 更新临时小组状态
|
||||
export async function updateTeamStatus(sessionId: string, status: TeamStatus): Promise<TeamSession> {
|
||||
const updateData: Partial<TeamSession> = { status }
|
||||
@@ -51,7 +59,9 @@ export async function updateTeamStatus(sessionId: string, status: TeamStatus): P
|
||||
|
||||
// 结束游戏(解散临时小组 + 重置成员状态)
|
||||
export async function endGame(sessionId: string) {
|
||||
const session = await pb.collection('team_sessions').getOne(sessionId) as any
|
||||
const session = await pb.collection('team_sessions').getOne(sessionId, {
|
||||
$autoCancel: false
|
||||
}) as any
|
||||
const members: string[] = session.members || []
|
||||
|
||||
// 解散临时小组
|
||||
@@ -60,10 +70,7 @@ export async function endGame(sessionId: string) {
|
||||
// 重置所有成员状态为 idle
|
||||
for (const memberId of members) {
|
||||
try {
|
||||
const user = await pb.collection('users').getOne(memberId) as any
|
||||
if (user && user.status === 'in_team') {
|
||||
await pb.collection('users').update(memberId, { status: 'idle' })
|
||||
}
|
||||
await pb.collection('users').update(memberId, { status: 'idle' })
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// src/api/voice.ts
|
||||
import { pb } from './pocketbase'
|
||||
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
|
||||
|
||||
export function getLiveKitUrl(): string {
|
||||
return LIVEKIT_URL
|
||||
}
|
||||
|
||||
export async function fetchVoiceToken(sessionId: string): Promise<string> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
const token = pb.authStore.token
|
||||
console.log('[voice] fetching token for session:', sessionId, 'token prefix:', token?.slice(0, 20))
|
||||
|
||||
const res = await fetch(`/voice-api/voice-token/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('[voice] token service response status:', res.status)
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: '语音服务暂不可用' }))
|
||||
console.log('[voice] token service error:', data)
|
||||
throw new Error(data.error || data.detail || '语音服务暂不可用')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data.token
|
||||
}
|
||||
@@ -4,9 +4,9 @@
|
||||
--gg-primary-light: #10b981;
|
||||
--gg-primary-dark: #047857;
|
||||
|
||||
/* 辅助色:深紫 */
|
||||
--gg-accent: #7c3aed;
|
||||
--gg-accent-light: #8b5cf6;
|
||||
/* 辅助色:翠绿 */
|
||||
--gg-accent: #0d9488;
|
||||
--gg-accent-light: #14b8a6;
|
||||
|
||||
/* 背景色(亮色) */
|
||||
--gg-bg: #f0fdf4;
|
||||
@@ -40,7 +40,7 @@
|
||||
--gg-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* 渐变 */
|
||||
--gg-gradient: linear-gradient(135deg, #059669 0%, #7c3aed 100%);
|
||||
--gg-gradient: linear-gradient(135deg, #059669 0%, #0d9488 100%);
|
||||
--gg-gradient-green: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
--gg-gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
--gg-gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/* src/assets/mobile.css */
|
||||
/* Vant 4 主题变量覆盖 —— 映射到项目 design.css 的 --gg-* 设计系统 */
|
||||
|
||||
:root {
|
||||
/* 主色(Vant primary → gg-primary 绿色) */
|
||||
--van-primary-color: var(--gg-primary); /* #059669 */
|
||||
--van-success-color: var(--gg-success); /* #10b981 */
|
||||
--van-danger-color: var(--gg-danger); /* #ef4444 */
|
||||
--van-warning-color: var(--gg-warning); /* #f59e0b */
|
||||
|
||||
/* 文字色 */
|
||||
--van-text-color: var(--gg-text); /* #1e293b */
|
||||
--van-text-color-2: var(--gg-text-secondary); /* #475569 */
|
||||
--van-text-color-3: var(--gg-text-muted); /* #94a3b8 */
|
||||
|
||||
/* 背景 */
|
||||
--van-background: var(--gg-bg); /* #f0fdf4 */
|
||||
--van-background-2: var(--gg-bg-card); /* #ffffff */
|
||||
|
||||
/* 边框 */
|
||||
--van-border-color: var(--gg-border); /* #e2e8f0 */
|
||||
|
||||
/* 圆角 */
|
||||
--van-radius-sm: var(--gg-radius-sm);
|
||||
--van-radius-md: var(--gg-radius-md);
|
||||
--van-radius-lg: var(--gg-radius-lg);
|
||||
|
||||
/* Tabbar(底部导航) */
|
||||
--van-tabbar-background: var(--gg-bg-card);
|
||||
--van-tabbar-item-active-color: var(--gg-primary);
|
||||
--van-tabbar-item-text-color: var(--gg-text-muted);
|
||||
--van-tabbar-height: 56px;
|
||||
|
||||
/* NavBar(顶部栏) */
|
||||
--van-nav-bar-background: var(--gg-bg-card);
|
||||
--van-nav-bar-title-font-size: 17px;
|
||||
--van-nav-bar-height: 52px;
|
||||
--van-nav-bar-icon-color: var(--gg-text);
|
||||
|
||||
/* 按钮主色渐变 */
|
||||
--van-button-primary-background: var(--gg-primary);
|
||||
--van-button-primary-border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
/* 手机端全局:安全区域适配(刘海屏底部 Tab 不被遮挡) */
|
||||
.mobile-app {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
/* 禁止移动端点击高亮 */
|
||||
.mobile-app * {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ============ 细节体验优化(全局) ============ */
|
||||
|
||||
/* 1. 按钮点击反馈:统一按压缩放 */
|
||||
.mobile-app .van-button:active {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.mobile-app .van-button {
|
||||
transition: opacity 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
/* 2. 可点击卡片按压反馈(约定:凡 .clickable 或带 @click 的卡片建议加此类) */
|
||||
.mobile-app .clickable {
|
||||
transition: transform 0.12s, box-shadow 0.12s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mobile-app .clickable:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 3. van-empty 空状态:图标加品牌色淡背景,提升存在感 */
|
||||
.mobile-app .van-empty__image {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.mobile-app .van-empty__description {
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 4. 列表项分隔线统一为更柔和的颜色 */
|
||||
.mobile-app .van-cell {
|
||||
border-color: var(--gg-border);
|
||||
}
|
||||
.mobile-app .van-cell::after {
|
||||
border-color: var(--gg-border) !important;
|
||||
}
|
||||
|
||||
/* 5. 全局卡片阴影统一(覆盖各页面自定义的过深/过浅阴影) */
|
||||
.mobile-app .van-popup,
|
||||
.mobile-app .van-action-sheet {
|
||||
box-shadow: 0 -4px 24px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
/* 6. Tab 栏激活态下划线更柔和 */
|
||||
.mobile-app .van-tabs__line {
|
||||
background: var(--gg-gradient);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 7. 输入框聚焦态:边框过渡更顺滑 */
|
||||
.mobile-app .van-field {
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
/* 8. 骨架屏占位(list 加载时,配合 van-skeleton 或 .skeleton 类使用) */
|
||||
.mobile-app .skeleton-block {
|
||||
background: linear-gradient(90deg, var(--gg-bg-elevated) 25%, var(--gg-bg) 37%, var(--gg-bg-elevated) 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-loading 1.4s ease infinite;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
}
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 100% 50%; }
|
||||
100% { background-position: 0 50%; }
|
||||
}
|
||||
|
||||
/* 9. 滚动容器:隐藏滚动条但保留滚动能力(移动端原生体验) */
|
||||
.mobile-app .scroll-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.mobile-app .scroll-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 10. 文字溢出省略工具类(列表标题常用) */
|
||||
.mobile-app .ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mobile-app .ellipsis-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 11. 页面切换淡入动画(router-view 配合) */
|
||||
.mobile-app .mobile-content {
|
||||
animation: page-fade-in 0.25s ease;
|
||||
}
|
||||
@keyframes page-fade-in {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 12. Toast 在刘海屏顶部不被遮挡 */
|
||||
.mobile-app .van-toast {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 10%);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<!--
|
||||
MobileIcon.vue
|
||||
手机端统一 SVG 图标组件 —— 线性描边风格,2px 线宽,24×24 viewBox
|
||||
颜色继承 currentColor,通过 color/style/class 控制
|
||||
用法:<MobileIcon name="game" /> <MobileIcon name="bell" :size="22" color="var(--gg-primary)" />
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
size?: number | string
|
||||
color?: string
|
||||
}>(), {
|
||||
size: 20,
|
||||
color: 'currentColor'
|
||||
})
|
||||
|
||||
// 所有图标的 path 数据(线性描边,stroke 风格统一)
|
||||
// 每个 name 对应一组 SVG 内部元素(path/circle 等)
|
||||
const ICONS: Record<string, string> = {
|
||||
// ---- 品牌/导航 ----
|
||||
logo: '<path d="M6 8h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2Z"/><path d="M7 13h2"/><path d="M15 13h2"/><path d="M9 16h6"/>',
|
||||
home: '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V20a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5"/><path d="M9.5 21v-6h5v6"/>',
|
||||
users: '<circle cx="9" cy="8" r="3"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><path d="M16 6.5a3 3 0 0 1 0 5.8"/><path d="M17 14.5a5.5 5.5 0 0 1 3.5 5.1"/>',
|
||||
game: '<rect x="2.5" y="7" width="19" height="10" rx="3"/><path d="M7 11v3"/><path d="M5.5 12.5h3"/><circle cx="16" cy="11.5" r="0.8" fill="currentColor" stroke="none"/><circle cx="18.5" cy="14" r="0.8" fill="currentColor" stroke="none"/>',
|
||||
bell: '<path d="M6 9a6 6 0 0 1 12 0c0 4 1.2 5.5 2 6.5H4c.8-1 2-2.5 2-6.5Z"/><path d="M10 19a2 2 0 0 0 4 0"/>',
|
||||
user: '<circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/>',
|
||||
|
||||
// ---- 功能/操作 ----
|
||||
plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
|
||||
search: '<circle cx="11" cy="11" r="6.5"/><path d="m20 20-3.6-3.6"/>',
|
||||
edit: '<path d="M4 20h4L19 9l-4-4L4 16v4Z"/><path d="m14 6 4 4"/>',
|
||||
trash: '<path d="M4 7h16"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/><path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13"/><path d="M10 11v6"/><path d="M14 11v6"/>',
|
||||
close: '<path d="M6 6l12 12"/><path d="M18 6 6 18"/>',
|
||||
check: '<path d="m5 12.5 4.5 4.5L19 7"/>',
|
||||
arrowDown: '<path d="M12 5v14"/><path d="m6 13 6 6 6-6"/>',
|
||||
arrowLeft: '<path d="M19 12H5"/><path d="m11 6-6 6 6 6"/>',
|
||||
arrowRight: '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
|
||||
arrowUp: '<path d="M12 19V5"/><path d="m6 11 6-6 6 6"/>',
|
||||
refresh: '<path d="M4 12a8 8 0 0 1 13.5-5.8L20 8"/><path d="M20 4v4h-4"/><path d="M20 12a8 8 0 0 1-13.5 5.8L4 16"/><path d="M4 20v-4h4"/>',
|
||||
more: '<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/>',
|
||||
|
||||
// ---- 状态/反馈 ----
|
||||
star: '<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.3L12 17.3 6.5 20.3l1.1-6.3L3 9.6l6.3-.9L12 3Z"/>',
|
||||
starFilled: '<path d="m12 3 2.7 5.7 6.3.9-4.6 4.4 1.1 6.3L12 17.3 6.5 20.3l1.1-6.3L3 9.6l6.3-.9L12 3Z" fill="currentColor" stroke="none"/>',
|
||||
fire: '<path d="M12 3c1 3-1 4-1 6 0 1 1 2 2 2s2-1 2-3c2 2 3 4 3 7a6 6 0 0 1-12 0c0-3 2-5 3-6 1 1 1 2 2 2 1 0 1-2 1-5 0-1 0-2 0-3Z"/>',
|
||||
bookmark: '<path d="M6 4h12v16l-6-4-6 4V4Z"/>',
|
||||
warning: '<path d="M12 4 2.5 20h19L12 4Z"/><path d="M12 10v4"/><circle cx="12" cy="17" r="0.8" fill="currentColor" stroke="none"/>',
|
||||
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><circle cx="12" cy="7.5" r="0.8" fill="currentColor" stroke="none"/>',
|
||||
|
||||
// ---- 时间/地点/类型 ----
|
||||
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7v5l3.5 2"/>',
|
||||
location: '<path d="M12 21s7-6 7-11a7 7 0 0 0-14 0c0 5 7 11 7 11Z"/><circle cx="12" cy="10" r="2.5"/>',
|
||||
calendar: '<rect x="4" y="5" width="16" height="16" rx="2"/><path d="M4 9h16"/><path d="M8 3v4"/><path d="M16 3v4"/>',
|
||||
trophy: '<path d="M8 4h8v4a4 4 0 0 1-8 0V4Z"/><path d="M8 5H5v2a3 3 0 0 0 3 3"/><path d="M16 5h3v2a3 3 0 0 1-3 3"/><path d="M12 12v4"/><path d="M9 20h6"/><path d="M10 16h4l.5 4h-5l.5-4Z"/>',
|
||||
tag: '<path d="M3 5h7l9 9-7 7-9-9V5Z"/><circle cx="7.5" cy="9.5" r="1.2" fill="currentColor" stroke="none"/>',
|
||||
chart: '<path d="M4 20V4"/><path d="M4 20h16"/><path d="M8 16v-4"/><path d="M13 16V8"/><path d="M18 16v-7"/>',
|
||||
|
||||
// ---- 组队/语音 ----
|
||||
volume: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="M16 9a3 3 0 0 1 0 6"/><path d="M18.5 7a6 6 0 0 1 0 10"/>',
|
||||
volumeOff: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="m16 9 5 6"/><path d="m21 9-5 6"/>',
|
||||
speaker: '<path d="M4 9v6h4l5 4V5L8 9H4Z"/><path d="M16 8a5 5 0 0 1 0 8"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>',
|
||||
mic: '<rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/>',
|
||||
micOff: '<path d="M9 5a3 3 0 0 1 6 0v4"/><path d="M9 9v2a3 3 0 0 0 5 2"/><path d="M5 11a7 7 0 0 0 11 4"/><path d="M12 18v3"/><path d="m4 4 16 16"/>',
|
||||
|
||||
// ---- 群组/资产相关 ----
|
||||
wallet: '<rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 9h18"/><circle cx="16.5" cy="13" r="1.2" fill="currentColor" stroke="none"/>',
|
||||
gift: '<rect x="4" y="8" width="16" height="12" rx="1"/><path d="M4 12h16"/><path d="M12 8v12"/><path d="M12 8C12 5 10 4 8.5 4S6 6 8 8"/><path d="M12 8c0-3 2-4 3.5-4S18 6 16 8"/>',
|
||||
photo: '<rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="11" r="2"/><path d="m3 17 5-4 4 3 3-2 6 4"/>',
|
||||
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1.5 1.5"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1.5-1.5"/>',
|
||||
download: '<path d="M12 4v11"/><path d="m7 11 5 5 5-5"/><path d="M5 20h14"/>',
|
||||
upload: '<path d="M12 16V5"/><path d="m7 9 5-5 5 5"/><path d="M5 20h14"/>',
|
||||
music: '<circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="16" r="2.5"/><path d="M8.5 18V5l12-2v13"/>',
|
||||
video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>',
|
||||
document: '<path d="M6 3h8l4 4v14H6V3Z"/><path d="M14 3v4h4"/><path d="M9 13h6"/><path d="M9 17h6"/>',
|
||||
setting: '<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M19 5l-2 2M7 17l-2 2"/>',
|
||||
logout: '<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4"/><path d="M16 16l4-4-4-4"/><path d="M20 12H9"/>',
|
||||
|
||||
// ---- 状态点 ----
|
||||
dotIdle: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
|
||||
}
|
||||
|
||||
const innerSvg = computed(() => ICONS[props.name] || ICONS.info)
|
||||
|
||||
const wrapperStyle = computed(() => ({
|
||||
width: typeof props.size === 'number' ? `${props.size}px` : props.size,
|
||||
height: typeof props.size === 'number' ? `${props.size}px` : props.size,
|
||||
color: props.color
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:style="wrapperStyle"
|
||||
class="mobile-icon"
|
||||
v-html="innerSvg"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-icon {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,411 @@
|
||||
<!-- src/components-mobile/bet/BetListMobile.vue -->
|
||||
<!-- 手机端竞猜列表 + 详情 + 下注 + 创建 + 结算 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { listBets, getBet, getBetOptions, getBetEntries, placeBet, createBet, closeBet, settleBet } from '@/api/bets'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const bets = ref<Bet[]>([])
|
||||
const loading = ref(false)
|
||||
const viewingBetId = ref<string | null>(null)
|
||||
|
||||
const currentBet = ref<Bet | null>(null)
|
||||
const options = ref<BetOption[]>([])
|
||||
const entries = ref<BetEntry[]>([])
|
||||
const detailLoading = ref(false)
|
||||
|
||||
async function loadBets() {
|
||||
loading.value = true
|
||||
try {
|
||||
bets.value = await listBets(props.groupId)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBets()
|
||||
})
|
||||
|
||||
async function viewBet(betId: string) {
|
||||
viewingBetId.value = betId
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [bet, opts, ents] = await Promise.all([
|
||||
getBet(betId),
|
||||
getBetOptions(betId),
|
||||
getBetEntries(betId)
|
||||
])
|
||||
currentBet.value = bet
|
||||
options.value = opts
|
||||
entries.value = ents
|
||||
} catch (e) {
|
||||
showFailToast('加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
viewingBetId.value = null
|
||||
currentBet.value = null
|
||||
loadBets()
|
||||
}
|
||||
|
||||
function optionEntryCount(optionId: string): number {
|
||||
return entries.value.filter(e => e.option === optionId).length
|
||||
}
|
||||
|
||||
function optionTotalStake(optionId: string): number {
|
||||
return entries.value.filter(e => e.option === optionId).reduce((sum, e) => sum + e.stake, 0)
|
||||
}
|
||||
|
||||
const myEntry = () => entries.value.find(e => e.user === userStore.userId)
|
||||
|
||||
// 下注
|
||||
const showBet = ref(false)
|
||||
const selectedOption = ref<string>('')
|
||||
const stakeAmount = ref(0)
|
||||
const placeLoading = ref(false)
|
||||
|
||||
function openBetSheet(optionId: string) {
|
||||
if (!currentBet.value || currentBet.value.status !== 'open') return
|
||||
if (myEntry()) {
|
||||
showFailToast('你已经下注了')
|
||||
return
|
||||
}
|
||||
selectedOption.value = optionId
|
||||
stakeAmount.value = currentBet.value.minStake
|
||||
showBet.value = true
|
||||
}
|
||||
|
||||
async function handlePlaceBet() {
|
||||
if (!currentBet.value) return
|
||||
if (stakeAmount.value < currentBet.value.minStake || stakeAmount.value > currentBet.value.maxStake) {
|
||||
showFailToast(`下注范围 ${currentBet.value.minStake}-${currentBet.value.maxStake}`)
|
||||
return
|
||||
}
|
||||
placeLoading.value = true
|
||||
try {
|
||||
await placeBet(currentBet.value.id, selectedOption.value, stakeAmount.value)
|
||||
showSuccessToast('下注成功')
|
||||
showBet.value = false
|
||||
await viewBet(currentBet.value.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '下注失败')
|
||||
} finally {
|
||||
placeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建竞猜
|
||||
const showCreate = ref(false)
|
||||
const createForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
optionsText: '',
|
||||
minStake: 1,
|
||||
maxStake: 100,
|
||||
deadline: ''
|
||||
})
|
||||
const createLoading = ref(false)
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.value.title.trim()) {
|
||||
showFailToast('请输入标题')
|
||||
return
|
||||
}
|
||||
const opts = createForm.value.optionsText.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
if (opts.length < 2) {
|
||||
showFailToast('至少 2 个选项')
|
||||
return
|
||||
}
|
||||
if (!createForm.value.deadline) {
|
||||
showFailToast('请设置截止时间')
|
||||
return
|
||||
}
|
||||
createLoading.value = true
|
||||
try {
|
||||
await createBet({
|
||||
group: props.groupId,
|
||||
title: createForm.value.title.trim(),
|
||||
description: createForm.value.description.trim() || undefined,
|
||||
options: opts,
|
||||
minStake: createForm.value.minStake,
|
||||
maxStake: createForm.value.maxStake,
|
||||
deadline: createForm.value.deadline
|
||||
})
|
||||
showSuccessToast('创建成功')
|
||||
showCreate.value = false
|
||||
createForm.value = { title: '', description: '', optionsText: '', minStake: 1, maxStake: 100, deadline: '' }
|
||||
await loadBets()
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '创建失败')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭/结算(创建者)
|
||||
async function handleClose() {
|
||||
if (!currentBet.value) return
|
||||
showConfirmDialog({ title: '关闭竞猜', message: '关闭后将停止下注,确定?' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await closeBet(currentBet.value!.id)
|
||||
showSuccessToast('已关闭')
|
||||
await viewBet(currentBet.value!.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const showSettle = ref(false)
|
||||
const settleOption = ref('')
|
||||
|
||||
async function handleSettle() {
|
||||
if (!currentBet.value || !settleOption.value) return
|
||||
try {
|
||||
await settleBet(currentBet.value.id, settleOption.value)
|
||||
showSuccessToast('已结算')
|
||||
showSettle.value = false
|
||||
await viewBet(currentBet.value.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '结算失败')
|
||||
}
|
||||
}
|
||||
|
||||
const isCreator = () => currentBet.value?.creator === userStore.userId
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
|
||||
if (min < 60) return `${min}分钟前`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour}小时前`
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bet-mobile">
|
||||
<!-- 详情 -->
|
||||
<div v-if="viewingBetId && currentBet">
|
||||
<van-nav-bar :title="currentBet.title" left-arrow @click-left="backToList" />
|
||||
|
||||
<div v-if="detailLoading" class="loading-box">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail-content">
|
||||
<div class="detail-header">
|
||||
<van-tag :type="currentBet.status === 'open' ? 'success' : currentBet.status === 'closed' ? 'warning' : 'default'">
|
||||
{{ currentBet.status === 'open' ? '下注中' : currentBet.status === 'closed' ? '待结算' : '已结算' }}
|
||||
</van-tag>
|
||||
<span class="detail-meta">范围 {{ currentBet.minStake }}-{{ currentBet.maxStake }} 积分</span>
|
||||
</div>
|
||||
|
||||
<p v-if="currentBet.description" class="detail-desc">{{ currentBet.description }}</p>
|
||||
|
||||
<div class="options-list">
|
||||
<div
|
||||
v-for="opt in options"
|
||||
:key="opt.id"
|
||||
class="option-item"
|
||||
@click="currentBet.status === 'open' && openBetSheet(opt.id)"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span class="option-text">{{ opt.content }}</span>
|
||||
<span v-if="currentBet.resultOption === opt.id" class="winner-badge"><MobileIcon name="trophy" :size="13" color="var(--gg-warning)" /> 胜出</span>
|
||||
</div>
|
||||
<div class="option-stats">
|
||||
<span>{{ optionEntryCount(opt.id) }} 注</span>
|
||||
<span>{{ optionTotalStake(opt.id) }} 积分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的结果 -->
|
||||
<div v-if="currentBet.status === 'settled' && myEntry()" class="my-result">
|
||||
<van-notice-bar
|
||||
:type="myEntry()?.won ? 'success' : 'warning'"
|
||||
wrapable
|
||||
>
|
||||
{{ myEntry()?.won ? `你赢了!` : '本次未中奖' }}
|
||||
</van-notice-bar>
|
||||
</div>
|
||||
|
||||
<!-- 创建者操作 -->
|
||||
<div v-if="isCreator()" class="detail-actions">
|
||||
<van-button v-if="currentBet.status === 'open'" type="warning" block round plain @click="handleClose">
|
||||
关闭下注
|
||||
</van-button>
|
||||
<van-button v-if="currentBet.status === 'closed'" type="primary" block round @click="showSettle = true">
|
||||
结算
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<template v-else>
|
||||
<div class="list-header">
|
||||
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
|
||||
发起竞猜
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<van-pull-refresh v-model="loading" @refresh="loadBets">
|
||||
<div v-if="bets.length === 0 && !loading" class="empty">
|
||||
<van-empty description="暂无竞猜" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div class="bet-list">
|
||||
<div
|
||||
v-for="bet in bets"
|
||||
:key="bet.id"
|
||||
class="bet-card"
|
||||
@click="viewBet(bet.id)"
|
||||
>
|
||||
<div class="bet-card-header">
|
||||
<van-tag :type="bet.status === 'open' ? 'success' : bet.status === 'closed' ? 'warning' : 'default'" size="medium">
|
||||
{{ bet.status === 'open' ? '下注中' : bet.status === 'closed' ? '待结算' : '已结算' }}
|
||||
</van-tag>
|
||||
<span class="bet-stake">{{ bet.minStake }}-{{ bet.maxStake }} 积分</span>
|
||||
</div>
|
||||
<div class="bet-title">{{ bet.title }}</div>
|
||||
<div class="bet-card-footer">
|
||||
<span class="bet-time">{{ timeAgo(bet.created) }}</span>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-pull-refresh>
|
||||
</template>
|
||||
|
||||
<!-- 下注弹层 -->
|
||||
<van-action-sheet v-model:show="showBet" title="下注">
|
||||
<div class="bet-sheet-content">
|
||||
<div class="stake-display">
|
||||
<span class="stake-num">{{ stakeAmount }}</span>
|
||||
<span class="stake-unit">积分</span>
|
||||
</div>
|
||||
<van-stepper
|
||||
v-model="stakeAmount"
|
||||
:min="currentBet?.minStake"
|
||||
:max="currentBet?.maxStake"
|
||||
integer
|
||||
/>
|
||||
<div class="bet-sheet-actions">
|
||||
<van-button type="primary" block round :loading="placeLoading" @click="handlePlaceBet">
|
||||
确认下注
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
|
||||
<!-- 创建弹层 -->
|
||||
<van-popup v-model:show="showCreate" position="bottom" round closeable :style="{ height: '85%' }">
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">发起竞猜</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="createForm.title" label="标题" placeholder="竞猜主题" required />
|
||||
<van-field v-model="createForm.description" label="说明" placeholder="可选" />
|
||||
<van-field
|
||||
v-model="createForm.optionsText"
|
||||
type="textarea"
|
||||
label="选项"
|
||||
placeholder="每行一个选项"
|
||||
rows="3"
|
||||
/>
|
||||
<van-field name="stepper" label="最低下注">
|
||||
<template #input><van-stepper v-model="createForm.minStake" min="1" /></template>
|
||||
</van-field>
|
||||
<van-field name="stepper" label="最高下注">
|
||||
<template #input><van-stepper v-model="createForm.maxStake" min="1" /></template>
|
||||
</van-field>
|
||||
<van-field v-model="createForm.deadline" label="截止时间" placeholder="如 2026-12-31 23:59" required />
|
||||
</van-cell-group>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
|
||||
创建
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 结算弹层 -->
|
||||
<van-action-sheet v-model:show="showSettle" title="选择胜出选项">
|
||||
<div class="settle-content">
|
||||
<div
|
||||
v-for="opt in options"
|
||||
:key="opt.id"
|
||||
class="settle-option"
|
||||
:class="{ 'settle-selected': settleOption === opt.id }"
|
||||
@click="settleOption = opt.id"
|
||||
>
|
||||
{{ opt.content }}
|
||||
</div>
|
||||
<div class="settle-actions">
|
||||
<van-button type="primary" block round @click="handleSettle">确认结算</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-mobile { padding: 12px; }
|
||||
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||
.empty { padding: 30px 0; }
|
||||
.list-header { display: flex; justify-content: flex-end; margin-bottom: 10px; }
|
||||
|
||||
.bet-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bet-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
|
||||
.bet-card:active { opacity: 0.85; }
|
||||
.bet-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.bet-stake { font-size: 12px; color: var(--gg-text-muted); }
|
||||
.bet-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
|
||||
.bet-card-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; color: var(--gg-text-muted); font-size: 13px; }
|
||||
|
||||
.detail-content { padding: 12px; }
|
||||
.detail-header { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.detail-meta { font-size: 12px; color: var(--gg-text-muted); }
|
||||
.detail-desc { font-size: 14px; color: var(--gg-text-secondary); margin: 0 0 16px; line-height: 1.5; }
|
||||
|
||||
.options-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.option-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 12px 14px; border: 1px solid var(--gg-border); }
|
||||
.option-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.option-text { font-size: 14px; font-weight: 500; color: var(--gg-text); }
|
||||
.winner-badge { display: inline-flex; align-items: center; gap: 2px; font-size: 13px; color: var(--gg-warning); font-weight: 600; }
|
||||
.option-stats { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--gg-text-muted); }
|
||||
|
||||
.my-result { margin-top: 16px; }
|
||||
.detail-actions { margin-top: 20px; }
|
||||
|
||||
/* 下注弹层 */
|
||||
.bet-sheet-content { padding: 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
.stake-display { display: flex; align-items: baseline; gap: 4px; }
|
||||
.stake-num { font-size: 40px; font-weight: 700; color: var(--gg-primary); }
|
||||
.stake-unit { font-size: 14px; color: var(--gg-text-muted); }
|
||||
.bet-sheet-actions { width: 100%; }
|
||||
|
||||
/* 弹层通用 */
|
||||
.popup-content { padding: 16px 0 24px; display: flex; flex-direction: column; height: 100%; overflow-y: auto; }
|
||||
.popup-title { text-align: center; font-size: 16px; font-weight: 600; padding: 8px 0 16px; }
|
||||
.popup-actions { padding: 20px 16px 0; }
|
||||
|
||||
.settle-content { padding: 16px; }
|
||||
.settle-option { padding: 14px; background: var(--gg-bg); border-radius: var(--gg-radius-sm); margin-bottom: 8px; text-align: center; border: 1px solid var(--gg-border); font-size: 14px; }
|
||||
.settle-selected { background: rgba(5, 150, 105, 0.1); border-color: var(--gg-primary); color: var(--gg-primary); font-weight: 600; }
|
||||
.settle-actions { margin-top: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,285 @@
|
||||
<!-- src/components-mobile/bulletin/BulletinListMobile.vue -->
|
||||
<!-- 手机端信息公示板:置顶区 + 普通列表 + 发布 + 已读标记 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { subscribeBulletins } from '@/api/bulletins'
|
||||
import { BulletinPriorityMap } from '@/types'
|
||||
import type { BulletinPost } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
import BulletinPostSheetMobile from './BulletinPostSheetMobile.vue'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
|
||||
const posts = computed(() => bulletinStore.posts)
|
||||
const pinnedPosts = computed(() => bulletinStore.pinnedPosts)
|
||||
const normalPosts = computed(() => bulletinStore.normalPosts)
|
||||
|
||||
const loading = ref(false)
|
||||
let unsubscribeFn: (() => void) | null = null
|
||||
|
||||
// 详情/编辑面板
|
||||
const showSheet = ref(false)
|
||||
const viewingPost = ref<BulletinPost | null>(null)
|
||||
const editing = ref(false)
|
||||
|
||||
// 发布面板
|
||||
const showCreate = ref(false)
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
await bulletinStore.loadPosts(props.groupId)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll()
|
||||
try {
|
||||
unsubscribeFn = await subscribeBulletins(props.groupId, () => loadAll())
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribeFn) {
|
||||
unsubscribeFn()
|
||||
unsubscribeFn = null
|
||||
}
|
||||
})
|
||||
|
||||
function openDetail(post: BulletinPost) {
|
||||
viewingPost.value = post
|
||||
editing.value = false
|
||||
showSheet.value = true
|
||||
// 标记已读
|
||||
if (!bulletinStore.isRead(post.id)) {
|
||||
bulletinStore.markAsRead(post.id).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(post: BulletinPost) {
|
||||
viewingPost.value = post
|
||||
editing.value = true
|
||||
showSheet.value = true
|
||||
}
|
||||
|
||||
async function handleDelete(post: BulletinPost) {
|
||||
showConfirmDialog({
|
||||
title: '删除公告',
|
||||
message: `确定要删除「${post.title}」吗?`
|
||||
}).then(async () => {
|
||||
try {
|
||||
await bulletinStore.remove(post.id)
|
||||
showSuccessToast('删除成功')
|
||||
showSheet.value = false
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
try {
|
||||
await bulletinStore.markAllAsRead(props.groupId)
|
||||
showSuccessToast('全部已读')
|
||||
} catch (e: any) {
|
||||
showFailToast('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function priorityType(p: string): any {
|
||||
const map: Record<string, string> = { urgent: 'danger', high: 'warning', normal: 'primary', low: 'default' }
|
||||
return map[p] || 'default'
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
|
||||
if (min < 1) return '刚刚'
|
||||
if (min < 60) return `${min}分钟前`
|
||||
const hour = Math.floor(min / 60)
|
||||
if (hour < 24) return `${hour}小时前`
|
||||
const day = Math.floor(hour / 24)
|
||||
if (day < 30) return `${day}天前`
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 面板关闭后处理(编辑/发布后刷新)
|
||||
async function onSheetSaved() {
|
||||
showSheet.value = false
|
||||
showCreate.value = false
|
||||
await loadAll()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bulletin-mobile">
|
||||
<!-- 工具栏 -->
|
||||
<div class="toolbar">
|
||||
<van-button size="small" round icon="success" plain @click="handleMarkAllRead">全部已读</van-button>
|
||||
<van-button type="primary" size="small" round icon="edit" @click="showCreate = true">发布公告</van-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && posts.length === 0" class="loading-box">
|
||||
<van-loading size="24px">加载中...</van-loading>
|
||||
</div>
|
||||
|
||||
<div v-else-if="posts.length === 0" class="empty">
|
||||
<van-empty description="暂无公告" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div v-else class="post-list">
|
||||
<!-- 置顶区 -->
|
||||
<div v-if="pinnedPosts.length > 0" class="section-block">
|
||||
<div class="block-title">
|
||||
<van-icon name="bookmark-o" /> 置顶
|
||||
</div>
|
||||
<div
|
||||
v-for="post in pinnedPosts"
|
||||
:key="post.id"
|
||||
class="post-card pinned"
|
||||
:class="{ unread: !bulletinStore.isRead(post.id) }"
|
||||
@click="openDetail(post)"
|
||||
>
|
||||
<div class="post-header">
|
||||
<van-tag :type="priorityType(post.priority)" size="medium">{{ BulletinPriorityMap[post.priority] }}</van-tag>
|
||||
<span class="post-time">{{ timeAgo(post.created) }}</span>
|
||||
</div>
|
||||
<div class="post-title">{{ post.title }}</div>
|
||||
<div class="post-content-preview">{{ post.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 普通列表 -->
|
||||
<div v-if="normalPosts.length > 0" class="section-block">
|
||||
<div v-if="pinnedPosts.length > 0" class="block-title">
|
||||
<van-icon name="notes-o" /> 公告
|
||||
</div>
|
||||
<div
|
||||
v-for="post in normalPosts"
|
||||
:key="post.id"
|
||||
class="post-card"
|
||||
:class="{ unread: !bulletinStore.isRead(post.id) }"
|
||||
@click="openDetail(post)"
|
||||
>
|
||||
<div class="post-header">
|
||||
<van-tag :type="priorityType(post.priority)" size="medium">{{ BulletinPriorityMap[post.priority] }}</van-tag>
|
||||
<span class="post-time">{{ timeAgo(post.created) }}</span>
|
||||
</div>
|
||||
<div class="post-title">{{ post.title }}</div>
|
||||
<div class="post-content-preview">{{ post.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情/编辑面板 -->
|
||||
<BulletinPostSheetMobile
|
||||
v-model:show="showSheet"
|
||||
:post="viewingPost"
|
||||
:editing="editing"
|
||||
:group-id="props.groupId"
|
||||
@edit="openEdit"
|
||||
@delete="handleDelete"
|
||||
@saved="onSheetSaved"
|
||||
/>
|
||||
|
||||
<!-- 发布面板(复用 PostSheet,post 为 null 表示新建) -->
|
||||
<BulletinPostSheetMobile
|
||||
v-model:show="showCreate"
|
||||
:post="null"
|
||||
:editing="true"
|
||||
:group-id="props.groupId"
|
||||
@saved="onSheetSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bulletin-mobile { padding: 12px; }
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.loading-box { display: flex; justify-content: center; padding: 40px; }
|
||||
.empty { padding: 20px 0; }
|
||||
|
||||
.section-block { margin-bottom: 16px; }
|
||||
|
||||
.block-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.post-card {
|
||||
background: var(--gg-bg-card);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--gg-shadow);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.post-card:active { opacity: 0.85; }
|
||||
|
||||
.post-card.pinned {
|
||||
border-left: 3px solid var(--gg-warning);
|
||||
}
|
||||
|
||||
.post-card.unread::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--gg-danger);
|
||||
}
|
||||
|
||||
.post-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.post-time {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.post-content-preview {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
margin-top: 6px;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<!-- src/components-mobile/bulletin/BulletinPostSheetMobile.vue -->
|
||||
<!-- 公告详情/编辑/创建 三合一底部面板 -->
|
||||
<!-- - post=null & editing=true → 创建 -->
|
||||
<!-- - post=非null & editing=false → 详情(含编辑/删除) -->
|
||||
<!-- - post=非null & editing=true → 编辑 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useBulletinStore } from '@/stores/bulletin'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { BulletinPriorityMap } from '@/types'
|
||||
import type { BulletinPost, BulletinPriority } from '@/types'
|
||||
import { displayName } from '@/types'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
post: BulletinPost | null
|
||||
editing: boolean
|
||||
groupId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
'edit': [post: BulletinPost]
|
||||
'delete': [post: BulletinPost]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const bulletinStore = useBulletinStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (val) => emit('update:show', val)
|
||||
})
|
||||
|
||||
// 表单
|
||||
const form = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
priority: 'normal' as BulletinPriority,
|
||||
pinned: false,
|
||||
expiresAt: ''
|
||||
})
|
||||
const saving = ref(false)
|
||||
|
||||
// 是否创建模式
|
||||
const isCreate = computed(() => props.editing && !props.post)
|
||||
|
||||
// 权限:群主/管理员可编辑删除
|
||||
const currentUserId = computed(() => pb.authStore.model?.id || '')
|
||||
const canManage = computed(() => {
|
||||
if (!props.post) return false
|
||||
if (groupStore.isGroupOwner) return true
|
||||
if (groupStore.isGroupAdmin) return true
|
||||
if (props.post.creator === currentUserId.value) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// 打开时同步表单
|
||||
watch(() => props.show, (val) => {
|
||||
if (!val) return
|
||||
if (props.editing && props.post) {
|
||||
// 编辑模式:填充现有数据
|
||||
form.value = {
|
||||
title: props.post.title,
|
||||
content: props.post.content,
|
||||
priority: props.post.priority,
|
||||
pinned: props.post.pinned,
|
||||
expiresAt: props.post.expiresAt || ''
|
||||
}
|
||||
} else if (isCreate.value) {
|
||||
// 创建模式:清空
|
||||
form.value = { title: '', content: '', priority: 'normal', pinned: false, expiresAt: '' }
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.title.trim()) {
|
||||
showFailToast('请输入标题')
|
||||
return
|
||||
}
|
||||
if (!form.value.content.trim()) {
|
||||
showFailToast('请输入内容')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: form.value.title.trim(),
|
||||
content: form.value.content.trim(),
|
||||
priority: form.value.priority,
|
||||
pinned: form.value.pinned,
|
||||
expiresAt: form.value.expiresAt || undefined
|
||||
}
|
||||
if (isCreate.value) {
|
||||
await bulletinStore.create({ group: props.groupId, ...payload })
|
||||
showSuccessToast('发布成功')
|
||||
} else if (props.post) {
|
||||
await bulletinStore.update(props.post.id, payload)
|
||||
showSuccessToast('修改成功')
|
||||
}
|
||||
emit('saved')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const priorityKeys = Object.keys(BulletinPriorityMap) as BulletinPriority[]
|
||||
|
||||
function priorityType(p: string): any {
|
||||
const map: Record<string, string> = { urgent: 'danger', high: 'warning', normal: 'primary', low: 'default' }
|
||||
return map[p] || 'default'
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
round
|
||||
closeable
|
||||
:style="{ height: editing ? '80%' : '70%' }"
|
||||
>
|
||||
<div class="post-sheet">
|
||||
<!-- 编辑/创建模式 -->
|
||||
<template v-if="editing">
|
||||
<div class="sheet-title">{{ isCreate ? '发布公告' : '编辑公告' }}</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="form.title" label="标题" placeholder="公告标题" required maxlength="100" />
|
||||
<van-field
|
||||
v-model="form.content"
|
||||
type="textarea"
|
||||
label="内容"
|
||||
placeholder="公告正文(支持换行)"
|
||||
rows="5"
|
||||
autosize
|
||||
required
|
||||
/>
|
||||
<van-field name="priority" label="优先级">
|
||||
<template #input>
|
||||
<select v-model="form.priority" class="native-select">
|
||||
<option v-for="p in priorityKeys" :key="p" :value="p">{{ BulletinPriorityMap[p] }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-cell title="置顶" center>
|
||||
<template #right-icon>
|
||||
<van-switch v-model="form.pinned" size="22px" />
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-field v-model="form.expiresAt" label="截止时间" placeholder="可选,如 2026-12-31" />
|
||||
</van-cell-group>
|
||||
<div class="sheet-actions">
|
||||
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
|
||||
{{ isCreate ? '发布' : '保存' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 详情模式 -->
|
||||
<template v-else-if="post">
|
||||
<div class="detail-header">
|
||||
<van-tag :type="priorityType(post.priority)" size="large">{{ BulletinPriorityMap[post.priority] }}</van-tag>
|
||||
<van-tag v-if="post.pinned" type="warning" size="large">置顶</van-tag>
|
||||
<span class="detail-time">{{ formatDate(post.created) }}</span>
|
||||
</div>
|
||||
|
||||
<h2 class="detail-title">{{ post.title }}</h2>
|
||||
|
||||
<div class="detail-meta">
|
||||
<span>发布人:{{ displayName(post.expand?.creator) }}</span>
|
||||
<span v-if="post.expiresAt" class="detail-expire">截止:{{ formatDate(post.expiresAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-content">{{ post.content }}</div>
|
||||
|
||||
<div v-if="canManage" class="detail-actions">
|
||||
<van-button size="small" round icon="edit" @click="emit('edit', post)">编辑</van-button>
|
||||
<van-button size="small" round plain type="danger" icon="delete-o" @click="emit('delete', post)">删除</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.post-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.native-select {
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
background: var(--gg-bg-card);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sheet-actions {
|
||||
padding: 20px 16px 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* 详情 */
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
padding: 8px 16px 16px;
|
||||
}
|
||||
|
||||
.detail-expire {
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
font-size: 15px;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.7;
|
||||
padding: 0 16px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
padding: 24px 16px 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<!-- src/components-mobile/event/CreateEventSheetMobile.vue -->
|
||||
<!-- 创建活动底部面板:标题/描述/地点/开始/结束/人数上限 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useEventStore } from '@/stores/event'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
groupId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
'created': []
|
||||
}>()
|
||||
|
||||
const eventStore = useEventStore()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (val) => emit('update:show', val)
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
location: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
maxParticipants: 0
|
||||
})
|
||||
const saving = ref(false)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
// 默认开始时间为当前+1小时(取整点)
|
||||
const now = new Date()
|
||||
now.setHours(now.getHours() + 1, 0, 0, 0)
|
||||
form.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
location: '',
|
||||
startTime: toLocalInput(now),
|
||||
endTime: '',
|
||||
maxParticipants: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function toLocalInput(d: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.title.trim()) {
|
||||
showFailToast('请输入活动标题')
|
||||
return
|
||||
}
|
||||
if (!form.value.startTime) {
|
||||
showFailToast('请设置开始时间')
|
||||
return
|
||||
}
|
||||
const startTime = new Date(form.value.startTime).toISOString()
|
||||
const endTime = form.value.endTime ? new Date(form.value.endTime).toISOString() : undefined
|
||||
if (endTime && new Date(endTime) <= new Date(startTime)) {
|
||||
showFailToast('结束时间必须晚于开始时间')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await eventStore.addEvent({
|
||||
group: props.groupId,
|
||||
title: form.value.title.trim(),
|
||||
description: form.value.description.trim() || undefined,
|
||||
location: form.value.location.trim() || undefined,
|
||||
startTime,
|
||||
endTime,
|
||||
maxParticipants: form.value.maxParticipants > 0 ? form.value.maxParticipants : undefined
|
||||
})
|
||||
showSuccessToast('创建成功')
|
||||
emit('created')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '创建失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
round
|
||||
closeable
|
||||
:style="{ height: '80%' }"
|
||||
>
|
||||
<div class="create-sheet">
|
||||
<div class="sheet-title">发起活动</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="form.title" label="标题" placeholder="活动名称" required maxlength="100" />
|
||||
<van-field
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
label="描述"
|
||||
placeholder="活动详情(可选)"
|
||||
rows="3"
|
||||
autosize
|
||||
/>
|
||||
<van-field v-model="form.location" label="地点" placeholder="如:语音房 / 线下某处" />
|
||||
<van-field
|
||||
v-model="form.startTime"
|
||||
type="datetime-local"
|
||||
label="开始时间"
|
||||
placeholder="选择时间"
|
||||
required
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.endTime"
|
||||
type="datetime-local"
|
||||
label="结束时间"
|
||||
placeholder="可选"
|
||||
/>
|
||||
<van-field name="stepper" label="人数上限">
|
||||
<template #input>
|
||||
<van-stepper v-model="form.maxParticipants" min="0" max="200" />
|
||||
</template>
|
||||
</van-field>
|
||||
</van-cell-group>
|
||||
<div class="hint">人数上限为 0 表示不限制</div>
|
||||
<div class="sheet-actions">
|
||||
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
|
||||
创建活动
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.sheet-actions {
|
||||
padding: 16px;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<!-- src/components-mobile/event/EventListMobile.vue -->
|
||||
<!-- 手机端事件列表 + 展开详情(RSVP + 评论)+ 创建入口 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useEventStore } from '@/stores/event'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events'
|
||||
import { EventStatusMap, RSVPTypeMap } from '@/types'
|
||||
import type { Event, RSVPType } from '@/types'
|
||||
import { displayName } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||
import CreateEventSheetMobile from './CreateEventSheetMobile.vue'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const eventStore = useEventStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const upcomingEvents = computed(() => eventStore.upcomingEvents)
|
||||
const pastEvents = computed(() => eventStore.pastEvents)
|
||||
const expandedId = ref<string | null>(null)
|
||||
const showCreate = ref(false)
|
||||
|
||||
let unsubEvents: (() => void) | null = null
|
||||
let unsubComments: (() => void) | null = null
|
||||
let unsubRSVPs: (() => void) | null = null
|
||||
|
||||
const currentUserId = computed(() => pb.authStore.model?.id || '')
|
||||
|
||||
onMounted(async () => {
|
||||
await eventStore.loadEvents(props.groupId)
|
||||
try {
|
||||
unsubEvents = await subscribeEvents(props.groupId, async () => {
|
||||
await eventStore.loadEvents(props.groupId)
|
||||
if (expandedId.value) await loadDetail(expandedId.value)
|
||||
})
|
||||
} catch (e) { console.error(e) }
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubEvents) unsubEvents()
|
||||
if (unsubComments) unsubComments()
|
||||
if (unsubRSVPs) unsubRSVPs()
|
||||
eventStore.clear()
|
||||
})
|
||||
|
||||
async function loadDetail(eventId: string) {
|
||||
await eventStore.loadCommentsAndRSVPs(eventId)
|
||||
}
|
||||
|
||||
async function toggleExpand(event: Event) {
|
||||
if (expandedId.value === event.id) {
|
||||
expandedId.value = null
|
||||
if (unsubComments) { unsubComments(); unsubComments = null }
|
||||
if (unsubRSVPs) { unsubRSVPs(); unsubRSVPs = null }
|
||||
} else {
|
||||
expandedId.value = event.id
|
||||
await loadDetail(event.id)
|
||||
if (unsubComments) unsubComments()
|
||||
if (unsubRSVPs) unsubRSVPs()
|
||||
try {
|
||||
unsubComments = await subscribeEventComments(event.id, () => loadDetail(event.id))
|
||||
unsubRSVPs = await subscribeEventRSVPs(event.id, () => loadDetail(event.id))
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
}
|
||||
|
||||
const rsvps = computed(() => eventStore.rsvps)
|
||||
const comments = computed(() => eventStore.comments)
|
||||
|
||||
function myRsvp(eventId: string) {
|
||||
if (expandedId.value !== eventId) return null
|
||||
return rsvps.value.find(r => r.user === currentUserId.value) || null
|
||||
}
|
||||
|
||||
function goingCount(eventId: string): number {
|
||||
if (expandedId.value !== eventId) return 0
|
||||
return rsvps.value.filter(r => r.type === 'going').length
|
||||
}
|
||||
|
||||
async function doRsvp(event: Event, type: RSVPType) {
|
||||
try {
|
||||
await eventStore.rsvp(event.id, type)
|
||||
showSuccessToast(`已${RSVPTypeMap[type]}`)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRsvp(event: Event) {
|
||||
try {
|
||||
await eventStore.cancelRSVP(event.id)
|
||||
showSuccessToast('已取消')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 评论
|
||||
const newComment = ref('')
|
||||
const commentSubmitting = ref(false)
|
||||
|
||||
async function submitComment(eventId: string) {
|
||||
if (!newComment.value.trim()) {
|
||||
showFailToast('请输入评论')
|
||||
return
|
||||
}
|
||||
commentSubmitting.value = true
|
||||
try {
|
||||
await eventStore.addComment(eventId, newComment.value.trim())
|
||||
newComment.value = ''
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '评论失败')
|
||||
} finally {
|
||||
commentSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(event: Event) {
|
||||
showConfirmDialog({
|
||||
title: '删除事件',
|
||||
message: `确定要删除「${event.title}」吗?`
|
||||
}).then(async () => {
|
||||
try {
|
||||
await eventStore.removeEvent(event.id)
|
||||
showSuccessToast('已删除')
|
||||
expandedId.value = null
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function canManage(event: Event): boolean {
|
||||
if (groupStore.isGroupOwner) return true
|
||||
if (groupStore.isGroupAdmin) return true
|
||||
if (event.creator === currentUserId.value) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function statusType(s: string): any {
|
||||
return s === 'upcoming' ? 'primary' : s === 'ongoing' ? 'success' : 'default'
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function onCreated() {
|
||||
showCreate.value = false
|
||||
await eventStore.loadEvents(props.groupId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="event-mobile">
|
||||
<div class="toolbar">
|
||||
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
|
||||
发起活动
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div v-if="upcomingEvents.length === 0 && pastEvents.length === 0" class="empty">
|
||||
<van-empty description="暂无活动" image-size="100">
|
||||
<van-button type="primary" size="small" round @click="showCreate = true">发起第一个活动</van-button>
|
||||
</van-empty>
|
||||
</div>
|
||||
|
||||
<!-- 即将开始 -->
|
||||
<div v-if="upcomingEvents.length > 0" class="section-block">
|
||||
<div class="block-title">即将开始</div>
|
||||
<div v-for="event in upcomingEvents" :key="event.id" class="event-card-wrap">
|
||||
<div class="event-card" @click="toggleExpand(event)">
|
||||
<div class="event-card-header">
|
||||
<van-tag :type="statusType(event.status)" size="medium">{{ EventStatusMap[event.status] }}</van-tag>
|
||||
<span v-if="expandedId !== event.id && goingCount(event.id) > 0" class="event-going">
|
||||
{{ goingCount(event.id) }} 人参加
|
||||
</span>
|
||||
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="event-title">{{ event.title }}</div>
|
||||
<div class="event-time"><MobileIcon name="clock" :size="13" /> {{ formatDateTime(event.startTime) }}</div>
|
||||
<div v-if="event.location" class="event-loc"><MobileIcon name="location" :size="13" /> {{ event.location }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 展开详情 -->
|
||||
<div v-if="expandedId === event.id" class="event-detail">
|
||||
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
|
||||
<div v-if="event.endTime" class="detail-meta">结束:{{ formatDateTime(event.endTime) }}</div>
|
||||
<div v-if="event.maxParticipants" class="detail-meta">人数上限:{{ event.maxParticipants }}</div>
|
||||
|
||||
<!-- RSVP -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">参加报名({{ goingCount(event.id) }} 人)</div>
|
||||
<div class="rsvp-row">
|
||||
<template v-if="myRsvp(event.id)">
|
||||
<van-tag :type="myRsvp(event.id)!.type === 'going' ? 'success' : 'primary'" size="large">
|
||||
{{ RSVPTypeMap[myRsvp(event.id)!.type] }}
|
||||
</van-tag>
|
||||
<van-button size="mini" plain round @click="cancelRsvp(event)">取消报名</van-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-button size="small" type="primary" round @click="doRsvp(event, 'going')">参加</van-button>
|
||||
<van-button size="small" round @click="doRsvp(event, 'interested')">感兴趣</van-button>
|
||||
<van-button size="small" round @click="doRsvp(event, 'maybe')">可能参加</van-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评论 -->
|
||||
<div class="detail-section">
|
||||
<div class="detail-section-title">评论 ({{ comments.length }})</div>
|
||||
<div class="comment-input-row">
|
||||
<van-field v-model="newComment" placeholder="写评论..." class="comment-input" />
|
||||
<van-button size="small" type="primary" :loading="commentSubmitting" @click="submitComment(event.id)">发送</van-button>
|
||||
</div>
|
||||
<div v-if="comments.length === 0" class="comment-empty">暂无评论</div>
|
||||
<div v-else class="comment-list">
|
||||
<div v-for="c in comments" :key="c.id" class="comment-item">
|
||||
<img :src="c.expand?.user?.avatar || '/default-avatar.svg'" class="comment-avatar" alt="" />
|
||||
<div class="comment-body">
|
||||
<div class="comment-author">{{ displayName(c.expand?.user) }}</div>
|
||||
<div class="comment-content">{{ c.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 管理操作 -->
|
||||
<div v-if="canManage(event)" class="detail-actions">
|
||||
<van-button size="small" plain type="danger" round icon="delete-o" @click="handleDelete(event)">删除</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已结束 -->
|
||||
<div v-if="pastEvents.length > 0" class="section-block">
|
||||
<div class="block-title">已结束</div>
|
||||
<div v-for="event in pastEvents" :key="event.id" class="event-card-wrap">
|
||||
<div class="event-card ended" @click="toggleExpand(event)">
|
||||
<div class="event-card-header">
|
||||
<van-tag type="default" size="medium">{{ EventStatusMap[event.status] }}</van-tag>
|
||||
<van-icon :name="expandedId === event.id ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="event-title">{{ event.title }}</div>
|
||||
<div class="event-time"><MobileIcon name="clock" :size="13" /> {{ formatDateTime(event.startTime) }}</div>
|
||||
</div>
|
||||
<div v-if="expandedId === event.id" class="event-detail">
|
||||
<p v-if="event.description" class="detail-desc">{{ event.description }}</p>
|
||||
<div v-if="canManage(event)" class="detail-actions">
|
||||
<van-button size="small" plain type="danger" round icon="delete-o" @click="handleDelete(event)">删除</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateEventSheetMobile v-model:show="showCreate" :group-id="props.groupId" @created="onCreated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.event-mobile { padding: 12px; }
|
||||
|
||||
.toolbar { display: flex; justify-content: flex-end; margin-bottom: 12px; }
|
||||
.empty { padding: 20px 0; }
|
||||
|
||||
.section-block { margin-bottom: 16px; }
|
||||
.block-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.event-card-wrap { margin-bottom: 10px; }
|
||||
|
||||
.event-card {
|
||||
background: var(--gg-bg-card);
|
||||
border-radius: var(--gg-radius-md);
|
||||
padding: 14px;
|
||||
box-shadow: var(--gg-shadow);
|
||||
}
|
||||
.event-card:active { opacity: 0.85; }
|
||||
.event-card.ended { opacity: 0.7; }
|
||||
|
||||
.event-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.event-going { font-size: 12px; color: var(--gg-text-muted); margin-left: auto; }
|
||||
.expand-icon { margin-left: auto; color: var(--gg-text-muted); font-size: 14px; }
|
||||
|
||||
.event-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
|
||||
.event-time { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--gg-text-secondary); margin-top: 6px; }
|
||||
.event-loc { display: flex; align-items: center; gap: 4px; font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; }
|
||||
|
||||
.event-detail {
|
||||
background: var(--gg-bg);
|
||||
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
|
||||
padding: 14px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.detail-desc { font-size: 14px; color: var(--gg-text); line-height: 1.6; margin: 0 0 12px; white-space: pre-wrap; }
|
||||
.detail-meta { font-size: 12px; color: var(--gg-text-muted); margin-bottom: 4px; }
|
||||
|
||||
.detail-section { margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--gg-border); }
|
||||
.detail-section-title { font-size: 13px; font-weight: 600; color: var(--gg-text-secondary); margin-bottom: 10px; }
|
||||
|
||||
.rsvp-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
|
||||
.comment-input-row { display: flex; gap: 8px; align-items: center; }
|
||||
.comment-input { flex: 1; background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 4px 10px; }
|
||||
.comment-empty { font-size: 12px; color: var(--gg-text-muted); padding: 12px 0; text-align: center; }
|
||||
.comment-list { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
||||
.comment-item { display: flex; gap: 8px; }
|
||||
.comment-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
|
||||
.comment-body { flex: 1; min-width: 0; }
|
||||
.comment-author { font-size: 12px; font-weight: 600; color: var(--gg-text); }
|
||||
.comment-content { font-size: 13px; color: var(--gg-text-secondary); margin-top: 2px; word-break: break-word; }
|
||||
|
||||
.detail-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--gg-border); }
|
||||
</style>
|
||||
@@ -0,0 +1,228 @@
|
||||
<!-- src/components-mobile/game/AddGameSheetMobile.vue -->
|
||||
<!-- 添加游戏表单(绑定到当前群组):名称/别名/平台/标签/封面 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { addGame, getAllPlatforms } from '@/api/games'
|
||||
import type { GamePlatform } from '@/types'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
groupId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
'saved': []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (val) => emit('update:show', val)
|
||||
})
|
||||
|
||||
const platforms = getAllPlatforms()
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
aliases: '',
|
||||
platform: '' as GamePlatform | '',
|
||||
tags: ''
|
||||
})
|
||||
const coverFile = ref<File | null>(null)
|
||||
const coverPreview = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
form.value = { name: '', aliases: '', platform: '', tags: '' }
|
||||
coverFile.value = null
|
||||
coverPreview.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files?.[0]) {
|
||||
coverFile.value = input.files[0]
|
||||
coverPreview.value = URL.createObjectURL(input.files[0])
|
||||
}
|
||||
}
|
||||
|
||||
function clearCover() {
|
||||
coverFile.value = null
|
||||
coverPreview.value = ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!form.value.name.trim()) {
|
||||
showFailToast('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
saving.value = true
|
||||
const aliases = form.value.aliases.split(',').map(t => t.trim()).filter(Boolean)
|
||||
const tags = form.value.tags.split(',').map(t => t.trim()).filter(Boolean)
|
||||
await addGame(props.groupId, {
|
||||
name: form.value.name.trim(),
|
||||
aliases: aliases.length > 0 ? aliases : undefined,
|
||||
platform: form.value.platform || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined,
|
||||
coverFile: coverFile.value || undefined
|
||||
})
|
||||
showSuccessToast('添加成功')
|
||||
emit('saved')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '添加失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
round
|
||||
closeable
|
||||
:style="{ height: '80%' }"
|
||||
>
|
||||
<div class="add-sheet">
|
||||
<div class="sheet-title">添加游戏</div>
|
||||
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="form.name" label="名称" placeholder="游戏名称" required />
|
||||
<van-field v-model="form.aliases" label="别名" placeholder="逗号分隔,如 LOL,英雄联盟" />
|
||||
<van-field name="platform" label="平台">
|
||||
<template #input>
|
||||
<select v-model="form.platform" class="native-select">
|
||||
<option value="">不指定</option>
|
||||
<option v-for="p in platforms" :key="p" :value="p">{{ p }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field v-model="form.tags" label="标签" placeholder="逗号分隔,如 MOBA,竞技" />
|
||||
</van-cell-group>
|
||||
|
||||
<!-- 封面上传 -->
|
||||
<div class="cover-field">
|
||||
<div class="field-label">封面图</div>
|
||||
<div class="cover-upload">
|
||||
<div v-if="coverPreview" class="cover-preview">
|
||||
<img :src="coverPreview" alt="" />
|
||||
<button class="cover-clear" @click="clearCover">
|
||||
<van-icon name="cross" />
|
||||
</button>
|
||||
</div>
|
||||
<label v-else class="cover-picker">
|
||||
<van-icon name="photograph" size="28" />
|
||||
<span>上传封面</span>
|
||||
<input type="file" accept="image/*" class="file-input" @change="onFileChange" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-actions">
|
||||
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
|
||||
添加游戏
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.add-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 8px 0 16px;
|
||||
}
|
||||
|
||||
.native-select {
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
background: var(--gg-bg-card);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cover-field {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.cover-upload {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
position: relative;
|
||||
width: 90px;
|
||||
height: 120px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.cover-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-clear {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cover-picker {
|
||||
width: 90px;
|
||||
height: 120px;
|
||||
border: 1px dashed var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sheet-actions {
|
||||
padding: 20px 16px 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,557 @@
|
||||
<!-- src/components-mobile/game/GameDetailSheetMobile.vue -->
|
||||
<!-- 游戏详情底部面板:封面/名称/别名/标签/收藏/编辑/删除/快速组队 + 评论评分 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Game, GamePlatform, GameComment } from '@/types'
|
||||
import {
|
||||
toggleFavorite, isFavorite, deleteGame, updateGame, getGameCoverUrl, getAllPlatforms,
|
||||
getGameComments, addComment
|
||||
} from '@/api/games'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { createTeamSession } from '@/api/sessions'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
game: Game | null
|
||||
groupId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
'deleted': []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const groupStore = useGroupStore()
|
||||
const teamStore = useTeamStore()
|
||||
const platforms = getAllPlatforms()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (val) => emit('update:show', val)
|
||||
})
|
||||
|
||||
const favorited = ref(false)
|
||||
const editing = ref(false)
|
||||
const editName = ref('')
|
||||
const editAliases = ref('')
|
||||
const editPlatform = ref<GamePlatform | ''>('')
|
||||
const editTags = ref('')
|
||||
const saving = ref(false)
|
||||
|
||||
// 评论
|
||||
const comments = ref<GameComment[]>([])
|
||||
const newContent = ref('')
|
||||
const newRating = ref(0)
|
||||
const commentLoading = ref(false)
|
||||
const commentSubmitting = ref(false)
|
||||
|
||||
const canModify = computed(() => {
|
||||
if (!props.game) return false
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) return false
|
||||
if (groupStore.isGroupOwner) return true
|
||||
if (groupStore.isGroupAdmin) return true
|
||||
if (props.game.addedBy === userId) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// 打开时初始化
|
||||
watch(() => props.show, async (val) => {
|
||||
if (val && props.game) {
|
||||
editing.value = false
|
||||
try {
|
||||
favorited.value = await isFavorite(props.game.id)
|
||||
} catch { /* ignore */ }
|
||||
await loadComments()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadComments() {
|
||||
if (!props.game) return
|
||||
try {
|
||||
commentLoading.value = true
|
||||
comments.value = await getGameComments(props.game.id)
|
||||
} catch { /* ignore */ } finally {
|
||||
commentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit() {
|
||||
if (!props.game) return
|
||||
editName.value = props.game.name
|
||||
editAliases.value = (props.game.aliases || []).join(', ')
|
||||
editPlatform.value = props.game.platform || ''
|
||||
editTags.value = (props.game.tags || []).join(', ')
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.game || !editName.value.trim()) {
|
||||
showFailToast('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
try {
|
||||
saving.value = true
|
||||
const aliases = editAliases.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
const tags = editTags.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||
await updateGame(props.game.id, {
|
||||
name: editName.value.trim(),
|
||||
aliases: aliases.length > 0 ? aliases : undefined,
|
||||
platform: editPlatform.value || undefined,
|
||||
tags: tags.length > 0 ? tags : undefined
|
||||
})
|
||||
showSuccessToast('修改成功')
|
||||
editing.value = false
|
||||
emit('deleted') // 触发父组件重新加载
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '修改失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFavorite() {
|
||||
if (!props.game) return
|
||||
try {
|
||||
favorited.value = await toggleFavorite(props.game.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!props.game) return
|
||||
const game = props.game
|
||||
showConfirmDialog({
|
||||
title: '删除游戏',
|
||||
message: `确定要删除「${game.name}」吗?`
|
||||
}).then(async () => {
|
||||
try {
|
||||
await deleteGame(game.id)
|
||||
showSuccessToast('删除成功')
|
||||
visible.value = false
|
||||
emit('deleted')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function handleCreateTeam() {
|
||||
if (!props.game || !props.groupId) return
|
||||
try {
|
||||
const me = pb.authStore.model?.id
|
||||
if (!me) return
|
||||
const session = await createTeamSession({
|
||||
sourceGroup: props.groupId,
|
||||
name: `${props.game.name}小队`,
|
||||
gameName: props.game.name,
|
||||
members: [me]
|
||||
})
|
||||
await teamStore.loadActiveSession()
|
||||
showSuccessToast('已发起组队')
|
||||
visible.value = false
|
||||
router.push({
|
||||
name: 'VoiceRoom',
|
||||
params: { groupId: props.groupId, sessionId: session.id }
|
||||
})
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '组队失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitComment() {
|
||||
if (!props.game) return
|
||||
if (!newContent.value.trim()) {
|
||||
showFailToast('请输入评论内容')
|
||||
return
|
||||
}
|
||||
try {
|
||||
commentSubmitting.value = true
|
||||
await addComment(props.game.id, newContent.value.trim(), newRating.value || undefined)
|
||||
newContent.value = ''
|
||||
newRating.value = 0
|
||||
await loadComments()
|
||||
showSuccessToast('评论成功')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '评论失败')
|
||||
} finally {
|
||||
commentSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
round
|
||||
closeable
|
||||
:style="{ height: '85%' }"
|
||||
>
|
||||
<div v-if="game" class="detail-sheet">
|
||||
<!-- 编辑模式 -->
|
||||
<template v-if="editing">
|
||||
<div class="sheet-title">编辑游戏</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="editName" label="名称" placeholder="游戏名称" required />
|
||||
<van-field v-model="editAliases" label="别名" placeholder="逗号分隔" />
|
||||
<van-field name="platform" label="平台">
|
||||
<template #input>
|
||||
<select v-model="editPlatform" class="native-select">
|
||||
<option value="">不指定</option>
|
||||
<option v-for="p in platforms" :key="p" :value="p">{{ p }}</option>
|
||||
</select>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field v-model="editTags" label="标签" placeholder="逗号分隔" />
|
||||
</van-cell-group>
|
||||
<div class="edit-actions">
|
||||
<van-button block round @click="editing = false">取消</van-button>
|
||||
<van-button type="primary" block round :loading="saving" @click="handleSave">保存</van-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 详情模式 -->
|
||||
<template v-else>
|
||||
<div class="detail-cover-wrap">
|
||||
<img
|
||||
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
|
||||
:alt="game.name"
|
||||
class="detail-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 class="detail-name">{{ game.name }}</h2>
|
||||
|
||||
<div v-if="game.aliases?.length" class="detail-aliases">
|
||||
<span v-for="alias in game.aliases" :key="alias" class="alias-tag">{{ alias }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-meta">
|
||||
<van-tag v-if="game.platform" type="primary" size="medium">{{ game.platform }}</van-tag>
|
||||
<span class="popularity"><MobileIcon name="fire" :size="13" color="var(--gg-warning)" /> {{ game.popularCount }} 人游玩</span>
|
||||
</div>
|
||||
|
||||
<div v-if="game.tags?.length" class="detail-tags">
|
||||
<van-tag v-for="tag in game.tags" :key="tag" plain size="medium">{{ tag }}</van-tag>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="detail-actions">
|
||||
<van-button
|
||||
:type="favorited ? 'warning' : 'default'"
|
||||
size="small"
|
||||
round
|
||||
:icon="favorited ? 'star' : 'star-o'"
|
||||
@click="handleFavorite"
|
||||
>{{ favorited ? '已收藏' : '收藏' }}</van-button>
|
||||
<van-button v-if="canModify" size="small" round icon="edit" @click="startEdit">编辑</van-button>
|
||||
<van-button v-if="canModify" size="small" round plain type="danger" icon="delete-o" @click="handleDelete">删除</van-button>
|
||||
</div>
|
||||
|
||||
<van-button type="primary" block round class="team-btn" @click="handleCreateTeam">
|
||||
快速组队
|
||||
</van-button>
|
||||
|
||||
<!-- 评论区 -->
|
||||
<div class="comments-section">
|
||||
<div class="comments-title">评论 ({{ comments.length }})</div>
|
||||
|
||||
<!-- 评论输入 -->
|
||||
<div class="comment-form">
|
||||
<div class="rating-row">
|
||||
<span class="rating-label">评分</span>
|
||||
<div class="stars">
|
||||
<van-icon
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
name="star"
|
||||
:class="{ active: star <= newRating }"
|
||||
size="18"
|
||||
@click="newRating = star"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<van-field
|
||||
v-model="newContent"
|
||||
type="textarea"
|
||||
placeholder="写下你的评价..."
|
||||
rows="2"
|
||||
autosize
|
||||
class="comment-input"
|
||||
/>
|
||||
<van-button
|
||||
type="primary"
|
||||
size="small"
|
||||
round
|
||||
:loading="commentSubmitting"
|
||||
@click="handleSubmitComment"
|
||||
>发表</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 评论列表 -->
|
||||
<div v-if="commentLoading" class="comment-loading">
|
||||
<van-loading size="20px">加载中...</van-loading>
|
||||
</div>
|
||||
<div v-else-if="comments.length === 0" class="comment-empty">
|
||||
暂无评论,快来抢沙发
|
||||
</div>
|
||||
<div v-else class="comment-list">
|
||||
<div v-for="c in comments" :key="c.id" class="comment-item">
|
||||
<img
|
||||
:src="c.expand?.author?.avatar || '/default-avatar.svg'"
|
||||
class="comment-avatar"
|
||||
alt=""
|
||||
/>
|
||||
<div class="comment-body">
|
||||
<div class="comment-head">
|
||||
<span class="comment-author">{{ c.expand?.author?.name || c.expand?.author?.username || '匿名' }}</span>
|
||||
<span v-if="c.rating" class="comment-rating">
|
||||
<van-icon v-for="s in c.rating" :key="s" name="star" class="star-filled" size="12" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="comment-content">{{ c.content }}</div>
|
||||
<div class="comment-time">{{ formatDate(c.created) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 0 24px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 4px 0 8px;
|
||||
}
|
||||
|
||||
.native-select {
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
background: var(--gg-bg-card);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.edit-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.detail-cover-wrap {
|
||||
width: 160px;
|
||||
height: 210px;
|
||||
border-radius: var(--gg-radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--gg-bg-elevated);
|
||||
border: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.detail-name {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--gg-text);
|
||||
text-align: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.detail-aliases {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.alias-tag {
|
||||
padding: 3px 10px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.popularity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.detail-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.team-btn {
|
||||
width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
/* 评论区 */
|
||||
.comments-section {
|
||||
width: 100%;
|
||||
padding: 8px 16px 0;
|
||||
border-top: 8px solid var(--gg-bg);
|
||||
}
|
||||
|
||||
.comments-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.comment-form {
|
||||
background: var(--gg-bg);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.rating-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rating-label {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-secondary);
|
||||
}
|
||||
|
||||
.stars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stars .van-icon {
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.stars .van-icon.active {
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.comment-form .van-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.comment-loading, .comment-empty {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.comment-rating .star-filled {
|
||||
color: var(--gg-warning);
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<!-- src/components-mobile/game/ImportGamesSheetMobile.vue -->
|
||||
<!-- 批量导入游戏:每行一个游戏名(可含平台/标签),绑定到当前群组 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { importGames } from '@/api/games'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
groupId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:show': [value: boolean]
|
||||
'imported': []
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.show,
|
||||
set: (val) => emit('update:show', val)
|
||||
})
|
||||
|
||||
const gamesText = ref('')
|
||||
const importing = ref(false)
|
||||
const result = ref<{ success: number; failed: number } | null>(null)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
gamesText.value = ''
|
||||
result.value = null
|
||||
}
|
||||
})
|
||||
|
||||
async function handleImport() {
|
||||
const lines = gamesText.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
if (lines.length === 0) {
|
||||
showFailToast('请输入游戏名称(每行一个)')
|
||||
return
|
||||
}
|
||||
try {
|
||||
importing.value = true
|
||||
result.value = null
|
||||
// 每行解析:支持 "游戏名" 或 "游戏名 | 平台" 或 "游戏名 | 平台 | 标签1,标签2"
|
||||
const games = lines.map(line => {
|
||||
const parts = line.split('|').map(s => s.trim())
|
||||
const name = parts[0]
|
||||
const platform = parts[1] || undefined
|
||||
const tags = parts[2] ? parts[2].split(',').map(t => t.trim()).filter(Boolean) : undefined
|
||||
return { name, platform: platform as any, tags }
|
||||
})
|
||||
const results = await importGames(props.groupId, games)
|
||||
const success = results.filter(r => r.success).length
|
||||
const failed = results.length - success
|
||||
result.value = { success, failed }
|
||||
if (failed === 0) {
|
||||
showSuccessToast(`成功导入 ${success} 个游戏`)
|
||||
emit('imported')
|
||||
} else {
|
||||
showFailToast(`成功 ${success},失败 ${failed}`)
|
||||
}
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '导入失败')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<van-popup
|
||||
v-model:show="visible"
|
||||
position="bottom"
|
||||
round
|
||||
closeable
|
||||
:style="{ height: '70%' }"
|
||||
>
|
||||
<div class="import-sheet">
|
||||
<div class="sheet-title">批量导入游戏</div>
|
||||
|
||||
<div class="hint">
|
||||
每行输入一个游戏,支持以下格式:
|
||||
<code>游戏名</code> 或
|
||||
<code>游戏名 | 平台</code> 或
|
||||
<code>游戏名 | 平台 | 标签1,标签2</code>
|
||||
</div>
|
||||
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="gamesText"
|
||||
type="textarea"
|
||||
placeholder="每行一个游戏,可用 | 分隔平台和标签"
|
||||
rows="8"
|
||||
autosize
|
||||
/>
|
||||
</van-cell-group>
|
||||
|
||||
<div v-if="result" class="result-bar">
|
||||
<van-tag type="success">成功 {{ result.success }}</van-tag>
|
||||
<van-tag v-if="result.failed > 0" type="danger">失败 {{ result.failed }}</van-tag>
|
||||
</div>
|
||||
|
||||
<div class="sheet-actions">
|
||||
<van-button type="primary" block round :loading="importing" @click="handleImport">
|
||||
开始导入
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0 24px;
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
padding: 0 16px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hint code {
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.sheet-actions {
|
||||
padding: 20px 16px 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,476 @@
|
||||
<!-- src/components-mobile/group/ActivityFeedMobile.vue -->
|
||||
<!-- 群组动态:当前组队状态 + 空闲成员 + 所有成员按状态分组 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createTeamSession } from '@/api/sessions'
|
||||
import { sendBulkInvitations } from '@/api/invitations'
|
||||
import type { User } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
import MobileIcon from '@/components-mobile/MobileIcon.vue'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const teamStore = useTeamStore()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
|
||||
// 按状态分组
|
||||
const membersByStatus = computed(() => {
|
||||
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
|
||||
for (const m of members.value) {
|
||||
const s = (m.status || 'idle') as keyof typeof groups
|
||||
if (groups[s]) groups[s].push(m)
|
||||
else groups.away.push(m)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
idle: '空闲',
|
||||
in_team: '组队中',
|
||||
working: '工作中',
|
||||
away: '离开'
|
||||
}
|
||||
const statusColors: Record<string, string> = {
|
||||
idle: 'var(--gg-success)',
|
||||
in_team: 'var(--gg-info)',
|
||||
working: 'var(--gg-danger)',
|
||||
away: 'var(--gg-text-muted)'
|
||||
}
|
||||
|
||||
// 发起组队
|
||||
const showCreateTeam = ref(false)
|
||||
const teamName = ref('')
|
||||
const teamGame = ref('')
|
||||
const selectedMembers = ref<string[]>([])
|
||||
const teamLoading = ref(false)
|
||||
|
||||
const idleMembers = computed(() => membersByStatus.value.idle)
|
||||
|
||||
async function handleCreateTeam() {
|
||||
if (!teamGame.value.trim()) {
|
||||
showFailToast('请输入游戏名称')
|
||||
return
|
||||
}
|
||||
teamLoading.value = true
|
||||
try {
|
||||
const me = userStore.userId
|
||||
const memberIds = [...new Set([me, ...selectedMembers.value])]
|
||||
const session = await createTeamSession({
|
||||
sourceGroup: props.groupId,
|
||||
name: teamName.value.trim() || `${teamGame.value}小队`,
|
||||
gameName: teamGame.value.trim(),
|
||||
members: memberIds
|
||||
})
|
||||
// 邀请选中的成员
|
||||
const toInvite = selectedMembers.value.filter(id => id !== me)
|
||||
if (toInvite.length > 0 && session?.id) {
|
||||
await sendBulkInvitations(toInvite, session.id)
|
||||
}
|
||||
await teamStore.loadActiveSession()
|
||||
showCreateTeam.value = false
|
||||
teamName.value = ''
|
||||
teamGame.value = ''
|
||||
selectedMembers.value = []
|
||||
showSuccessToast('组队成功')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '组队失败')
|
||||
} finally {
|
||||
teamLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMember(id: string) {
|
||||
const idx = selectedMembers.value.indexOf(id)
|
||||
if (idx === -1) selectedMembers.value.push(id)
|
||||
else selectedMembers.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
// 当前组队状态
|
||||
const hasSession = computed(() => !!teamStore.currentSession)
|
||||
const sessionInThisGroup = computed(() =>
|
||||
teamStore.currentSession?.sourceGroup === props.groupId
|
||||
)
|
||||
|
||||
function goVoiceRoom() {
|
||||
const s = teamStore.currentSession
|
||||
if (s) {
|
||||
router.push({
|
||||
name: 'VoiceRoom',
|
||||
params: { groupId: s.sourceGroup, sessionId: s.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 当前会话状态(用于显示招募中/游戏中)
|
||||
const sessionStatus = computed(() => teamStore.currentSession?.status || 'recruiting')
|
||||
const isRecruiting = computed(() => sessionStatus.value === 'recruiting')
|
||||
const isPlaying = computed(() => sessionStatus.value === 'playing')
|
||||
|
||||
// 解散/结束组队(与 PC 端一致:session 存在且处于招募/游戏中时,群组成员可操作)
|
||||
const ending = ref(false)
|
||||
async function handleEndSession() {
|
||||
const s = teamStore.currentSession
|
||||
if (!s) return
|
||||
const isRecruit = s.status === 'recruiting'
|
||||
showConfirmDialog({
|
||||
title: isRecruit ? '解散小队' : '结束游戏',
|
||||
message: isRecruit
|
||||
? '确定要解散当前小队吗?'
|
||||
: '确定要结束当前游戏吗?小队将被解散。'
|
||||
}).then(async () => {
|
||||
ending.value = true
|
||||
try {
|
||||
await teamStore.finishGame()
|
||||
showSuccessToast(isRecruit ? '已解散' : '已结束')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
} finally {
|
||||
ending.value = false
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="activity-feed">
|
||||
<!-- 当前组队卡片 -->
|
||||
<div v-if="hasSession && sessionInThisGroup" class="current-team-card">
|
||||
<div class="team-card-body" @click="goVoiceRoom">
|
||||
<div class="team-header">
|
||||
<MobileIcon name="volume" :size="16" color="#fff" />
|
||||
<span class="team-label">{{ isPlaying ? '游戏中' : '招募中' }}</span>
|
||||
</div>
|
||||
<div class="team-game">{{ teamStore.currentSession?.gameName }}</div>
|
||||
<div class="team-meta">
|
||||
{{ teamStore.currentSession?.name }} · {{ teamStore.currentSession?.members?.length || 0 }} 人
|
||||
</div>
|
||||
<div class="team-go">
|
||||
<MobileIcon name="arrowRight" :size="14" color="#fff" />
|
||||
进入语音房
|
||||
</div>
|
||||
</div>
|
||||
<!-- 解散/结束按钮(招募中或游戏中时显示) -->
|
||||
<button
|
||||
v-if="isRecruiting || isPlaying"
|
||||
class="team-end-btn"
|
||||
:loading="ending"
|
||||
@click.stop="handleEndSession"
|
||||
>
|
||||
<MobileIcon name="close" :size="14" color="#fff" />
|
||||
{{ isRecruiting ? '解散' : '结束' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 快速组队按钮 -->
|
||||
<van-button
|
||||
v-else
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
@click="showCreateTeam = true"
|
||||
>
|
||||
<MobileIcon name="plus" :size="16" color="#fff" />
|
||||
<span style="margin-left: 4px;">发起组队</span>
|
||||
</van-button>
|
||||
|
||||
<!-- 成员状态分组 -->
|
||||
<div class="members-section">
|
||||
<div
|
||||
v-for="(list, status) in membersByStatus"
|
||||
:key="status"
|
||||
v-show="list.length > 0"
|
||||
class="status-group"
|
||||
>
|
||||
<div class="status-group-header">
|
||||
<span class="status-dot" :style="{ background: statusColors[status] }" />
|
||||
<span class="status-group-label">{{ statusLabels[status] }}</span>
|
||||
<span class="status-group-count">{{ list.length }}</span>
|
||||
</div>
|
||||
<div class="member-list">
|
||||
<div v-for="m in list" :key="m.id" class="member-item">
|
||||
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
|
||||
<div class="member-info">
|
||||
<div class="member-name">{{ m.name || m.username }}</div>
|
||||
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发起组队弹层 -->
|
||||
<van-popup
|
||||
v-model:show="showCreateTeam"
|
||||
position="bottom"
|
||||
round
|
||||
closeable
|
||||
:style="{ height: '75%' }"
|
||||
>
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">发起组队</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="teamGame" label="游戏" placeholder="玩什么游戏" required />
|
||||
<van-field v-model="teamName" label="队名" placeholder="可选,默认游戏+小队" />
|
||||
</van-cell-group>
|
||||
|
||||
<div class="invite-title">邀请成员(空闲:{{ idleMembers.length }})</div>
|
||||
<div class="invite-list">
|
||||
<div
|
||||
v-for="m in idleMembers.filter(x => x.id !== userStore.userId)"
|
||||
:key="m.id"
|
||||
class="invite-member"
|
||||
:class="{ 'invite-selected': selectedMembers.includes(m.id) }"
|
||||
@click="toggleMember(m.id)"
|
||||
>
|
||||
<img :src="m.avatar || '/default-avatar.svg'" class="invite-avatar" alt="" />
|
||||
<span class="invite-name">{{ m.name || m.username }}</span>
|
||||
<van-icon
|
||||
v-if="selectedMembers.includes(m.id)"
|
||||
name="success"
|
||||
class="invite-check"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="idleMembers.length <= 1" class="no-idle">暂无其他空闲成员</div>
|
||||
</div>
|
||||
|
||||
<div class="popup-actions">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:loading="teamLoading"
|
||||
@click="handleCreateTeam"
|
||||
>
|
||||
开始组队
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.activity-feed {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.current-team-card {
|
||||
background: linear-gradient(135deg, var(--gg-primary), var(--gg-accent));
|
||||
border-radius: var(--gg-radius-lg);
|
||||
padding: 16px;
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.25);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.team-card-body:active {
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.team-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.team-game {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.team-meta {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.team-go {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 解散/结束按钮 */
|
||||
.team-end-btn {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 5px 10px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.team-end-btn:active {
|
||||
background: rgba(239, 68, 68, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.members-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.status-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 2px 8px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-group-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.status-group-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
background: var(--gg-bg-elevated);
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--gg-bg-card);
|
||||
padding: 6px 10px 6px 6px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
box-shadow: var(--gg-shadow);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.member-note {
|
||||
font-size: 11px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
/* 弹层 */
|
||||
.popup-content {
|
||||
padding: 16px 0 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding: 8px 0 16px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.invite-title {
|
||||
padding: 16px 16px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.invite-list {
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invite-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--gg-bg);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
border: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.invite-member.invite-selected {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.invite-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.invite-name {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.invite-check {
|
||||
color: var(--gg-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-idle {
|
||||
font-size: 13px;
|
||||
color: var(--gg-text-muted);
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
padding: 20px 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<!-- src/components-mobile/group/MemberListMobile.vue -->
|
||||
<!-- 群组成员列表:按状态展示 + 群主管理(移除成员、审核开关、入群申请) -->
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { getGroupJoinRequests, updateGroupApproval } from '@/api/groups'
|
||||
import { respondJoinRequest } from '@/api/groups'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import type { JoinRequest, User } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const groupStore = useGroupStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
const members = computed(() => groupStore.currentMembers)
|
||||
const isOwner = computed(() => group.value?.owner === userStore.userId)
|
||||
|
||||
const joinRequests = ref<JoinRequest[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
if (isOwner.value && group.value) {
|
||||
try {
|
||||
joinRequests.value = await getGroupJoinRequests(group.value.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
|
||||
// 按状态分组
|
||||
const membersByStatus = computed(() => {
|
||||
const groups: Record<string, User[]> = { idle: [], in_team: [], working: [], away: [] }
|
||||
for (const m of members.value) {
|
||||
const s = (m.status || 'idle') as keyof typeof groups
|
||||
if (groups[s]) groups[s].push(m)
|
||||
else groups.away.push(m)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const statusLabels: Record<string, string> = { idle: '空闲', in_team: '组队中', working: '工作中', away: '离开' }
|
||||
const statusColors: Record<string, string> = {
|
||||
idle: 'var(--gg-success)', in_team: 'var(--gg-info)',
|
||||
working: 'var(--gg-danger)', away: 'var(--gg-text-muted)'
|
||||
}
|
||||
|
||||
// 移除成员(群主)
|
||||
async function removeMember(userId: string, name: string) {
|
||||
if (!isOwner.value || !group.value) return
|
||||
showConfirmDialog({
|
||||
title: '移除成员',
|
||||
message: `确定要将 ${name} 移出群组吗?`
|
||||
}).then(async () => {
|
||||
try {
|
||||
const newMembers = group.value!.members.filter(id => id !== userId)
|
||||
await pb.collection('groups').update(group.value!.id, { members: newMembers })
|
||||
await groupStore.setCurrentGroup(group.value!.id)
|
||||
showSuccessToast('已移除')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 审核开关
|
||||
async function toggleApproval(val: boolean) {
|
||||
if (!group.value) return
|
||||
try {
|
||||
await updateGroupApproval(group.value.id, val)
|
||||
await groupStore.setCurrentGroup(group.value.id)
|
||||
showSuccessToast(val ? '已开启审核' : '已关闭审核')
|
||||
} catch (e: any) {
|
||||
showFailToast('更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理入群申请
|
||||
const requestLoading = ref<string | null>(null)
|
||||
async function handleRequest(requestId: string, status: 'approved' | 'rejected') {
|
||||
requestLoading.value = requestId
|
||||
try {
|
||||
await respondJoinRequest(requestId, status)
|
||||
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
|
||||
await groupStore.setCurrentGroup(props.groupId)
|
||||
showSuccessToast(status === 'approved' ? '已通过' : '已拒绝')
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
} finally {
|
||||
requestLoading.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="member-list-mobile">
|
||||
<!-- 群主管理区 -->
|
||||
<div v-if="isOwner" class="owner-panel">
|
||||
<!-- 入群申请 -->
|
||||
<div v-if="joinRequests.length > 0" class="requests-block">
|
||||
<div class="block-title">入群申请({{ joinRequests.length }})</div>
|
||||
<div
|
||||
v-for="req in joinRequests"
|
||||
:key="req.id"
|
||||
class="request-item"
|
||||
>
|
||||
<img
|
||||
:src="req.expand?.user?.avatar || '/default-avatar.svg'"
|
||||
class="req-avatar"
|
||||
alt=""
|
||||
/>
|
||||
<div class="req-info">
|
||||
<div class="req-name">{{ req.expand?.user?.name || req.expand?.user?.username }}</div>
|
||||
</div>
|
||||
<div class="req-actions">
|
||||
<van-button
|
||||
type="primary"
|
||||
size="mini"
|
||||
round
|
||||
:loading="requestLoading === req.id"
|
||||
@click="handleRequest(req.id, 'approved')"
|
||||
>通过</van-button>
|
||||
<van-button
|
||||
size="mini"
|
||||
round
|
||||
:loading="requestLoading === req.id"
|
||||
@click="handleRequest(req.id, 'rejected')"
|
||||
>拒绝</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 审核开关 -->
|
||||
<van-cell-group inset>
|
||||
<van-cell title="加入审核" center>
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
:model-value="group?.requireApproval"
|
||||
size="22px"
|
||||
@update:model-value="toggleApproval"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
||||
<!-- 成员分组列表 -->
|
||||
<div
|
||||
v-for="(list, status) in membersByStatus"
|
||||
:key="status"
|
||||
v-show="list.length > 0"
|
||||
class="status-section"
|
||||
>
|
||||
<div class="status-header">
|
||||
<span class="status-dot" :style="{ background: statusColors[status] }" />
|
||||
<span class="status-label">{{ statusLabels[status] }}</span>
|
||||
<span class="status-count">{{ list.length }}</span>
|
||||
</div>
|
||||
<div class="members-grid">
|
||||
<div
|
||||
v-for="m in list"
|
||||
:key="m.id"
|
||||
class="member-card"
|
||||
>
|
||||
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
|
||||
<div class="member-info">
|
||||
<div class="member-name">
|
||||
{{ m.name || m.username }}
|
||||
<van-tag v-if="m.id === group?.owner" type="warning" size="medium">群主</van-tag>
|
||||
</div>
|
||||
<div v-if="m.statusNote" class="member-note">{{ m.statusNote }}</div>
|
||||
</div>
|
||||
<van-button
|
||||
v-if="isOwner && m.id !== group?.owner && m.id !== userStore.userId"
|
||||
size="mini"
|
||||
plain
|
||||
type="danger"
|
||||
@click="removeMember(m.id, m.name || m.username)"
|
||||
>移除</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-list-mobile {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.owner-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text-muted);
|
||||
margin-bottom: 8px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.request-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--gg-bg-card);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
margin-bottom: 8px;
|
||||
box-shadow: var(--gg-shadow);
|
||||
}
|
||||
|
||||
.req-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.req-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.req-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.req-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.status-count {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.members-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: var(--gg-bg-card);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--gg-radius-sm);
|
||||
box-shadow: var(--gg-shadow);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.member-note {
|
||||
font-size: 12px;
|
||||
color: var(--gg-text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user