Compare commits
37 Commits
625d0baf7d
...
uat
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
+14
-1
@@ -35,4 +35,17 @@ backend/pb_migrations.bak/
|
|||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
.cache/.playwright-mcp/
|
.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/
|
||||||
|
|||||||
@@ -4,22 +4,40 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
## 项目概述
|
## 项目概述
|
||||||
|
|
||||||
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库。
|
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库、投票、积分竞猜、账本和资产。
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 构建前端
|
# 构建前端
|
||||||
|
# 注意:npm run build 默认加载 .env.dev 的变量。Docker 构建时通过 ARG 注入空 VITE_PB_URL,由 nginx 代理处理
|
||||||
|
# 如需本地测试 UAT 构建设置,使用:cd frontend && npm run build -- --mode uat
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
|
||||||
# 本地开发(一般不用,用 Docker 部署代替)
|
# 本地 vite dev server(一般不用,用 Docker 部署代替)
|
||||||
cd frontend && npm run dev
|
cd frontend && npm run dev
|
||||||
|
|
||||||
# 部署脚本(根目录)
|
# 部署脚本(根目录)
|
||||||
./deploy-backend.sh # 部署 PocketBase 后端
|
./deploy-dev.sh # 构建 + 部署 Dev 全套 (PB + LiveKit + 前端, 端口 7033/8090)
|
||||||
./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033)
|
./deploy-uat.sh # 构建 + 部署 UAT 全套 (PB + LiveKit + 前端, 端口 7034/8712)
|
||||||
./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
|
|
||||||
./stop-all.sh # 停止所有服务
|
./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 前必须等用户确认。
|
**重要**: 不要在本地启动 vite dev server,使用 Docker 部署后通过端口访问测试。Dev 环境在 `http://192.168.1.14:7033`。部署到 UAT 前必须等用户确认。
|
||||||
@@ -31,6 +49,8 @@ cd frontend && npm run dev
|
|||||||
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),localStorage 持久化认证
|
- **API 通信**: PocketBase JS SDK (`pocketbase` npm 包),localStorage 持久化认证
|
||||||
- **实时通信**: PocketBase realtime subscriptions
|
- **实时通信**: PocketBase realtime subscriptions
|
||||||
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
|
- **样式**: 自定义 CSS 变量 (`--gg-*` 前缀, `design.css`) + Tailwind + Element Plus,绿色主题
|
||||||
|
- **语音通话**: LiveKit server v1.10 + `livekit-client` + 独立 Express token 服务
|
||||||
|
- **桌面端**: Electron 35 + `electron-store` + `electron-builder`
|
||||||
|
|
||||||
## 环境与端口
|
## 环境与端口
|
||||||
|
|
||||||
@@ -38,8 +58,12 @@ cd frontend && npm run dev
|
|||||||
|------|-----|-----|
|
|------|-----|-----|
|
||||||
| 前端 (nginx) | 7033 | 7034 |
|
| 前端 (nginx) | 7033 | 7034 |
|
||||||
| PocketBase | 8090 | 8712 |
|
| PocketBase | 8090 | 8712 |
|
||||||
|
| LiveKit | 7880/7881/7882(udp) | 7890/7891/7892(udp) |
|
||||||
|
| Voice Token | 7883 | 7893 |
|
||||||
|
|
||||||
Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`、`docker-compose.uat.yml`,共享 `gamegroup-net` 网络。
|
Docker Compose 文件:`docker-compose.dev.yml`(Dev 全套)、`docker-compose.uat.yml`(UAT 全套),共享 `gamegroup-net` 网络。每个环境独立运行完整的 PB + LiveKit + Voice Token + 前端服务。
|
||||||
|
|
||||||
|
前端 `.env` 文件:`frontend/.env.dev`(VITE_PB_URL=8711, PORT=7033)和 `frontend/.env.uat`(VITE_PB_URL=8711, PORT=7034)。注意 Dev/UAT 构建时 VITE_PB_URL 都指向 8711,因为 Docker 构建时会将其覆盖为空,由 nginx 反向代理处理。
|
||||||
|
|
||||||
## 架构
|
## 架构
|
||||||
|
|
||||||
@@ -48,16 +72,17 @@ Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`
|
|||||||
```
|
```
|
||||||
pocketbase.ts (PB 客户端初始化)
|
pocketbase.ts (PB 客户端初始化)
|
||||||
→ router guards (isAuthenticated 检查)
|
→ router guards (isAuthenticated 检查)
|
||||||
→ stores (user/group/team/notification)
|
→ stores (user/group/team/notification/poll/ledger/asset/memory)
|
||||||
→ api/ (PocketBase CRUD 封装)
|
→ api/ (PocketBase CRUD 封装,每个领域一个文件)
|
||||||
→ components + views
|
→ components + views
|
||||||
```
|
```
|
||||||
|
|
||||||
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()`
|
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()`
|
||||||
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`),封装 CRUD 和过滤逻辑
|
- **`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`)
|
||||||
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`)
|
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`)
|
||||||
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
|
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
|
||||||
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数
|
- **`composables/useVoiceRoom.ts`** — LiveKit 语音房间封装,处理连接/断开/麦克风/扬声器控制
|
||||||
|
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap`、`TeamStatusMap`)
|
||||||
|
|
||||||
### 认证流程
|
### 认证流程
|
||||||
|
|
||||||
@@ -65,19 +90,57 @@ pocketbase.ts (PB 客户端初始化)
|
|||||||
- 登录:支持昵称/邮箱/username 登录。输入不含 `@` 时查询 `users` collection 的 `name`/`username` 字段,获取 `username` 后调用 `authWithPassword(username, password)`
|
- 登录:支持昵称/邮箱/username 登录。输入不含 `@` 时查询 `users` collection 的 `name`/`username` 字段,获取 `username` 后调用 `authWithPassword(username, password)`
|
||||||
- 路由守卫:`requiresAuth` 跳转登录页,`requiresGuest` 跳转首页
|
- 路由守卫:`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 为独立路由。
|
||||||
|
|
||||||
### Vite 代理 vs Nginx
|
### Vite 代理 vs Nginx
|
||||||
|
|
||||||
开发环境 Vite 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。
|
开发环境 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)
|
### 数据模型(PocketBase Collections)
|
||||||
|
|
||||||
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
|
- **users** — 认证集合。`username` 是系统字段(不可改,仅 `[a-z0-9_-]`),中文昵称存 `name` 字段。状态:idle/working/in_team/away
|
||||||
- **groups** — owner + members 关系,支持审核加入(requireApproval)
|
- **groups** — owner + members 关系,支持审核加入(requireApproval)
|
||||||
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved
|
- **team_sessions** — 临时组队,状态流转:recruiting → playing → finished/dissolved。含 `voiceRoom` 和 `voiceActive` 字段
|
||||||
- **invitations** — 组队邀请,pending/accepted/rejected
|
- **invitations** — 组队邀请,pending/accepted/rejected
|
||||||
- **games** — 游戏库,归属 group,含平台、标签、封面
|
- **games** — 游戏库,归属 group,含平台、标签、封面
|
||||||
- **game_comments** / **game_favorites** — 评论和收藏
|
- **game_comments** / **game_favorites** — 评论和收藏
|
||||||
- **join_requests** — 入群申请
|
- **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 注意事项
|
### PocketBase 注意事项
|
||||||
|
|
||||||
@@ -86,3 +149,4 @@ pocketbase.ts (PB 客户端初始化)
|
|||||||
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
|
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
|
||||||
- PocketBase 管理面板:`admin@example.com` / `admin123456`
|
- PocketBase 管理面板:`admin@example.com` / `admin123456`
|
||||||
- 前端 `.env` 文件:`VITE_PB_URL` 配置后端地址,`VITE_PORT` 配置开发端口
|
- 前端 `.env` 文件:`VITE_PB_URL` 配置后端地址,`VITE_PORT` 配置开发端口
|
||||||
|
- API 调用添加 `$autoCancel: false` 避免 PocketBase SDK 自动取消请求
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
|
||||||
migrate((db) => {
|
|
||||||
const collection = new Collection({
|
|
||||||
"id": "notifications_collection",
|
|
||||||
"created": "2026-04-18 10:00:05.000Z",
|
|
||||||
"updated": "2026-04-18 10:00:05.000Z",
|
|
||||||
"name": "notifications",
|
|
||||||
"type": "base",
|
|
||||||
"system": false,
|
|
||||||
"schema": [
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_user",
|
|
||||||
"name": "user",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"cascadeDelete": true,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_type",
|
|
||||||
"name": "type",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": ["poll_new", "poll_deadline", "poll_result", "team_invite", "team_starting", "join_request", "member_joined"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_title",
|
|
||||||
"name": "title",
|
|
||||||
"type": "text",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": 200,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_content",
|
|
||||||
"name": "content",
|
|
||||||
"type": "text",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_read",
|
|
||||||
"name": "read",
|
|
||||||
"type": "bool",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_rid",
|
|
||||||
"name": "relatedId",
|
|
||||||
"type": "text",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": null,
|
|
||||||
"max": null,
|
|
||||||
"pattern": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_rtype",
|
|
||||||
"name": "relatedType",
|
|
||||||
"type": "select",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": ["poll", "team", "group"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"indexes": [],
|
|
||||||
"listRule": "user = @request.auth.id",
|
|
||||||
"viewRule": "user = @request.auth.id",
|
|
||||||
"createRule": "@request.auth.id != \"\"",
|
|
||||||
"updateRule": "user = @request.auth.id",
|
|
||||||
"deleteRule": "user = @request.auth.id",
|
|
||||||
"options": {}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Dao(db).saveCollection(collection);
|
|
||||||
}, (db) => {
|
|
||||||
const dao = new Dao(db);
|
|
||||||
const collection = dao.findCollectionByNameOrId("notifications_collection");
|
|
||||||
|
|
||||||
return dao.deleteCollection(collection);
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,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);
|
||||||
|
})
|
||||||
+57
-47
@@ -1,16 +1,16 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
migrate((db) => {
|
migrate((db) => {
|
||||||
const collection = new Collection({
|
const collection = new Collection({
|
||||||
"id": "memories_collection",
|
"id": "assets_col",
|
||||||
"created": "2026-04-18 10:00:04.000Z",
|
"created": "2026-04-18 10:00:02.000Z",
|
||||||
"updated": "2026-04-18 10:00:04.000Z",
|
"updated": "2026-04-18 10:00:02.000Z",
|
||||||
"name": "memories",
|
"name": "assets",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
"schema": [
|
"schema": [
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_group",
|
"id": "ast_group",
|
||||||
"name": "group",
|
"name": "group",
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -26,8 +26,8 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_uploader",
|
"id": "ast_creator",
|
||||||
"name": "uploader",
|
"name": "creator",
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
@@ -42,21 +42,40 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_title",
|
"id": "ast_name",
|
||||||
"name": "title",
|
"name": "name",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"min": null,
|
"min": null,
|
||||||
"max": 200,
|
"max": 100,
|
||||||
"pattern": ""
|
"pattern": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_desc",
|
"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",
|
"name": "description",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"required": false,
|
"required": false,
|
||||||
@@ -64,49 +83,40 @@ migrate((db) => {
|
|||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"min": null,
|
"min": null,
|
||||||
"max": null,
|
"max": 500,
|
||||||
"pattern": ""
|
"pattern": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_file",
|
"id": "ast_holder",
|
||||||
"name": "file",
|
"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",
|
"type": "file",
|
||||||
"required": true,
|
"required": false,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
|
"mimeTypes": ["image/*"],
|
||||||
|
"thumbs": ["200x200"],
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
"maxSize": 524288000,
|
"maxSize": 5242880,
|
||||||
"mimeTypes": ["image/*", "video/*", "audio/*", "application/pdf", "application/msword", "application/vnd.*", "text/*", "application/zip", "application/x-rar-compressed"]
|
"protected": false
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_ft",
|
|
||||||
"name": "fileType",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": ["image", "video", "audio", "document", "other"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_size",
|
|
||||||
"name": "size",
|
|
||||||
"type": "number",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": 0,
|
|
||||||
"max": null,
|
|
||||||
"noDecimal": false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -114,15 +124,15 @@ migrate((db) => {
|
|||||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||||
"viewRule": "@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",
|
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||||
"updateRule": "uploader = @request.auth.id",
|
"updateRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||||
"deleteRule": "uploader = @request.auth.id || group.owner = @request.auth.id",
|
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
|
||||||
"options": {}
|
"options": {}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Dao(db).saveCollection(collection);
|
return Dao(db).saveCollection(collection);
|
||||||
}, (db) => {
|
}, (db) => {
|
||||||
const dao = new Dao(db);
|
const dao = new Dao(db);
|
||||||
const collection = dao.findCollectionByNameOrId("memories_collection");
|
const collection = dao.findCollectionByNameOrId("assets_col");
|
||||||
|
|
||||||
return dao.deleteCollection(collection);
|
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);
|
||||||
|
})
|
||||||
+17
-24
@@ -1,23 +1,21 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
migrate((db) => {
|
migrate((db) => {
|
||||||
const collection = new Collection({
|
const collection = new Collection({
|
||||||
"id": "poll_options_collection",
|
"id": "betopts_col",
|
||||||
"created": "2026-04-18 00:00:02.000Z",
|
"created": "2026-04-18 21:00:03.000Z",
|
||||||
"updated": "2026-04-18 00:00:02.000Z",
|
"updated": "2026-04-18 21:00:03.000Z",
|
||||||
"name": "poll_options",
|
"name": "bet_options",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
"schema": [
|
"schema": [
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_poll",
|
"id": "bo_bet",
|
||||||
"name": "poll",
|
"name": "bet",
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
"options": {
|
||||||
"collectionId": "polls_collection",
|
"collectionId": "bets_col",
|
||||||
"cascadeDelete": true,
|
"cascadeDelete": true,
|
||||||
"minSelect": null,
|
"minSelect": null,
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
@@ -26,46 +24,41 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_content",
|
"id": "bo_content",
|
||||||
"name": "content",
|
"name": "content",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
"options": {
|
||||||
"min": null,
|
"min": 1,
|
||||||
"max": 200,
|
"max": 200,
|
||||||
"pattern": ""
|
"pattern": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_order",
|
"id": "bo_order",
|
||||||
"name": "order",
|
"name": "order",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
"options": {
|
||||||
"min": 0,
|
"min": 1,
|
||||||
"max": null,
|
"max": null,
|
||||||
"noDecimal": true
|
"noDecimal": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": [],
|
"indexes": [],
|
||||||
"listRule": "@request.auth.id != \"\"",
|
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||||
"viewRule": "@request.auth.id != \"\"",
|
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||||
"createRule": "@request.auth.id != \"\"",
|
"createRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
|
||||||
"updateRule": "poll.creator = @request.auth.id",
|
"updateRule": "bet.creator = @request.auth.id",
|
||||||
"deleteRule": "poll.creator = @request.auth.id",
|
"deleteRule": "bet.creator = @request.auth.id",
|
||||||
"options": {}
|
"options": {}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Dao(db).saveCollection(collection);
|
return Dao(db).saveCollection(collection);
|
||||||
}, (db) => {
|
}, (db) => {
|
||||||
const dao = new Dao(db);
|
const dao = new Dao(db);
|
||||||
const collection = dao.findCollectionByNameOrId("poll_options_collection");
|
const collection = dao.findCollectionByNameOrId("betopts_col");
|
||||||
|
|
||||||
return dao.deleteCollection(collection);
|
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)
|
||||||
|
})
|
||||||
+72
-65
@@ -1,16 +1,16 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
migrate((db) => {
|
migrate((db) => {
|
||||||
const collection = new Collection({
|
const collection = new Collection({
|
||||||
"id": "polls_collection",
|
"id": "sf_events_001",
|
||||||
"created": "2026-04-18 00:00:01.000Z",
|
"created": "2026-04-21 00:00:00.000Z",
|
||||||
"updated": "2026-04-18 00:00:01.000Z",
|
"updated": "2026-04-21 00:00:00.000Z",
|
||||||
"name": "polls",
|
"name": "events",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
"schema": [
|
"schema": [
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_group",
|
"id": "sf_e_group",
|
||||||
"name": "group",
|
"name": "group",
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -18,22 +18,6 @@ migrate((db) => {
|
|||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"collectionId": "es63bkyiblpnxdf",
|
"collectionId": "es63bkyiblpnxdf",
|
||||||
"cascadeDelete": false,
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_creator",
|
|
||||||
"name": "creator",
|
|
||||||
"type": "relation",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"collectionId": "_pb_users_auth_",
|
|
||||||
"cascadeDelete": true,
|
"cascadeDelete": true,
|
||||||
"minSelect": null,
|
"minSelect": null,
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
@@ -42,7 +26,23 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_title",
|
"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",
|
"name": "title",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -56,32 +56,49 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_type",
|
"id": "sf_e_desc",
|
||||||
"name": "type",
|
"name": "description",
|
||||||
"type": "select",
|
"type": "text",
|
||||||
"required": true,
|
"required": false,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"minSelect": null,
|
"min": null,
|
||||||
"maxSelect": 1,
|
"max": 2000,
|
||||||
"values": ["option", "rollcall"]
|
"pattern": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_anonymous",
|
"id": "sf_e_location",
|
||||||
"name": "anonymous",
|
"name": "location",
|
||||||
"type": "bool",
|
"type": "text",
|
||||||
"required": false,
|
"required": false,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {}
|
"options": {
|
||||||
|
"min": null,
|
||||||
|
"max": 200,
|
||||||
|
"pattern": ""
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_deadline",
|
"id": "sf_e_start",
|
||||||
"name": "deadline",
|
"name": "startTime",
|
||||||
|
"type": "date",
|
||||||
|
"required": true,
|
||||||
|
"presentable": false,
|
||||||
|
"unique": false,
|
||||||
|
"options": {
|
||||||
|
"min": "",
|
||||||
|
"max": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"system": false,
|
||||||
|
"id": "sf_e_end",
|
||||||
|
"name": "endTime",
|
||||||
"type": "date",
|
"type": "date",
|
||||||
"required": false,
|
"required": false,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
@@ -93,7 +110,25 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_maxp",
|
"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",
|
"name": "maxParticipants",
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"required": false,
|
"required": false,
|
||||||
@@ -104,33 +139,6 @@ migrate((db) => {
|
|||||||
"max": null,
|
"max": null,
|
||||||
"noDecimal": true
|
"noDecimal": true
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_status",
|
|
||||||
"name": "status",
|
|
||||||
"type": "select",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"minSelect": null,
|
|
||||||
"maxSelect": 1,
|
|
||||||
"values": ["active", "settled"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_settled",
|
|
||||||
"name": "settledAt",
|
|
||||||
"type": "date",
|
|
||||||
"required": false,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": "",
|
|
||||||
"max": ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": [],
|
"indexes": [],
|
||||||
@@ -145,7 +153,6 @@ migrate((db) => {
|
|||||||
return Dao(db).saveCollection(collection);
|
return Dao(db).saveCollection(collection);
|
||||||
}, (db) => {
|
}, (db) => {
|
||||||
const dao = new Dao(db);
|
const dao = new Dao(db);
|
||||||
const collection = dao.findCollectionByNameOrId("polls_collection");
|
const collection = dao.findCollectionByNameOrId("sf_events_001");
|
||||||
|
|
||||||
return dao.deleteCollection(collection);
|
return dao.deleteCollection(collection);
|
||||||
})
|
})
|
||||||
+22
-25
@@ -1,23 +1,23 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
migrate((db) => {
|
migrate((db) => {
|
||||||
const collection = new Collection({
|
const collection = new Collection({
|
||||||
"id": "poll_votes_collection",
|
"id": "sf_ec_001",
|
||||||
"created": "2026-04-18 00:00:03.000Z",
|
"created": "2026-04-21 00:00:00.000Z",
|
||||||
"updated": "2026-04-18 00:00:03.000Z",
|
"updated": "2026-04-21 00:00:00.000Z",
|
||||||
"name": "poll_votes",
|
"name": "event_comments",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
"schema": [
|
"schema": [
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_poll",
|
"id": "sf_ec_event",
|
||||||
"name": "poll",
|
"name": "event",
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"collectionId": "polls_collection",
|
"collectionId": "sf_events_001",
|
||||||
"cascadeDelete": true,
|
"cascadeDelete": true,
|
||||||
"minSelect": null,
|
"minSelect": null,
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
@@ -26,14 +26,14 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_option",
|
"id": "sf_ec_user",
|
||||||
"name": "option",
|
"name": "user",
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"collectionId": "poll_options_collection",
|
"collectionId": "_pb_users_auth_",
|
||||||
"cascadeDelete": false,
|
"cascadeDelete": false,
|
||||||
"minSelect": null,
|
"minSelect": null,
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
@@ -42,34 +42,31 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_user",
|
"id": "sf_ec_content",
|
||||||
"name": "user",
|
"name": "content",
|
||||||
"type": "relation",
|
"type": "text",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"collectionId": "_pb_users_auth_",
|
"min": 1,
|
||||||
"cascadeDelete": true,
|
"max": 500,
|
||||||
"minSelect": null,
|
"pattern": ""
|
||||||
"maxSelect": 1,
|
|
||||||
"displayFields": null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": ["CREATE UNIQUE INDEX idx_poll_user ON poll_votes (poll, user)"],
|
"indexes": [],
|
||||||
"listRule": "@request.auth.id != \"\"",
|
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
"viewRule": "@request.auth.id != \"\"",
|
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id && poll.group.members ~ @request.auth.id",
|
"createRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
"updateRule": "user = @request.auth.id",
|
"updateRule": "user = @request.auth.id",
|
||||||
"deleteRule": "user = @request.auth.id",
|
"deleteRule": "user = @request.auth.id || event.group.owner = @request.auth.id",
|
||||||
"options": {}
|
"options": {}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Dao(db).saveCollection(collection);
|
return Dao(db).saveCollection(collection);
|
||||||
}, (db) => {
|
}, (db) => {
|
||||||
const dao = new Dao(db);
|
const dao = new Dao(db);
|
||||||
const collection = dao.findCollectionByNameOrId("poll_votes_collection");
|
const collection = dao.findCollectionByNameOrId("sf_ec_001");
|
||||||
|
|
||||||
return dao.deleteCollection(collection);
|
return dao.deleteCollection(collection);
|
||||||
})
|
})
|
||||||
+43
-36
@@ -1,23 +1,23 @@
|
|||||||
/// <reference path="../pb_data/types.d.ts" />
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
migrate((db) => {
|
migrate((db) => {
|
||||||
const collection = new Collection({
|
const collection = new Collection({
|
||||||
"id": "point_logs_collection",
|
"id": "sf_er_001",
|
||||||
"created": "2026-04-18 10:00:06.000Z",
|
"created": "2026-04-21 00:00:00.000Z",
|
||||||
"updated": "2026-04-18 10:00:06.000Z",
|
"updated": "2026-04-21 00:00:00.000Z",
|
||||||
"name": "point_logs",
|
"name": "event_rsvps",
|
||||||
"type": "base",
|
"type": "base",
|
||||||
"system": false,
|
"system": false,
|
||||||
"schema": [
|
"schema": [
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_user",
|
"id": "sf_er_event",
|
||||||
"name": "user",
|
"name": "event",
|
||||||
"type": "relation",
|
"type": "relation",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"collectionId": "_pb_users_auth_",
|
"collectionId": "sf_events_001",
|
||||||
"cascadeDelete": true,
|
"cascadeDelete": true,
|
||||||
"minSelect": null,
|
"minSelect": null,
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
@@ -26,59 +26,66 @@ migrate((db) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_action",
|
"id": "sf_er_user",
|
||||||
"name": "action",
|
"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",
|
"type": "select",
|
||||||
"required": true,
|
"required": true,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"maxSelect": 1,
|
"maxSelect": 1,
|
||||||
"values": ["vote", "team", "memory"]
|
"values": [
|
||||||
|
"going",
|
||||||
|
"interested",
|
||||||
|
"maybe"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"system": false,
|
"system": false,
|
||||||
"id": "sf_points",
|
"id": "sf_er_comment",
|
||||||
"name": "points",
|
"name": "comment",
|
||||||
"type": "number",
|
|
||||||
"required": true,
|
|
||||||
"presentable": false,
|
|
||||||
"unique": false,
|
|
||||||
"options": {
|
|
||||||
"min": 1,
|
|
||||||
"max": null,
|
|
||||||
"noDecimal": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"system": false,
|
|
||||||
"id": "sf_rid",
|
|
||||||
"name": "relatedId",
|
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"required": true,
|
"required": false,
|
||||||
"presentable": false,
|
"presentable": false,
|
||||||
"unique": false,
|
"unique": false,
|
||||||
"options": {
|
"options": {
|
||||||
"min": null,
|
"min": null,
|
||||||
"max": null,
|
"max": 200,
|
||||||
"pattern": ""
|
"pattern": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"indexes": [],
|
"indexes": [
|
||||||
"listRule": "user = @request.auth.id",
|
"CREATE UNIQUE INDEX idx_event_user ON event_rsvps (event, user)"
|
||||||
"viewRule": "user = @request.auth.id",
|
],
|
||||||
"createRule": "@request.auth.id != \"\" && user = @request.auth.id",
|
"listRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
"updateRule": null,
|
"viewRule": "@request.auth.id != \"\" && event.group.members ~ @request.auth.id",
|
||||||
"deleteRule": null,
|
"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": {}
|
"options": {}
|
||||||
});
|
});
|
||||||
|
|
||||||
return Dao(db).saveCollection(collection);
|
return Dao(db).saveCollection(collection);
|
||||||
}, (db) => {
|
}, (db) => {
|
||||||
const dao = new Dao(db);
|
const dao = new Dao(db);
|
||||||
const collection = dao.findCollectionByNameOrId("point_logs_collection");
|
const collection = dao.findCollectionByNameOrId("sf_er_001");
|
||||||
|
|
||||||
return dao.deleteCollection(collection);
|
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
|
#!/bin/bash
|
||||||
# 部署 Dev 前端
|
# 部署 Dev 全套环境(PocketBase + LiveKit + voice-token + 前端)
|
||||||
|
|
||||||
echo "🚀 部署 Dev 前端..."
|
echo "🚀 部署 Dev 环境..."
|
||||||
|
|
||||||
# 确保网络存在
|
# 确保网络存在
|
||||||
docker network create gamegroup-net 2>/dev/null || true
|
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
|
docker compose -f docker-compose.dev.yml up -d --build --force-recreate
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "✅ Dev 环境已启动"
|
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
|
#!/bin/bash
|
||||||
# 部署 UAT 前端
|
# 部署 UAT 全套环境(PocketBase + LiveKit + voice-token + 前端)
|
||||||
|
|
||||||
echo "🚀 部署 UAT 前端..."
|
echo "🚀 部署 UAT 环境..."
|
||||||
|
|
||||||
# 确保网络存在
|
# 确保网络存在
|
||||||
docker network create gamegroup-net 2>/dev/null || true
|
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
|
docker compose -f docker-compose.uat.yml up -d --build --force-recreate
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "✅ UAT 环境已启动"
|
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:
|
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:
|
frontend-dev:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
@@ -6,9 +57,13 @@ services:
|
|||||||
container_name: gamegroup-frontend-dev
|
container_name: gamegroup-frontend-dev
|
||||||
ports:
|
ports:
|
||||||
- "7033:80"
|
- "7033:80"
|
||||||
|
volumes:
|
||||||
|
- ./electron-update/dev:/usr/share/nginx/html/electron-update
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- pocketbase-dev
|
||||||
networks:
|
networks:
|
||||||
- gamegroup-net
|
- gamegroup-net
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,36 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- gamegroup-net
|
- 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:
|
frontend-uat:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
@@ -29,6 +59,8 @@ services:
|
|||||||
container_name: gamegroup-frontend-uat
|
container_name: gamegroup-frontend-uat
|
||||||
ports:
|
ports:
|
||||||
- "7034:80"
|
- "7034:80"
|
||||||
|
volumes:
|
||||||
|
- ./electron-update/uat:/usr/share/nginx/html/electron-update
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
|
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
|
# Dev Environment
|
||||||
VITE_PB_URL=http://192.168.1.14:8711
|
VITE_PB_URL=http://192.168.1.14:8711
|
||||||
VITE_PORT=7033
|
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
|
# UAT Environment
|
||||||
VITE_PB_URL=http://192.168.1.14:8711
|
VITE_PB_URL=http://192.168.1.14:8711
|
||||||
VITE_PORT=7034
|
VITE_PORT=7034
|
||||||
|
VITE_LIVEKIT_URL=ws://192.168.1.14:7890
|
||||||
|
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7893
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ server {
|
|||||||
gzip off;
|
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
|
# API 代理到局域网 PocketBase
|
||||||
location /api/ {
|
location /api/ {
|
||||||
client_max_body_size 500m;
|
client_max_body_size 500m;
|
||||||
|
|||||||
+19
-2
@@ -30,6 +30,23 @@ server {
|
|||||||
gzip off;
|
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
|
# API 代理到局域网 PocketBase
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://192.168.1.14:8712;
|
proxy_pass http://192.168.1.14:8712;
|
||||||
@@ -43,8 +60,8 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 静态资源缓存
|
# 静态资源缓存(排除 /api/ 路径,避免拦截 PB 文件请求)
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/(?!api/|voice-api/).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-10
@@ -10,20 +10,21 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@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",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@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",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 pb from './pocketbase'
|
||||||
import type { Game, GamePlatform, GameComment } from '@/types'
|
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?: {
|
export async function getGroupGames(groupId: string, options?: {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -11,11 +17,12 @@ export async function getGroupGames(groupId: string, options?: {
|
|||||||
const { page = 1, limit = 50, platform, search } = options || {}
|
const { page = 1, limit = 50, platform, search } = options || {}
|
||||||
let filter = `group="${groupId}"`
|
let filter = `group="${groupId}"`
|
||||||
if (platform) filter += ` && platform="${platform}"`
|
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, {
|
const result = await pb.collection('games').getList(page, limit, {
|
||||||
filter,
|
filter,
|
||||||
sort: '-created'
|
sort: '-created',
|
||||||
|
$autoCancel: false
|
||||||
})
|
})
|
||||||
return { items: result.items as unknown as Game[], total: result.totalItems }
|
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: {
|
export async function addGame(groupId: string, data: {
|
||||||
name: string
|
name: string
|
||||||
|
aliases?: string[]
|
||||||
platform?: GamePlatform
|
platform?: GamePlatform
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
cover?: string
|
coverFile?: File
|
||||||
}) {
|
}) {
|
||||||
const user = pb.authStore.model
|
const user = pb.authStore.model
|
||||||
return pb.collection('games').create({
|
const formData = new FormData()
|
||||||
...data,
|
formData.append('name', data.name)
|
||||||
group: groupId,
|
formData.append('group', groupId)
|
||||||
addedBy: user?.id,
|
formData.append('addedBy', user?.id || '')
|
||||||
popularCount: 0
|
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) {
|
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<{
|
export async function importGames(groupId: string, games: Array<{
|
||||||
name: string
|
name: string
|
||||||
platform?: GamePlatform
|
platform?: GamePlatform
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
cover?: string
|
|
||||||
}>) {
|
}>) {
|
||||||
const results = []
|
const results = []
|
||||||
for (const game of games) {
|
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[]> {
|
export async function exportGames(groupId: string): Promise<Game[]> {
|
||||||
const result = await pb.collection('games').getFullList({
|
const result = await pb.collection('games').getFullList({
|
||||||
filter: `group="${groupId}"`,
|
filter: `group="${groupId}"`,
|
||||||
sort: '-created'
|
sort: '-created',
|
||||||
|
$autoCancel: false
|
||||||
})
|
})
|
||||||
return result as unknown as Game[]
|
return result as unknown as Game[]
|
||||||
}
|
}
|
||||||
@@ -75,17 +114,18 @@ export async function toggleFavorite(gameId: string): Promise<boolean> {
|
|||||||
if (!user) throw new Error('未登录')
|
if (!user) throw new Error('未登录')
|
||||||
|
|
||||||
const existing = await pb.collection('game_favorites').getList(1, 1, {
|
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) {
|
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
|
return false
|
||||||
} else {
|
} else {
|
||||||
await pb.collection('game_favorites').create({
|
await pb.collection('game_favorites').create({
|
||||||
game: gameId,
|
game: gameId,
|
||||||
user: user.id
|
user: user.id
|
||||||
})
|
}, { $autoCancel: false })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +136,8 @@ export async function isFavorite(gameId: string): Promise<boolean> {
|
|||||||
if (!user) return false
|
if (!user) return false
|
||||||
|
|
||||||
const result = await pb.collection('game_favorites').getList(1, 1, {
|
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
|
return result.items.length > 0
|
||||||
}
|
}
|
||||||
@@ -111,7 +152,7 @@ export async function addComment(gameId: string, content: string, rating?: numbe
|
|||||||
author: user.id,
|
author: user.id,
|
||||||
content,
|
content,
|
||||||
rating
|
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, {
|
const result = await pb.collection('game_comments').getList(1, 50, {
|
||||||
filter: `game="${gameId}"`,
|
filter: `game="${gameId}"`,
|
||||||
sort: '-created',
|
sort: '-created',
|
||||||
expand: 'author'
|
expand: 'author',
|
||||||
|
$autoCancel: false
|
||||||
})
|
})
|
||||||
return result.items as unknown as GameComment[]
|
return result.items as unknown as GameComment[]
|
||||||
}
|
}
|
||||||
@@ -132,12 +174,13 @@ export function getAllPlatforms(): GamePlatform[] {
|
|||||||
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
|
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
|
||||||
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
|
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
|
||||||
if (!query.trim()) return []
|
if (!query.trim()) return []
|
||||||
let filter = `name ~ "${query}"`
|
let filter = `(name ~ "${query}" || aliases ~ "${query}")`
|
||||||
if (groupId) filter += ` && group="${groupId}"`
|
if (groupId) filter += ` && group="${groupId}"`
|
||||||
|
|
||||||
const result = await pb.collection('games').getList(1, limit, {
|
const result = await pb.collection('games').getList(1, limit, {
|
||||||
filter,
|
filter,
|
||||||
sort: '-popularCount'
|
sort: '-popularCount',
|
||||||
|
$autoCancel: false
|
||||||
})
|
})
|
||||||
return result.items as unknown as Game[]
|
return result.items as unknown as Game[]
|
||||||
}
|
}
|
||||||
@@ -147,9 +190,9 @@ export async function getPopularGames(limit = 10): Promise<Game[]> {
|
|||||||
const user = pb.authStore.model
|
const user = pb.authStore.model
|
||||||
if (!user) return []
|
if (!user) return []
|
||||||
|
|
||||||
// 获取用户所在群组的游戏
|
|
||||||
const result = await pb.collection('games').getList(1, limit, {
|
const result = await pb.collection('games').getList(1, limit, {
|
||||||
sort: '-popularCount'
|
sort: '-popularCount',
|
||||||
|
$autoCancel: false
|
||||||
})
|
})
|
||||||
return result.items as unknown as Game[]
|
return result.items as unknown as Game[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,20 @@ export async function getMyGroupsJoinRequests(): Promise<JoinRequest[]> {
|
|||||||
return result.items as unknown as JoinRequest[]
|
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(
|
export async function respondJoinRequest(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@@ -189,6 +203,78 @@ 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) {
|
export function subscribeJoinRequests(groupId: string, callback: (request: JoinRequest) => void) {
|
||||||
return pb.collection('join_requests').subscribe('*', (payload) => {
|
return pb.collection('join_requests').subscribe('*', (payload) => {
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,6 +7,27 @@ export const pb = new PocketBase(pbUrl)
|
|||||||
|
|
||||||
// SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
|
// SDK v0.21+ 自动使用 localStorage 持久化,无需手动 cookie 操作
|
||||||
|
|
||||||
|
// 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() {
|
export function getCurrentUser() {
|
||||||
return pb.authStore.model
|
return pb.authStore.model
|
||||||
@@ -20,6 +41,10 @@ export function isAuthenticated(): boolean {
|
|||||||
// 登出
|
// 登出
|
||||||
export function logout() {
|
export function logout() {
|
||||||
pb.authStore.clear()
|
pb.authStore.clear()
|
||||||
|
const eAPI = (window as any).electronAPI
|
||||||
|
if (eAPI?.storeDelete) {
|
||||||
|
eAPI.storeDelete('pb_auth')
|
||||||
|
}
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type { PointLog, PointAction } from '@/types'
|
|||||||
const POINT_MAP: Record<PointAction, number> = {
|
const POINT_MAP: Record<PointAction, number> = {
|
||||||
vote: 1,
|
vote: 1,
|
||||||
team: 2,
|
team: 2,
|
||||||
memory: 1
|
memory: 1,
|
||||||
|
bet: 0 // 竞猜积分变动不固定,通过 bets.ts 直接操作
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {
|
export async function awardPoints(action: PointAction, relatedId: string): Promise<void> {
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export async function getGroupTeamSessions(groupId: string): Promise<TeamSession
|
|||||||
return result.items as unknown as 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> {
|
export async function updateTeamStatus(sessionId: string, status: TeamStatus): Promise<TeamSession> {
|
||||||
const updateData: Partial<TeamSession> = { status }
|
const updateData: Partial<TeamSession> = { status }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||||
|
import { Edit, Delete, Box, Monitor, SetUp, Headset } from '@element-plus/icons-vue'
|
||||||
|
import { AssetTypeMap, displayName } from '@/types'
|
||||||
|
import { getAssetImageUrl } from '@/api/assets'
|
||||||
|
import { useAssetStore } from '@/stores/asset'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import type { Asset, AssetType } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
asset: Asset
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [assetId: string]
|
||||||
|
transfer: [assetId: string]
|
||||||
|
delete: [assetId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const assetStore = useAssetStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
// 是否有图片
|
||||||
|
const hasImage = computed(() => !!props.asset.image)
|
||||||
|
|
||||||
|
// 图片 URL(缩略图)
|
||||||
|
const imageUrl = computed(() => {
|
||||||
|
if (!props.asset.image) return ''
|
||||||
|
return getAssetImageUrl(props.asset.id, props.asset.image, '200x200')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 类型标签
|
||||||
|
const typeLabel = computed(() => {
|
||||||
|
return AssetTypeMap[props.asset.type as AssetType] || '其他'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前持有者信息
|
||||||
|
const holder = computed(() => {
|
||||||
|
return props.asset.expand?.currentHolder
|
||||||
|
})
|
||||||
|
|
||||||
|
const holderName = computed(() => {
|
||||||
|
if (!props.asset.currentHolder) return '在库'
|
||||||
|
return displayName(holder.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isInStock = computed(() => !props.asset.currentHolder)
|
||||||
|
|
||||||
|
// 类型图标
|
||||||
|
const typeIcon = computed(() => {
|
||||||
|
switch (props.asset.type) {
|
||||||
|
case 'game_account': return Monitor
|
||||||
|
case 'console': return SetUp
|
||||||
|
case 'equipment': return Monitor
|
||||||
|
case 'accessory': return Headset
|
||||||
|
default: return Box
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前用户是否为创建者或群主
|
||||||
|
const canManage = computed(() => {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
if (!userId) return false
|
||||||
|
if (props.asset.creator === userId) return true
|
||||||
|
if (groupStore.currentGroup?.owner === userId) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 点击卡片
|
||||||
|
function handleCardClick() {
|
||||||
|
emit('transfer', props.asset.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
function handleEdit(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('edit', props.asset.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
async function handleDelete(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除这个资产吗?', '确认删除', {
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
await assetStore.removeAsset(props.asset.id)
|
||||||
|
emit('delete', props.asset.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="asset-card" @click="handleCardClick">
|
||||||
|
<!-- 图片区域 -->
|
||||||
|
<div class="asset-card__image">
|
||||||
|
<img
|
||||||
|
v-if="hasImage"
|
||||||
|
:src="imageUrl"
|
||||||
|
:alt="asset.name"
|
||||||
|
class="asset-card__img"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div v-else class="asset-card__placeholder">
|
||||||
|
<el-icon :size="32"><component :is="typeIcon" /></el-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮(悬停显示) -->
|
||||||
|
<div v-if="canManage" class="asset-card__actions">
|
||||||
|
<button class="asset-card__action-btn" title="编辑" @click="handleEdit">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
</button>
|
||||||
|
<button class="asset-card__action-btn asset-card__action-btn--danger" title="删除" @click="handleDelete">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 信息区域 -->
|
||||||
|
<div class="asset-card__info">
|
||||||
|
<!-- 名称 -->
|
||||||
|
<div class="asset-card__name" :title="asset.name">{{ asset.name }}</div>
|
||||||
|
|
||||||
|
<!-- 类型标签 -->
|
||||||
|
<div class="asset-card__meta">
|
||||||
|
<span class="asset-card__type-tag">{{ typeLabel }}</span>
|
||||||
|
<span v-if="isInStock" class="asset-card__stock-tag">在库</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 持有者 -->
|
||||||
|
<div class="asset-card__holder">
|
||||||
|
<template v-if="holder">
|
||||||
|
<div class="asset-card__avatar">
|
||||||
|
{{ holderName.charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<span class="asset-card__holder-name">{{ holderName }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="asset-card__stock-text">在库</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.asset-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 2px 16px rgba(5, 150, 105, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 图片区域 */
|
||||||
|
.asset-card__image {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
background: var(--gg-bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__placeholder {
|
||||||
|
color: var(--gg-text-hint);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
|
.asset-card__actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card:hover .asset-card__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__action-btn:hover {
|
||||||
|
background: var(--gg-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__action-btn--danger:hover {
|
||||||
|
background: var(--gg-danger);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 信息区域 */
|
||||||
|
.asset-card__info {
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__type-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
background: rgba(5, 150, 105, 0.1);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__stock-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 持有者 */
|
||||||
|
.asset-card__holder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__holder-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-card__stock-text {
|
||||||
|
color: var(--gg-success);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useAssetStore } from '@/stores/asset'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { AssetTypeMap } from '@/types'
|
||||||
|
import type { Asset, AssetType } from '@/types'
|
||||||
|
import AssetCard from './AssetCard.vue'
|
||||||
|
import CreateAssetDialog from './CreateAssetDialog.vue'
|
||||||
|
import TransferAssetDialog from './TransferAssetDialog.vue'
|
||||||
|
|
||||||
|
const assetStore = useAssetStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
// 类型过滤
|
||||||
|
const typeFilter = ref<string>('')
|
||||||
|
|
||||||
|
// 类型选项
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
return [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
...(Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤后的资产列表
|
||||||
|
const filteredAssets = computed(() => {
|
||||||
|
if (!typeFilter.value) return assetStore.assets
|
||||||
|
return assetStore.assets.filter(a => a.type === typeFilter.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showTransferDialog = ref(false)
|
||||||
|
|
||||||
|
// 编辑/转移目标
|
||||||
|
const editingAsset = ref<Asset | undefined>(undefined)
|
||||||
|
const transferringAsset = ref<Asset | null>(null)
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
async function loadData() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
await assetStore.loadAssets(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅变更
|
||||||
|
async function setupSubscription() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
await assetStore.startSubscription(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登记资产
|
||||||
|
function handleCreate() {
|
||||||
|
editingAsset.value = undefined
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑资产
|
||||||
|
function handleEdit(assetId: string) {
|
||||||
|
const asset = assetStore.assets.find(a => a.id === assetId)
|
||||||
|
if (asset) {
|
||||||
|
editingAsset.value = asset
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转移资产
|
||||||
|
function handleTransfer(assetId: string) {
|
||||||
|
const asset = assetStore.assets.find(a => a.id === assetId)
|
||||||
|
if (asset) {
|
||||||
|
transferringAsset.value = asset
|
||||||
|
showTransferDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 资产删除后刷新
|
||||||
|
function handleDelete() {
|
||||||
|
// store 已经从列表中移除了,无需额外操作
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存成功回调
|
||||||
|
function handleSaved() {
|
||||||
|
showCreateDialog.value = false
|
||||||
|
editingAsset.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转移成功回调
|
||||||
|
function handleTransferred() {
|
||||||
|
showTransferDialog.value = false
|
||||||
|
transferringAsset.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
await loadData()
|
||||||
|
await setupSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => groupStore.currentGroupId, async (newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
await loadData()
|
||||||
|
await setupSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
assetStore.stopSubscription()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="asset-list">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="asset-list__header">
|
||||||
|
<h3 class="asset-list__title">资产库</h3>
|
||||||
|
<button class="asset-list__create-btn" @click="handleCreate">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="asset-list__create-icon">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
登记资产
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 类型过滤 -->
|
||||||
|
<div class="asset-list__filter">
|
||||||
|
<el-select
|
||||||
|
v-model="typeFilter"
|
||||||
|
placeholder="筛选类型"
|
||||||
|
size="default"
|
||||||
|
style="width: 160px"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in typeOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<span class="asset-list__count">共 {{ filteredAssets.length }} 项</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="assetStore.loading" v-loading="true" class="asset-list__loading"></div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-else-if="filteredAssets.length === 0"
|
||||||
|
class="asset-list__empty"
|
||||||
|
>
|
||||||
|
<svg class="asset-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||||
|
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||||
|
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||||
|
</svg>
|
||||||
|
<p class="asset-list__empty-text">暂无资产</p>
|
||||||
|
<p class="asset-list__empty-hint">点击「登记资产」添加第一个资产</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 资产网格 -->
|
||||||
|
<div v-else class="asset-list__grid">
|
||||||
|
<AssetCard
|
||||||
|
v-for="asset in filteredAssets"
|
||||||
|
:key="asset.id"
|
||||||
|
:asset="asset"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@transfer="handleTransfer"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建/编辑弹窗 -->
|
||||||
|
<CreateAssetDialog
|
||||||
|
v-model="showCreateDialog"
|
||||||
|
:group-id="groupStore.currentGroupId"
|
||||||
|
:edit-asset="editingAsset"
|
||||||
|
@saved="handleSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 转移弹窗 -->
|
||||||
|
<TransferAssetDialog
|
||||||
|
v-model="showTransferDialog"
|
||||||
|
:asset="transferringAsset"
|
||||||
|
:members="groupStore.currentMembers"
|
||||||
|
@transferred="handleTransferred"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.asset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部操作栏 */
|
||||||
|
.asset-list__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list__create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list__create-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list__create-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过滤栏 */
|
||||||
|
.asset-list__filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list__count {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.asset-list__loading {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格布局 */
|
||||||
|
.asset-list__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.asset-list__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list__empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list__empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-list__empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.asset-list__grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage, type UploadFile } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import { useAssetStore } from '@/stores/asset'
|
||||||
|
import { AssetTypeMap } from '@/types'
|
||||||
|
import type { Asset, AssetType } from '@/types'
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
groupId: string
|
||||||
|
editAsset?: Asset
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const assetStore = useAssetStore()
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.editAsset)
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => isEditing.value ? '编辑资产' : '登记资产')
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
type: 'other' as AssetType,
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileList = ref<UploadFile[]>([])
|
||||||
|
const selectedImage = ref<File | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 资产类型选项
|
||||||
|
const typeOptions = computed(() => {
|
||||||
|
return (Object.entries(AssetTypeMap) as [AssetType, string][]).map(([value, label]) => ({
|
||||||
|
value,
|
||||||
|
label
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
name: '',
|
||||||
|
type: 'other',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
fileList.value = []
|
||||||
|
selectedImage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化编辑表单
|
||||||
|
function initForm() {
|
||||||
|
resetForm()
|
||||||
|
if (props.editAsset) {
|
||||||
|
form.value.name = props.editAsset.name
|
||||||
|
form.value.type = props.editAsset.type
|
||||||
|
form.value.description = props.editAsset.description || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件选择变更
|
||||||
|
function handleFileChange(_file: UploadFile, uploadFileList: UploadFile[]) {
|
||||||
|
fileList.value = uploadFileList
|
||||||
|
if (_file.raw) {
|
||||||
|
selectedImage.value = _file.raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除文件
|
||||||
|
function handleFileRemove() {
|
||||||
|
fileList.value = []
|
||||||
|
selectedImage.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超出限制
|
||||||
|
function handleExceed() {
|
||||||
|
ElMessage.warning('只能上传一张图片,请先移除已选图片')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.name.trim()) {
|
||||||
|
ElMessage.warning('请输入资产名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
if (isEditing.value && props.editAsset) {
|
||||||
|
await assetStore.editAsset(
|
||||||
|
props.editAsset.id,
|
||||||
|
{
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
type: form.value.type,
|
||||||
|
description: form.value.description.trim() || undefined,
|
||||||
|
},
|
||||||
|
selectedImage.value || undefined
|
||||||
|
)
|
||||||
|
ElMessage.success('资产更新成功')
|
||||||
|
} else {
|
||||||
|
await assetStore.addAsset({
|
||||||
|
group: props.groupId,
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
type: form.value.type,
|
||||||
|
description: form.value.description.trim() || undefined,
|
||||||
|
image: selectedImage.value || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('资产登记成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
emit('saved')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || (isEditing.value ? '更新资产失败' : '登记资产失败'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开时初始化
|
||||||
|
function handleOpen() {
|
||||||
|
initForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭时重置
|
||||||
|
function handleClose() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="480px"
|
||||||
|
@open="handleOpen"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<div class="create-form">
|
||||||
|
<!-- 名称 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>名称 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.name"
|
||||||
|
placeholder="请输入资产名称"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 类型 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>类型</label>
|
||||||
|
<el-select v-model="form.type" placeholder="选择资产类型" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in typeOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>描述</label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="简单描述一下(可选)"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图片上传 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>图片</label>
|
||||||
|
<el-upload
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
:file-list="fileList"
|
||||||
|
:on-change="handleFileChange"
|
||||||
|
:on-remove="handleFileRemove"
|
||||||
|
:on-exceed="handleExceed"
|
||||||
|
accept="image/*"
|
||||||
|
list-type="picture"
|
||||||
|
>
|
||||||
|
<div class="upload-trigger">
|
||||||
|
<el-icon class="upload-icon"><Plus /></el-icon>
|
||||||
|
<div>点击上传图片</div>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||||
|
{{ loading ? (isEditing ? '保存中...' : '创建中...') : (isEditing ? '保存' : '登记') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-trigger {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--gg-text-hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useAssetStore } from '@/stores/asset'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import type { Asset, User } from '@/types'
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
asset: Asset | null
|
||||||
|
members: User[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
transferred: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const assetStore = useAssetStore()
|
||||||
|
|
||||||
|
const selectedUserId = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 资产名称
|
||||||
|
const assetName = computed(() => props.asset?.name || '')
|
||||||
|
|
||||||
|
// 当前持有者
|
||||||
|
const currentHolderId = computed(() => props.asset?.currentHolder || '')
|
||||||
|
|
||||||
|
// 成员列表(排除当前持有者)
|
||||||
|
const availableMembers = computed(() => {
|
||||||
|
if (!props.asset) return []
|
||||||
|
return props.members.filter(m => m.id !== currentHolderId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
function resetForm() {
|
||||||
|
selectedUserId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择"放回在库"
|
||||||
|
function selectInStock() {
|
||||||
|
selectedUserId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择成员
|
||||||
|
function selectMember(userId: string) {
|
||||||
|
selectedUserId.value = userId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交转移
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!props.asset) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// null 表示放回在库,传空字符串让 PocketBase 清空
|
||||||
|
const targetUserId = selectedUserId.value || ''
|
||||||
|
await assetStore.transfer(props.asset.id, targetUserId)
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
ElMessage.success(selectedUserId.value ? '资产已转移' : '资产已放回在库')
|
||||||
|
emit('transferred')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '转移资产失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开时重置
|
||||||
|
function handleOpen() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="转移资产"
|
||||||
|
width="480px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<div v-if="asset" class="transfer-form">
|
||||||
|
<!-- 资产名称 -->
|
||||||
|
<div class="transfer-asset-name">
|
||||||
|
<span class="transfer-label">资产</span>
|
||||||
|
<span class="transfer-value">{{ assetName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 放回在库 -->
|
||||||
|
<div class="member-selection">
|
||||||
|
<div class="section-label">选择新持有者</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="member-item member-item--stock"
|
||||||
|
:class="{ 'member-item--selected': selectedUserId === null }"
|
||||||
|
@click="selectInStock"
|
||||||
|
>
|
||||||
|
<div class="member-item__check">
|
||||||
|
<span v-if="selectedUserId === null" class="check-dot"></span>
|
||||||
|
</div>
|
||||||
|
<div class="member-item__avatar member-item__avatar--stock">
|
||||||
|
库
|
||||||
|
</div>
|
||||||
|
<span class="member-item__name">放回在库</span>
|
||||||
|
<span class="member-item__tag">在库</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 成员列表 -->
|
||||||
|
<div
|
||||||
|
v-for="member in availableMembers"
|
||||||
|
:key="member.id"
|
||||||
|
class="member-item"
|
||||||
|
:class="{ 'member-item--selected': selectedUserId === member.id }"
|
||||||
|
@click="selectMember(member.id)"
|
||||||
|
>
|
||||||
|
<div class="member-item__check">
|
||||||
|
<span v-if="selectedUserId === member.id" class="check-dot"></span>
|
||||||
|
</div>
|
||||||
|
<div class="member-item__avatar">
|
||||||
|
{{ displayName(member).charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<span class="member-item__name">{{ displayName(member) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无可用成员 -->
|
||||||
|
<div v-if="availableMembers.length === 0 && currentHolderId" class="no-members">
|
||||||
|
没有其他群成员可以转移
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||||
|
{{ loading ? '转移中...' : '确认转移' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transfer-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-asset-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--gg-bg-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transfer-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-selection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
background: rgba(5, 150, 105, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item--selected {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
background: rgba(5, 150, 105, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item__check {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--gg-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item--selected .member-item__check {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item__avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item__avatar--stock {
|
||||||
|
background: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item__name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item__tag {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-success);
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-members {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-hint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
bet: Bet
|
||||||
|
options: BetOption[]
|
||||||
|
entries: BetEntry[]
|
||||||
|
currentUserEntry: BetEntry | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 状态标签
|
||||||
|
const statusMap: Record<string, { label: string; cls: string }> = {
|
||||||
|
open: { label: '进行中', cls: 'active' },
|
||||||
|
closed: { label: '已关闭', cls: 'closed' },
|
||||||
|
settled: { label: '已开奖', cls: 'settled' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusInfo = computed(() => statusMap[props.bet.status] || statusMap.open)
|
||||||
|
|
||||||
|
// 发起人名称
|
||||||
|
const creatorName = computed(() => displayName(props.bet.expand?.creator))
|
||||||
|
|
||||||
|
// 奖池总额
|
||||||
|
const totalPool = computed(() => props.entries.reduce((sum, e) => sum + e.stake, 0))
|
||||||
|
|
||||||
|
// 各选项的下注总额
|
||||||
|
const optionStakes = computed(() => {
|
||||||
|
const map: Record<string, number> = {}
|
||||||
|
for (const e of props.entries) {
|
||||||
|
map[e.option] = (map[e.option] || 0) + e.stake
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
// 截止时间倒计时
|
||||||
|
const deadlineText = computed(() => {
|
||||||
|
if (!props.bet.deadline) return null
|
||||||
|
const deadline = new Date(props.bet.deadline).getTime()
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = deadline - now
|
||||||
|
|
||||||
|
if (diff <= 0) return '已截止'
|
||||||
|
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60))
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
|
if (days > 0) return `${days}天后截止`
|
||||||
|
if (hours > 0) return `${hours}小时后截止`
|
||||||
|
if (minutes > 0) return `${minutes}分钟后截止`
|
||||||
|
return '即将截止'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bet-card" @click="emit('click')">
|
||||||
|
<!-- 顶部标签行 -->
|
||||||
|
<div class="bet-card__tags">
|
||||||
|
<span class="bet-card__status-tag" :class="`bet-card__status-tag--${statusInfo.cls}`">
|
||||||
|
{{ statusInfo.label }}
|
||||||
|
</span>
|
||||||
|
<span v-if="currentUserEntry" class="bet-card__voted-badge">
|
||||||
|
已下注
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="bet-card__title">{{ bet.title }}</div>
|
||||||
|
|
||||||
|
<!-- 发起人 -->
|
||||||
|
<div class="bet-card__creator">
|
||||||
|
<svg class="bet-card__creator-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ creatorName }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 奖池总额 -->
|
||||||
|
<div class="bet-card__pool">
|
||||||
|
<svg class="bet-card__pool-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="1" x2="12" y2="23" />
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
|
</svg>
|
||||||
|
<span class="bet-card__pool-label">奖池</span>
|
||||||
|
<span class="bet-card__pool-value">{{ totalPool }} 积分</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 各选项进度条 -->
|
||||||
|
<div v-if="options.length > 0 && totalPool > 0" class="bet-card__options">
|
||||||
|
<div
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.id"
|
||||||
|
class="bet-card__option"
|
||||||
|
>
|
||||||
|
<div class="bet-card__option-header">
|
||||||
|
<span class="bet-card__option-name">{{ option.content }}</span>
|
||||||
|
<span class="bet-card__option-pct">
|
||||||
|
{{ totalPool > 0 ? Math.round((optionStakes[option.id] || 0) / totalPool * 100) : 0 }}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bet-card__option-bar">
|
||||||
|
<div
|
||||||
|
class="bet-card__option-fill"
|
||||||
|
:style="{
|
||||||
|
width: totalPool > 0
|
||||||
|
? ((optionStakes[option.id] || 0) / totalPool * 100) + '%'
|
||||||
|
: '0%',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部:截止时间 -->
|
||||||
|
<div v-if="deadlineText" class="bet-card__footer">
|
||||||
|
<svg class="bet-card__footer-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12 6 12 12 16 14" />
|
||||||
|
</svg>
|
||||||
|
<span :class="{ 'bet-card__deadline--urgent': deadlineText === '即将截止' }">
|
||||||
|
{{ deadlineText }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bet-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 16px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签行 */
|
||||||
|
.bet-card__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__status-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__status-tag--active {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__status-tag--closed {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__status-tag--settled {
|
||||||
|
background: rgba(64, 158, 255, 0.1);
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__voted-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
background: rgba(5, 150, 105, 0.15);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题 */
|
||||||
|
.bet-card__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发起人 */
|
||||||
|
.bet-card__creator {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__creator-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 奖池 */
|
||||||
|
.bet-card__pool {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: rgba(5, 150, 105, 0.06);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__pool-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__pool-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__pool-value {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选项进度 */
|
||||||
|
.bet-card__options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__option-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__option-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__option-pct {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__option-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__option-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.bet-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__footer-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-card__deadline--urgent {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,650 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
|
||||||
|
import { getBet, getBetOptions, getBetEntries, placeBet, closeBet, subscribeBets } from '@/api/bets'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||||
|
import BetEntryList from './BetEntryList.vue'
|
||||||
|
import SettleBetDialog from './SettleBetDialog.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
betId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
back: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 数据
|
||||||
|
const bet = ref<Bet | null>(null)
|
||||||
|
const options = ref<BetOption[]>([])
|
||||||
|
const entries = ref<BetEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 下注表单
|
||||||
|
const selectedOption = ref('')
|
||||||
|
const stakeAmount = ref(1)
|
||||||
|
|
||||||
|
// 结算弹窗
|
||||||
|
const showSettle = ref(false)
|
||||||
|
|
||||||
|
// 实时订阅
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const creatorName = computed(() => displayName(bet.value?.expand?.creator))
|
||||||
|
|
||||||
|
const totalPool = computed(() => entries.value.reduce((sum, e) => sum + e.stake, 0))
|
||||||
|
|
||||||
|
// 各选项的下注总额和人数
|
||||||
|
const optionStats = computed(() => {
|
||||||
|
const stats: Record<string, { totalStake: number; count: number }> = {}
|
||||||
|
for (const opt of options.value) {
|
||||||
|
stats[opt.id] = { totalStake: 0, count: 0 }
|
||||||
|
}
|
||||||
|
for (const e of entries.value) {
|
||||||
|
if (!stats[e.option]) {
|
||||||
|
stats[e.option] = { totalStake: 0, count: 0 }
|
||||||
|
}
|
||||||
|
stats[e.option].totalStake += e.stake
|
||||||
|
stats[e.option].count += 1
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当前用户
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id)
|
||||||
|
const isCreator = computed(() => bet.value?.creator === currentUserId.value)
|
||||||
|
|
||||||
|
// 当前用户的下注记录
|
||||||
|
const currentUserEntry = computed(() =>
|
||||||
|
entries.value.find((e) => e.user === currentUserId.value) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const isOpen = computed(() => bet.value?.status === 'open')
|
||||||
|
const isClosed = computed(() => bet.value?.status === 'closed')
|
||||||
|
const isSettled = computed(() => bet.value?.status === 'settled')
|
||||||
|
|
||||||
|
// 结果选项ID
|
||||||
|
const resultOptionId = computed(() => bet.value?.resultOption || '')
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
async function loadDetail() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [betData, optionsData, entriesData] = await Promise.all([
|
||||||
|
getBet(props.betId),
|
||||||
|
getBetOptions(props.betId),
|
||||||
|
getBetEntries(props.betId),
|
||||||
|
])
|
||||||
|
bet.value = betData
|
||||||
|
options.value = optionsData
|
||||||
|
entries.value = entriesData
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载竞猜详情失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下注
|
||||||
|
async function handlePlaceBet() {
|
||||||
|
if (!selectedOption.value) {
|
||||||
|
ElMessage.warning('请选择一个选项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!stakeAmount.value || stakeAmount.value < 1) {
|
||||||
|
ElMessage.warning('请输入有效的积分数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await placeBet(props.betId, selectedOption.value, stakeAmount.value)
|
||||||
|
ElMessage.success('下注成功')
|
||||||
|
await loadDetail()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '下注失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭竞猜
|
||||||
|
async function handleClose() {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要关闭竞猜吗?关闭后将不能再下注。', '关闭竞猜', {
|
||||||
|
confirmButtonText: '确定关闭',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await closeBet(props.betId)
|
||||||
|
ElMessage.success('竞猜已关闭')
|
||||||
|
await loadDetail()
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时订阅
|
||||||
|
async function startSubscription() {
|
||||||
|
if (unsubscribeFn) {
|
||||||
|
unsubscribeFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
}
|
||||||
|
if (!bet.value) return
|
||||||
|
const groupId = bet.value.group
|
||||||
|
unsubscribeFn = await subscribeBets(groupId, () => {
|
||||||
|
loadDetail()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadDetail()
|
||||||
|
startSubscription()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribeFn) {
|
||||||
|
unsubscribeFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bet-detail">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="bet-detail__header">
|
||||||
|
<el-button text @click="emit('back')">
|
||||||
|
<el-icon><ArrowLeft /></el-icon>
|
||||||
|
返回
|
||||||
|
</el-button>
|
||||||
|
<div class="bet-detail__actions">
|
||||||
|
<button
|
||||||
|
v-if="isCreator && isOpen"
|
||||||
|
class="action-btn action-btn--close"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
关闭竞猜
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isCreator && isClosed"
|
||||||
|
class="action-btn action-btn--settle"
|
||||||
|
@click="showSettle = true"
|
||||||
|
>
|
||||||
|
开奖
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="loading" class="bet-detail__loading">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 竞猜内容 -->
|
||||||
|
<template v-else-if="bet">
|
||||||
|
<!-- 标题区域 -->
|
||||||
|
<div class="bet-detail__title-area">
|
||||||
|
<h2 class="bet-detail__title">{{ bet.title }}</h2>
|
||||||
|
<div class="bet-detail__meta">
|
||||||
|
<span class="bet-detail__meta-item">
|
||||||
|
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
{{ creatorName }} 发起
|
||||||
|
</span>
|
||||||
|
<span class="bet-detail__meta-item">
|
||||||
|
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="12" y1="1" x2="12" y2="23" />
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
|
</svg>
|
||||||
|
奖池 {{ totalPool }} 积分
|
||||||
|
</span>
|
||||||
|
<span class="bet-detail__meta-item">
|
||||||
|
下注范围 {{ bet.minStake }}~{{ bet.maxStake }} 积分
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<div v-if="bet.description" class="bet-detail__desc">
|
||||||
|
{{ bet.description }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选项列表 -->
|
||||||
|
<div class="bet-detail__options">
|
||||||
|
<div
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.id"
|
||||||
|
:class="[
|
||||||
|
'bet-detail__option',
|
||||||
|
{
|
||||||
|
'bet-detail__option--winner': isSettled && resultOptionId === option.id,
|
||||||
|
'bet-detail__option--loser': isSettled && resultOptionId !== option.id,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="bet-detail__option-header">
|
||||||
|
<span class="bet-detail__option-name">
|
||||||
|
{{ option.content }}
|
||||||
|
<span v-if="isSettled && resultOptionId === option.id" class="winner-badge">
|
||||||
|
正确答案
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<div class="bet-detail__option-stats">
|
||||||
|
<span class="bet-detail__option-count">
|
||||||
|
{{ (optionStats[option.id]?.count || 0) }} 人
|
||||||
|
</span>
|
||||||
|
<span class="bet-detail__option-stake">
|
||||||
|
{{ optionStats[option.id]?.totalStake || 0 }} 积分
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bet-detail__option-bar">
|
||||||
|
<div
|
||||||
|
class="bet-detail__option-fill"
|
||||||
|
:class="{ 'bet-detail__option-fill--winner': isSettled && resultOptionId === option.id }"
|
||||||
|
:style="{
|
||||||
|
width: totalPool > 0
|
||||||
|
? ((optionStats[option.id]?.totalStake || 0) / totalPool * 100) + '%'
|
||||||
|
: '0%',
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下注区域(状态 open 且未下注时) -->
|
||||||
|
<div v-if="isOpen && !currentUserEntry" class="bet-detail__bet-area">
|
||||||
|
<h4 class="bet-detail__bet-title">下注</h4>
|
||||||
|
<div class="bet-detail__bet-form">
|
||||||
|
<div class="bet-detail__bet-option">
|
||||||
|
<label>选择选项</label>
|
||||||
|
<div class="bet-detail__option-pick">
|
||||||
|
<button
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.id"
|
||||||
|
type="button"
|
||||||
|
:class="['bet-detail__pick-card', { 'bet-detail__pick-card--active': selectedOption === option.id }]"
|
||||||
|
@click="selectedOption = option.id"
|
||||||
|
>
|
||||||
|
{{ option.content }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bet-detail__bet-stake">
|
||||||
|
<label>下注积分</label>
|
||||||
|
<el-input-number
|
||||||
|
v-model="stakeAmount"
|
||||||
|
:min="bet.minStake"
|
||||||
|
:max="bet.maxStake"
|
||||||
|
:step="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="bet-detail__bet-btn"
|
||||||
|
:disabled="!selectedOption"
|
||||||
|
@click="handlePlaceBet"
|
||||||
|
>
|
||||||
|
下注
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已下注提示 -->
|
||||||
|
<div v-if="isOpen && currentUserEntry" class="bet-detail__already-bet">
|
||||||
|
<svg class="bet-detail__already-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
你已下注 <strong>{{ currentUserEntry.stake }}</strong> 积分,选择「{{ currentUserEntry.expand?.option?.content || '未知' }}」
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 下注记录 -->
|
||||||
|
<div class="bet-detail__entries">
|
||||||
|
<h4 class="bet-detail__entries-title">下注记录</h4>
|
||||||
|
<BetEntryList :entries="entries" :show-result="isSettled" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 开奖弹窗 -->
|
||||||
|
<SettleBetDialog
|
||||||
|
v-if="bet"
|
||||||
|
v-model="showSettle"
|
||||||
|
:bet-id="betId"
|
||||||
|
:options="options"
|
||||||
|
@settled="loadDetail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bet-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部操作栏 */
|
||||||
|
.bet-detail__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--close {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--close:hover {
|
||||||
|
border-color: var(--gg-danger);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--settle {
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--settle:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载中 */
|
||||||
|
.bet-detail__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 40px 0;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题区域 */
|
||||||
|
.bet-detail__title-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描述 */
|
||||||
|
.bet-detail__desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选项列表 */
|
||||||
|
.bet-detail__options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option--winner {
|
||||||
|
border-color: var(--gg-success);
|
||||||
|
background: rgba(16, 185, 129, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option--loser {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.winner-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-stake {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-fill--winner {
|
||||||
|
background: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下注区域 */
|
||||||
|
.bet-detail__bet-area {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__bet-title {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__bet-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__bet-option,
|
||||||
|
.bet-detail__bet-stake {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__bet-option label,
|
||||||
|
.bet-detail__bet-stake label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__option-pick {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__pick-card {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__pick-card:hover {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__pick-card--active {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
background: rgba(5, 150, 105, 0.08);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__bet-btn {
|
||||||
|
padding: 8px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__bet-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__bet-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 已下注提示 */
|
||||||
|
.bet-detail__already-bet {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(5, 150, 105, 0.06);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__already-bet strong {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__already-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--gg-success);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下注记录 */
|
||||||
|
.bet-detail__entries {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-detail__entries-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bet-detail__bet-form {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import type { BetEntry } from '@/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entries: BetEntry[]
|
||||||
|
showResult?: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="entry-list">
|
||||||
|
<div v-if="entries.length === 0" class="entry-list__empty">
|
||||||
|
暂无下注记录
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="entry in entries"
|
||||||
|
:key="entry.id"
|
||||||
|
class="entry-list__item"
|
||||||
|
>
|
||||||
|
<!-- 用户信息 -->
|
||||||
|
<div class="entry-list__user">
|
||||||
|
<div class="entry-list__avatar">
|
||||||
|
{{ displayName(entry.expand?.user).charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<span class="entry-list__name">{{ displayName(entry.expand?.user) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选择 & 积分 -->
|
||||||
|
<div class="entry-list__info">
|
||||||
|
<span class="entry-list__option">
|
||||||
|
{{ entry.expand?.option?.content || '未知选项' }}
|
||||||
|
</span>
|
||||||
|
<span class="entry-list__stake">{{ entry.stake }} 积分</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果标签 -->
|
||||||
|
<div v-if="showResult" class="entry-list__result">
|
||||||
|
<span v-if="entry.won" class="entry-list__badge entry-list__badge--win">赢</span>
|
||||||
|
<span v-else class="entry-list__badge entry-list__badge--lose">输</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.entry-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__option {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
max-width: 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__stake {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__result {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__badge--win {
|
||||||
|
background: rgba(16, 185, 129, 0.12);
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-list__badge--lose {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { listBets, getBetOptions, getBetEntries, subscribeBets } from '@/api/bets'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import BetCard from './BetCard.vue'
|
||||||
|
import CreateBetDialog from './CreateBetDialog.vue'
|
||||||
|
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
viewBet: [betId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
|
||||||
|
// 竞猜列表
|
||||||
|
const allBets = ref<Bet[]>([])
|
||||||
|
|
||||||
|
// 缓存每个 bet 的 options / entries
|
||||||
|
interface BetExtra {
|
||||||
|
options: BetOption[]
|
||||||
|
entries: BetEntry[]
|
||||||
|
currentUserEntry: BetEntry | null
|
||||||
|
}
|
||||||
|
const betExtras = ref<Record<string, BetExtra>>({})
|
||||||
|
|
||||||
|
// 分栏
|
||||||
|
const activeBets = computed(() =>
|
||||||
|
allBets.value.filter((b) => b.status === 'open' || b.status === 'closed')
|
||||||
|
)
|
||||||
|
const settledBets = computed(() =>
|
||||||
|
allBets.value.filter((b) => b.status === 'settled')
|
||||||
|
)
|
||||||
|
|
||||||
|
// 加载所有竞猜及详情
|
||||||
|
async function loadAll() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const bets = await listBets(groupId)
|
||||||
|
allBets.value = bets
|
||||||
|
|
||||||
|
// 并行加载每个 bet 的 options / entries
|
||||||
|
const currentUserId = pb.authStore.model?.id
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
bets.map(async (bet) => {
|
||||||
|
const [options, entries] = await Promise.all([
|
||||||
|
getBetOptions(bet.id),
|
||||||
|
getBetEntries(bet.id),
|
||||||
|
])
|
||||||
|
const currentUserEntry = currentUserId
|
||||||
|
? entries.find((e) => e.user === currentUserId) || null
|
||||||
|
: null
|
||||||
|
return { betId: bet.id, options, entries, currentUserEntry }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const extras: Record<string, BetExtra> = {}
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
extras[r.value.betId] = {
|
||||||
|
options: r.value.options,
|
||||||
|
entries: r.value.entries,
|
||||||
|
currentUserEntry: r.value.currentUserEntry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
betExtras.value = extras
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载竞猜列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取某个 bet 的缓存 extras
|
||||||
|
function getExtra(betId: string): BetExtra {
|
||||||
|
return betExtras.value[betId] || { options: [], entries: [], currentUserEntry: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时订阅
|
||||||
|
async function startSubscription() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
|
||||||
|
unsubscribeFn = await subscribeBets(groupId, () => {
|
||||||
|
loadAll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscription() {
|
||||||
|
if (unsubscribeFn) {
|
||||||
|
unsubscribeFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
loadAll()
|
||||||
|
startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
loadAll()
|
||||||
|
if (!unsubscribeFn) startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopSubscription()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bet-list">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="bet-list__header">
|
||||||
|
<h3 class="bet-list__title">竞猜</h3>
|
||||||
|
<button class="bet-list__create-btn" @click="showCreate = true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="bet-list__create-icon">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
发起竞猜
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateBetDialog
|
||||||
|
v-model="showCreate"
|
||||||
|
@created="loadAll(); showCreate = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 进行中 -->
|
||||||
|
<section v-if="activeBets.length > 0" class="bet-list__section">
|
||||||
|
<div class="bet-list__section-header">
|
||||||
|
<span class="bet-list__section-dot bet-list__section-dot--active"></span>
|
||||||
|
<span class="bet-list__section-label">进行中</span>
|
||||||
|
<span class="bet-list__section-count">{{ activeBets.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bet-list__grid">
|
||||||
|
<BetCard
|
||||||
|
v-for="bet in activeBets"
|
||||||
|
:key="bet.id"
|
||||||
|
:bet="bet"
|
||||||
|
:options="getExtra(bet.id).options"
|
||||||
|
:entries="getExtra(bet.id).entries"
|
||||||
|
:current-user-entry="getExtra(bet.id).currentUserEntry"
|
||||||
|
@click="emit('viewBet', bet.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 已结束 -->
|
||||||
|
<section v-if="settledBets.length > 0" class="bet-list__section">
|
||||||
|
<div class="bet-list__section-header">
|
||||||
|
<span class="bet-list__section-dot bet-list__section-dot--settled"></span>
|
||||||
|
<span class="bet-list__section-label">已结束</span>
|
||||||
|
<span class="bet-list__section-count">{{ settledBets.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bet-list__grid">
|
||||||
|
<BetCard
|
||||||
|
v-for="bet in settledBets"
|
||||||
|
:key="bet.id"
|
||||||
|
:bet="bet"
|
||||||
|
:options="getExtra(bet.id).options"
|
||||||
|
:entries="getExtra(bet.id).entries"
|
||||||
|
:current-user-entry="getExtra(bet.id).currentUserEntry"
|
||||||
|
@click="emit('viewBet', bet.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<div v-if="loading && allBets.length === 0" class="bet-list__loading">
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-if="activeBets.length === 0 && settledBets.length === 0 && !loading"
|
||||||
|
class="bet-list__empty"
|
||||||
|
>
|
||||||
|
<svg class="bet-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<line x1="12" y1="1" x2="12" y2="23" />
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
|
</svg>
|
||||||
|
<p class="bet-list__empty-text">暂无竞猜</p>
|
||||||
|
<p class="bet-list__empty-hint">群组内还没有发起过竞猜</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bet-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部操作栏 */
|
||||||
|
.bet-list__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__create-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__create-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分栏 */
|
||||||
|
.bet-list__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__section-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__section-dot--active {
|
||||||
|
background: var(--gg-success);
|
||||||
|
box-shadow: 0 0 6px var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__section-dot--settled {
|
||||||
|
background: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__section-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__section-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格布局 */
|
||||||
|
.bet-list__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载中 */
|
||||||
|
.bet-list__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.bet-list__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bet-list__empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bet-list__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import { createBet } from '@/api/bets'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
options: ['', ''],
|
||||||
|
deadline: '' as string | Date,
|
||||||
|
minStake: 1,
|
||||||
|
maxStake: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 是否可以添加选项
|
||||||
|
const canAddOption = computed(() => form.value.options.length < 10)
|
||||||
|
|
||||||
|
function addOption() {
|
||||||
|
if (canAddOption.value) {
|
||||||
|
form.value.options.push('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(index: number) {
|
||||||
|
form.value.options.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
options: ['', ''],
|
||||||
|
deadline: '',
|
||||||
|
minStake: 1,
|
||||||
|
maxStake: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
// 标题必填
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
ElMessage.warning('请输入竞猜标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 至少2个非空选项
|
||||||
|
const nonEmpty = form.value.options.filter((o) => o.trim())
|
||||||
|
if (nonEmpty.length < 2) {
|
||||||
|
ElMessage.warning('至少需要2个非空选项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截止时间必填
|
||||||
|
if (!form.value.deadline) {
|
||||||
|
ElMessage.warning('请选择截止时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 积分范围校验
|
||||||
|
if (form.value.minStake < 1 || form.value.maxStake < form.value.minStake) {
|
||||||
|
ElMessage.warning('请设置正确的积分范围')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) {
|
||||||
|
ElMessage.error('请先选择群组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await createBet({
|
||||||
|
group: groupId,
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
description: form.value.description.trim() || undefined,
|
||||||
|
options: nonEmpty,
|
||||||
|
minStake: form.value.minStake,
|
||||||
|
maxStake: form.value.maxStake,
|
||||||
|
deadline: new Date(String(form.value.deadline)).toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
resetForm()
|
||||||
|
ElMessage.success('竞猜创建成功')
|
||||||
|
emit('created')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '创建竞猜失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="发起竞猜"
|
||||||
|
width="520px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<div class="create-form">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>标题 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.title"
|
||||||
|
placeholder="今晚谁 MVP?"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>描述</label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="补充说明(可选)"
|
||||||
|
:rows="2"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选项管理 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>竞猜选项</label>
|
||||||
|
<div class="options-list">
|
||||||
|
<div
|
||||||
|
v-for="(_, index) in form.options"
|
||||||
|
:key="index"
|
||||||
|
class="option-item"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model="form.options[index]"
|
||||||
|
:placeholder="`选项 ${index + 1}`"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
v-if="form.options.length > 2"
|
||||||
|
type="danger"
|
||||||
|
text
|
||||||
|
@click="removeOption(index)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="canAddOption"
|
||||||
|
class="add-option-btn"
|
||||||
|
@click="addOption"
|
||||||
|
>
|
||||||
|
<el-icon><Plus /></el-icon> 添加选项
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 截止时间 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>截止时间 <span class="required">*</span></label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.deadline"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择截止时间"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 积分范围 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>下注积分范围</label>
|
||||||
|
<div class="stake-range">
|
||||||
|
<div class="stake-range__item">
|
||||||
|
<span class="stake-range__label">最小</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.minStake"
|
||||||
|
:min="1"
|
||||||
|
:max="100"
|
||||||
|
:step="1"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="stake-range__separator">~</span>
|
||||||
|
<div class="stake-range__item">
|
||||||
|
<span class="stake-range__label">最大</span>
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.maxStake"
|
||||||
|
:min="form.minStake"
|
||||||
|
:max="100"
|
||||||
|
:step="1"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button
|
||||||
|
class="submit-btn"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
{{ loading ? '创建中...' : '发起竞猜' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.options-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item .el-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-option-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-option-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-range {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-range__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-range__label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stake-range__separator {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { settleBet } from '@/api/bets'
|
||||||
|
import type { BetOption } from '@/types'
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
betId: string
|
||||||
|
options: BetOption[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
settled: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedOption = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
selectedOption.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSettle() {
|
||||||
|
if (!selectedOption.value) {
|
||||||
|
ElMessage.warning('请选择正确选项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await settleBet(props.betId, selectedOption.value)
|
||||||
|
visible.value = false
|
||||||
|
ElMessage.success('开奖成功')
|
||||||
|
emit('settled')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '开奖失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="开奖"
|
||||||
|
width="420px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<div class="settle-form">
|
||||||
|
<p class="settle-form__hint">请选择正确的选项来结算竞猜:</p>
|
||||||
|
|
||||||
|
<el-radio-group v-model="selectedOption" class="settle-form__options">
|
||||||
|
<div
|
||||||
|
v-for="option in options"
|
||||||
|
:key="option.id"
|
||||||
|
class="settle-form__option"
|
||||||
|
>
|
||||||
|
<el-radio :value="option.id">
|
||||||
|
{{ option.content }}
|
||||||
|
</el-radio>
|
||||||
|
</div>
|
||||||
|
</el-radio-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button
|
||||||
|
class="settle-form__submit"
|
||||||
|
:disabled="loading || !selectedOption"
|
||||||
|
@click="handleSettle"
|
||||||
|
>
|
||||||
|
{{ loading ? '结算中...' : '确认开奖' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settle-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-form__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-form__options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-form__option {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-form__option:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-form__submit {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-form__submit:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settle-form__submit:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { useBulletinStore } from '@/stores/bulletin'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { subscribeBulletins } from '@/api/bulletins'
|
||||||
|
import BulletinPostCard from './BulletinPostCard.vue'
|
||||||
|
import CreateBulletinDialog from './CreateBulletinDialog.vue'
|
||||||
|
import type { BulletinPost } from '@/types'
|
||||||
|
import { displayName, BulletinPriorityMap, BulletinPriorityColor } from '@/types'
|
||||||
|
|
||||||
|
const bulletinStore = useBulletinStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const editingPost = ref<BulletinPost | null>(null)
|
||||||
|
const detailPost = ref<BulletinPost | null>(null)
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
await bulletinStore.loadPosts(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSubscription() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
|
||||||
|
unsubscribeFn = await subscribeBulletins(groupId, () => {
|
||||||
|
loadAll()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscription() {
|
||||||
|
if (unsubscribeFn) {
|
||||||
|
unsubscribeFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardClick(post: BulletinPost) {
|
||||||
|
detailPost.value = post
|
||||||
|
bulletinStore.markAsRead(post.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(post: BulletinPost) {
|
||||||
|
editingPost.value = post
|
||||||
|
showCreate.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(post: BulletinPost) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除公告 "${post.title}" 吗?`,
|
||||||
|
'确认删除',
|
||||||
|
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
await bulletinStore.remove(post.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDialogClose() {
|
||||||
|
editingPost.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailVisible = computed({
|
||||||
|
get: () => !!detailPost.value,
|
||||||
|
set: (v: boolean) => { if (!v) detailPost.value = null }
|
||||||
|
})
|
||||||
|
|
||||||
|
function priorityLabel(post: BulletinPost) { return BulletinPriorityMap[post.priority] }
|
||||||
|
function priorityColor(post: BulletinPost) { return BulletinPriorityColor[post.priority] }
|
||||||
|
function creatorName(post: BulletinPost) { return displayName(post.expand?.creator) }
|
||||||
|
function isExpired(post: BulletinPost) {
|
||||||
|
return post.expiresAt ? new Date(post.expiresAt) <= new Date() : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60))
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days > 0) return `${days}天前`
|
||||||
|
if (hours > 0) return `${hours}小时前`
|
||||||
|
if (minutes > 0) return `${minutes}分钟前`
|
||||||
|
return '刚刚'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteFromDetail(post: BulletinPost) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要删除公告 "${post.title}" 吗?`,
|
||||||
|
'确认删除',
|
||||||
|
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
|
||||||
|
)
|
||||||
|
await bulletinStore.remove(post.id)
|
||||||
|
detailPost.value = null
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
loadAll()
|
||||||
|
startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
loadAll()
|
||||||
|
if (!unsubscribeFn) startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopSubscription()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bulletin-board">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="bulletin-board__header">
|
||||||
|
<h3 class="bulletin-board__title">公告</h3>
|
||||||
|
<button class="bulletin-board__create-btn" @click="showCreate = true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="bulletin-board__create-icon">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
发布公告
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateBulletinDialog
|
||||||
|
v-model="showCreate"
|
||||||
|
:edit-post="editingPost"
|
||||||
|
@created="loadAll(); showCreate = false; handleDialogClose()"
|
||||||
|
@updated="loadAll(); showCreate = false; handleDialogClose()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 详情弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="detailVisible"
|
||||||
|
:title="null"
|
||||||
|
width="580px"
|
||||||
|
:close-on-click-modal="true"
|
||||||
|
class="bulletin-detail-dialog"
|
||||||
|
@closed="detailPost = null"
|
||||||
|
>
|
||||||
|
<template v-if="detailPost">
|
||||||
|
<div class="bulletin-detail__tags">
|
||||||
|
<span
|
||||||
|
class="bulletin-detail__priority"
|
||||||
|
:style="{ background: priorityColor(detailPost) + '18', color: priorityColor(detailPost) }"
|
||||||
|
>
|
||||||
|
{{ priorityLabel(detailPost) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="detailPost.pinned" class="bulletin-detail__pinned">置顶</span>
|
||||||
|
<span v-if="isExpired(detailPost)" class="bulletin-detail__expired">已过期</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="bulletin-detail__title">{{ detailPost.title }}</h2>
|
||||||
|
<div class="bulletin-detail__meta">
|
||||||
|
<span class="bulletin-detail__author">{{ creatorName(detailPost) }}</span>
|
||||||
|
<span class="bulletin-detail__sep">·</span>
|
||||||
|
<span class="bulletin-detail__time">{{ formatTime(detailPost.created) }}</span>
|
||||||
|
<span v-if="detailPost.expiresAt" class="bulletin-detail__expires">
|
||||||
|
· 截止 {{ formatDate(detailPost.expiresAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="bulletin-detail__content">{{ detailPost.content }}</div>
|
||||||
|
<div class="bulletin-detail__footer">
|
||||||
|
<button class="bulletin-detail__btn" @click="handleEdit(detailPost!); detailPost = null">编辑</button>
|
||||||
|
<button class="bulletin-detail__btn bulletin-detail__btn--danger" @click="handleDeleteFromDetail(detailPost!)">删除</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 置顶公告 -->
|
||||||
|
<section v-if="bulletinStore.pinnedPosts.length > 0" class="bulletin-board__section">
|
||||||
|
<div class="bulletin-board__section-header">
|
||||||
|
<span class="bulletin-board__section-dot bulletin-board__section-dot--pinned"></span>
|
||||||
|
<span class="bulletin-board__section-label">置顶公告</span>
|
||||||
|
<span class="bulletin-board__section-count">{{ bulletinStore.pinnedPosts.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bulletin-board__grid">
|
||||||
|
<BulletinPostCard
|
||||||
|
v-for="post in bulletinStore.pinnedPosts"
|
||||||
|
:key="post.id"
|
||||||
|
:post="post"
|
||||||
|
:is-read="bulletinStore.isRead(post.id)"
|
||||||
|
@click="handleCardClick(post)"
|
||||||
|
@edit="handleEdit(post)"
|
||||||
|
@delete="handleDelete(post)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 普通公告 -->
|
||||||
|
<section v-if="bulletinStore.normalPosts.length > 0" class="bulletin-board__section">
|
||||||
|
<div class="bulletin-board__section-header">
|
||||||
|
<span class="bulletin-board__section-dot bulletin-board__section-dot--normal"></span>
|
||||||
|
<span class="bulletin-board__section-label">普通公告</span>
|
||||||
|
<span class="bulletin-board__section-count">{{ bulletinStore.normalPosts.length }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bulletin-board__grid">
|
||||||
|
<BulletinPostCard
|
||||||
|
v-for="post in bulletinStore.normalPosts"
|
||||||
|
:key="post.id"
|
||||||
|
:post="post"
|
||||||
|
:is-read="bulletinStore.isRead(post.id)"
|
||||||
|
@click="handleCardClick(post)"
|
||||||
|
@edit="handleEdit(post)"
|
||||||
|
@delete="handleDelete(post)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-if="bulletinStore.pinnedPosts.length === 0 && bulletinStore.normalPosts.length === 0 && !bulletinStore.loading"
|
||||||
|
class="bulletin-board__empty"
|
||||||
|
>
|
||||||
|
<svg class="bulletin-board__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||||
|
<polyline points="14 2 14 8 20 8" />
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
|
<polyline points="10 9 9 9 8 9" />
|
||||||
|
</svg>
|
||||||
|
<p class="bulletin-board__empty-text">暂无公告</p>
|
||||||
|
<p class="bulletin-board__empty-hint">群组内还没有发布过公告</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-board {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部操作栏 */
|
||||||
|
.bulletin-board__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__create-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__create-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分栏 */
|
||||||
|
.bulletin-board__section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-dot--pinned {
|
||||||
|
background: var(--gg-primary);
|
||||||
|
box-shadow: 0 0 6px var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-dot--normal {
|
||||||
|
background: var(--gg-info);
|
||||||
|
box-shadow: 0 0 6px var(--gg-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__section-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 网格布局 */
|
||||||
|
.bulletin-board__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.bulletin-board__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-board__empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bulletin-board__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bulletin-detail-dialog .el-dialog__header {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.bulletin-detail-dialog .el-dialog__body {
|
||||||
|
padding: 28px 32px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-detail__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__priority,
|
||||||
|
.bulletin-detail__pinned,
|
||||||
|
.bulletin-detail__expired {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__pinned {
|
||||||
|
background: rgba(5, 150, 105, 0.1);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__expired {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__sep {
|
||||||
|
color: var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__content {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text);
|
||||||
|
line-height: 1.8;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__btn {
|
||||||
|
padding: 6px 16px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__btn:hover {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-detail__btn--danger:hover {
|
||||||
|
border-color: var(--gg-danger);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useBulletinStore } from '@/stores/bulletin'
|
||||||
|
import { BulletinPriorityMap } from '@/types'
|
||||||
|
import type { BulletinPost } from '@/types'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
viewAll: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const bulletinStore = useBulletinStore()
|
||||||
|
|
||||||
|
const hasPinned = computed(() => bulletinStore.pinnedPosts.length > 0)
|
||||||
|
|
||||||
|
function handleClick(post: BulletinPost) {
|
||||||
|
bulletinStore.markAsRead(post.id)
|
||||||
|
emit('viewAll')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="hasPinned" class="bulletin-pinned">
|
||||||
|
<div
|
||||||
|
v-for="post in bulletinStore.pinnedPosts"
|
||||||
|
:key="post.id"
|
||||||
|
class="pinned-item"
|
||||||
|
:class="{ 'pinned-item--urgent': post.priority === 'urgent' }"
|
||||||
|
@click="handleClick(post)"
|
||||||
|
>
|
||||||
|
<span class="pinned-item__dot" />
|
||||||
|
<span class="pinned-item__priority">{{ BulletinPriorityMap[post.priority] }}</span>
|
||||||
|
<span class="pinned-item__title">{{ post.title }}</span>
|
||||||
|
<span v-if="!bulletinStore.isRead(post.id)" class="pinned-item__unread" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-pinned {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-left: 3px solid var(--gg-primary);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item--urgent {
|
||||||
|
border-left-color: var(--gg-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item--urgent:hover {
|
||||||
|
box-shadow: 0 0 16px rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item--urgent .pinned-item__dot {
|
||||||
|
background: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item__priority {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item--urgent .pinned-item__priority {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned-item__unread {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-danger);
|
||||||
|
box-shadow: 0 0 6px var(--gg-danger);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { BulletinPost } from '@/types'
|
||||||
|
import { displayName, BulletinPriorityMap, BulletinPriorityColor } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
post: BulletinPost
|
||||||
|
isRead: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: []
|
||||||
|
edit: []
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const priorityLabel = computed(() => BulletinPriorityMap[props.post.priority])
|
||||||
|
const priorityColor = computed(() => BulletinPriorityColor[props.post.priority])
|
||||||
|
|
||||||
|
const creatorName = computed(() =>
|
||||||
|
displayName(props.post.expand?.creator)
|
||||||
|
)
|
||||||
|
|
||||||
|
const creatorAvatar = computed(() =>
|
||||||
|
props.post.expand?.creator?.avatar || '/default-avatar.svg'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 内容摘要(最多3行)
|
||||||
|
const contentSummary = computed(() => {
|
||||||
|
const text = props.post.content.replace(/<[^>]+>/g, ' ').trim()
|
||||||
|
return text.length > 120 ? text.slice(0, 120) + '...' : text
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const timeText = computed(() => {
|
||||||
|
const d = new Date(props.post.created)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60))
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
|
if (days > 0) return `${days}天前`
|
||||||
|
if (hours > 0) return `${hours}小时前`
|
||||||
|
if (minutes > 0) return `${minutes}分钟前`
|
||||||
|
return '刚刚'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isExpired = computed(() => {
|
||||||
|
if (!props.post.expiresAt) return false
|
||||||
|
return new Date(props.post.expiresAt) <= new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleEdit(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('delete')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="bulletin-card"
|
||||||
|
:class="{
|
||||||
|
'bulletin-card--pinned': post.pinned,
|
||||||
|
'bulletin-card--urgent': post.priority === 'urgent',
|
||||||
|
'bulletin-card--expired': isExpired,
|
||||||
|
'bulletin-card--unread': !isRead,
|
||||||
|
}"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<!-- 未读标记 -->
|
||||||
|
<span v-if="!isRead" class="bulletin-card__unread-dot" />
|
||||||
|
|
||||||
|
<!-- 顶部标签 -->
|
||||||
|
<div class="bulletin-card__tags">
|
||||||
|
<span
|
||||||
|
class="bulletin-card__priority-tag"
|
||||||
|
:style="{ background: priorityColor + '18', color: priorityColor }"
|
||||||
|
>
|
||||||
|
{{ priorityLabel }}
|
||||||
|
</span>
|
||||||
|
<span v-if="post.pinned" class="bulletin-card__pinned-tag">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="12" height="12">
|
||||||
|
<path d="M12 2l3 7h7l-5.5 4 2 7-6.5-5-6.5 5 2-7L2 9h7z" />
|
||||||
|
</svg>
|
||||||
|
置顶
|
||||||
|
</span>
|
||||||
|
<span v-if="isExpired" class="bulletin-card__expired-tag">已过期</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="bulletin-card__title">{{ post.title }}</div>
|
||||||
|
|
||||||
|
<!-- 内容摘要 -->
|
||||||
|
<div class="bulletin-card__content" v-html="contentSummary" />
|
||||||
|
|
||||||
|
<!-- 底部信息 -->
|
||||||
|
<div class="bulletin-card__footer">
|
||||||
|
<div class="bulletin-card__author">
|
||||||
|
<img :src="creatorAvatar" :alt="creatorName" class="bulletin-card__avatar" />
|
||||||
|
<span class="bulletin-card__author-name">{{ creatorName }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="bulletin-card__time">{{ timeText }}</span>
|
||||||
|
<div class="bulletin-card__actions" @click.stop>
|
||||||
|
<button class="bulletin-card__action-btn" @click="handleEdit">编辑</button>
|
||||||
|
<button class="bulletin-card__action-btn bulletin-card__action-btn--danger" @click="handleDelete">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bulletin-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 16px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card--pinned {
|
||||||
|
border-left: 3px solid var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card--urgent {
|
||||||
|
border-left: 3px solid var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card--urgent:hover {
|
||||||
|
box-shadow: 0 0 20px rgba(239, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card--expired {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 未读标记 */
|
||||||
|
.bulletin-card__unread-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-danger);
|
||||||
|
box-shadow: 0 0 6px var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签 */
|
||||||
|
.bulletin-card__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__priority-tag,
|
||||||
|
.bulletin-card__pinned-tag,
|
||||||
|
.bulletin-card__expired-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__pinned-tag {
|
||||||
|
background: rgba(5, 150, 105, 0.1);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__expired-tag {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题 */
|
||||||
|
.bulletin-card__title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容 */
|
||||||
|
.bulletin-card__content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部 */
|
||||||
|
.bulletin-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__author {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__avatar {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__author-name {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card:hover .bulletin-card__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__action-btn {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__action-btn:hover {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulletin-card__action-btn--danger:hover {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.bulletin-card__actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useBulletinStore } from '@/stores/bulletin'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import type { BulletinPriority } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
editPost?: {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
priority: BulletinPriority
|
||||||
|
pinned: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
} | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: []
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const bulletinStore = useBulletinStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.editPost)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
priority: 'normal' as BulletinPriority,
|
||||||
|
pinned: false,
|
||||||
|
expiresAt: '' as string | Date,
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const priorityOptions: { label: string; value: BulletinPriority }[] = [
|
||||||
|
{ label: '低', value: 'low' },
|
||||||
|
{ label: '普通', value: 'normal' },
|
||||||
|
{ label: '高', value: 'high' },
|
||||||
|
{ label: '紧急', value: 'urgent' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
if (props.editPost) {
|
||||||
|
form.value = {
|
||||||
|
title: props.editPost.title,
|
||||||
|
content: props.editPost.content,
|
||||||
|
priority: props.editPost.priority,
|
||||||
|
pinned: props.editPost.pinned,
|
||||||
|
expiresAt: props.editPost.expiresAt || '',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
form.value = {
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
priority: 'normal',
|
||||||
|
pinned: false,
|
||||||
|
expiresAt: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
ElMessage.warning('请输入公告标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.content.trim()) {
|
||||||
|
ElMessage.warning('请输入公告内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) {
|
||||||
|
ElMessage.error('请先选择群组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
content: form.value.content.trim(),
|
||||||
|
priority: form.value.priority,
|
||||||
|
pinned: form.value.pinned,
|
||||||
|
expiresAt: form.value.expiresAt
|
||||||
|
? new Date(String(form.value.expiresAt)).toISOString()
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value && props.editPost) {
|
||||||
|
await bulletinStore.update(props.editPost.id, data)
|
||||||
|
ElMessage.success('公告更新成功')
|
||||||
|
emit('updated')
|
||||||
|
} else {
|
||||||
|
await bulletinStore.create({ group: groupId, ...data })
|
||||||
|
ElMessage.success('公告发布成功')
|
||||||
|
emit('created')
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="isEdit ? '编辑公告' : '发布公告'"
|
||||||
|
width="520px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<div class="create-form">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>标题 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.title"
|
||||||
|
placeholder="请输入公告标题"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>内容 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="6"
|
||||||
|
placeholder="支持 HTML 标签(如 <b>加粗</b>、<i>斜体</i>、<a href='...'>链接</a>)"
|
||||||
|
maxlength="5000"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 优先级 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>优先级</label>
|
||||||
|
<el-select v-model="form.priority" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="opt in priorityOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 置顶开关 -->
|
||||||
|
<div class="form-field form-field--inline">
|
||||||
|
<label>置顶显示</label>
|
||||||
|
<el-switch v-model="form.pinned" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过期时间 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>过期时间(可选)</label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.expiresAt"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择过期时间"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||||
|
{{ loading ? '提交中...' : (isEdit ? '保存' : '发布') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field--inline {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useEventStore } from '@/stores/event'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
groupId: string
|
||||||
|
editEvent?: { id: string; title: string; description?: string; location?: string; startTime: string; endTime?: string; maxParticipants?: number } | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
created: []
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const location = ref('')
|
||||||
|
const startTime = ref('')
|
||||||
|
const endTime = ref('')
|
||||||
|
const maxParticipants = ref<number | undefined>(undefined)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.editEvent)
|
||||||
|
|
||||||
|
watch(() => props.editEvent, (evt) => {
|
||||||
|
if (evt) {
|
||||||
|
title.value = evt.title
|
||||||
|
description.value = evt.description || ''
|
||||||
|
location.value = evt.location || ''
|
||||||
|
startTime.value = formatForInput(evt.startTime)
|
||||||
|
endTime.value = evt.endTime ? formatForInput(evt.endTime) : ''
|
||||||
|
maxParticipants.value = evt.maxParticipants
|
||||||
|
} else {
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function formatForInput(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
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())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
title.value = ''
|
||||||
|
description.value = ''
|
||||||
|
location.value = ''
|
||||||
|
startTime.value = ''
|
||||||
|
endTime.value = ''
|
||||||
|
maxParticipants.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!title.value.trim()) {
|
||||||
|
ElMessage.warning('请输入活动标题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!startTime.value) {
|
||||||
|
ElMessage.warning('请选择开始时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (endTime.value && new Date(endTime.value) <= new Date(startTime.value)) {
|
||||||
|
ElMessage.warning('结束时间必须晚于开始时间')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
group: props.groupId,
|
||||||
|
title: title.value.trim(),
|
||||||
|
description: description.value.trim() || undefined,
|
||||||
|
location: location.value.trim() || undefined,
|
||||||
|
startTime: new Date(startTime.value).toISOString(),
|
||||||
|
endTime: endTime.value ? new Date(endTime.value).toISOString() : undefined,
|
||||||
|
maxParticipants: maxParticipants.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value && props.editEvent) {
|
||||||
|
await eventStore.editEvent(props.editEvent.id, data)
|
||||||
|
ElMessage.success('活动已更新')
|
||||||
|
emit('updated')
|
||||||
|
} else {
|
||||||
|
await eventStore.addEvent(data)
|
||||||
|
ElMessage.success('活动创建成功')
|
||||||
|
emit('created')
|
||||||
|
}
|
||||||
|
visible.value = false
|
||||||
|
reset()
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
if (!isEdit.value) reset()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="isEdit ? '编辑活动' : '发起活动'"
|
||||||
|
width="520px"
|
||||||
|
@close="onClose"
|
||||||
|
>
|
||||||
|
<div class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label>活动标题 <span class="required">*</span></label>
|
||||||
|
<el-input v-model="title" placeholder="例如:周末面基开黑" maxlength="200" show-word-limit />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>活动地点</label>
|
||||||
|
<el-input v-model="location" placeholder="例如:XX 网咖 / XX 餐厅" maxlength="200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field field--half">
|
||||||
|
<label>开始时间 <span class="required">*</span></label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="startTime"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择开始时间"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field field--half">
|
||||||
|
<label>结束时间</label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="endTime"
|
||||||
|
type="datetime"
|
||||||
|
placeholder="选择结束时间(可选)"
|
||||||
|
format="YYYY-MM-DD HH:mm"
|
||||||
|
value-format="YYYY-MM-DDTHH:mm"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>人数上限</label>
|
||||||
|
<el-input-number v-model="maxParticipants" :min="1" :max="999" placeholder="不限" style="width: 100%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>活动详情</label>
|
||||||
|
<el-input
|
||||||
|
v-model="description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="描述一下活动内容..."
|
||||||
|
maxlength="2000"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field--half {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useEventStore } from '@/stores/event'
|
||||||
|
import { EventStatusMap, EventStatusColor, RSVPTypeMap, displayName } from '@/types'
|
||||||
|
import type { Event, RSVPType } from '@/types'
|
||||||
|
import { Calendar, Location, User, ChatDotRound } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
event: Event
|
||||||
|
expanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: [event: Event]
|
||||||
|
rsvp: [eventId: string, type: RSVPType]
|
||||||
|
comment: [eventId: string, content: string]
|
||||||
|
delete: [event: Event]
|
||||||
|
edit: [event: Event]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
|
||||||
|
const commentInput = ref('')
|
||||||
|
|
||||||
|
const canManage = computed(() => {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
return groupStore.isGroupOwner ||
|
||||||
|
(props.event.creator === userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const canDelete = computed(() => {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
return groupStore.isGroupOwner ||
|
||||||
|
(props.event.creator === userId)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(event: Event): string {
|
||||||
|
if (event.status === 'completed') return '已结束'
|
||||||
|
if (event.status === 'cancelled') return '已取消'
|
||||||
|
if (event.status === 'ongoing') return '进行中'
|
||||||
|
const diff = new Date(event.startTime).getTime() - Date.now()
|
||||||
|
const days = Math.ceil(diff / (1000 * 60 * 60 * 24))
|
||||||
|
if (days <= 0) return '今天'
|
||||||
|
if (days === 1) return '明天'
|
||||||
|
return `${days} 天后`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRSVPCounts(eventId: string) {
|
||||||
|
return {
|
||||||
|
going: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'going').length,
|
||||||
|
interested: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'interested').length,
|
||||||
|
maybe: eventStore.rsvps.filter(r => r.event === eventId && r.type === 'maybe').length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMyRSVP(eventId: string) {
|
||||||
|
const userId = pb.authStore.model?.id
|
||||||
|
return eventStore.rsvps.find(r => r.event === eventId && r.user === userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = computed(() => getRSVPCounts(props.event.id))
|
||||||
|
const myRSVP = computed(() => getMyRSVP(props.event.id))
|
||||||
|
const comments = computed(() =>
|
||||||
|
eventStore.comments.filter(c => c.event === props.event.id)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="event-card">
|
||||||
|
<div class="event-card-header" @click="emit('toggle', event)">
|
||||||
|
<div class="event-main">
|
||||||
|
<h3 class="event-title">{{ event.title }}</h3>
|
||||||
|
<span
|
||||||
|
class="event-status-tag"
|
||||||
|
:style="{ background: EventStatusColor[event.status] + '20', color: EventStatusColor[event.status] }"
|
||||||
|
>
|
||||||
|
{{ EventStatusMap[event.status] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="event-relative">{{ formatRelative(event) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-info">
|
||||||
|
<span class="info-item">
|
||||||
|
<el-icon><Calendar /></el-icon>
|
||||||
|
{{ formatDateTime(event.startTime) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="event.location" class="info-item">
|
||||||
|
<el-icon><Location /></el-icon>
|
||||||
|
{{ event.location }}
|
||||||
|
</span>
|
||||||
|
<span class="info-item">
|
||||||
|
<el-icon><User /></el-icon>
|
||||||
|
{{ counts.going }} 参加 · {{ counts.interested }} 感兴趣
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="expanded" class="event-expanded">
|
||||||
|
<p v-if="event.description" class="event-desc">{{ event.description }}</p>
|
||||||
|
|
||||||
|
<div class="rsvp-section">
|
||||||
|
<span class="rsvp-label">我要参加:</span>
|
||||||
|
<div class="rsvp-buttons">
|
||||||
|
<button
|
||||||
|
v-for="type in (['going', 'interested', 'maybe'] as RSVPType[])"
|
||||||
|
:key="type"
|
||||||
|
class="rsvp-btn"
|
||||||
|
:class="{ 'rsvp-btn--active': myRSVP?.type === type }"
|
||||||
|
@click.stop="emit('rsvp', event.id, type)"
|
||||||
|
>
|
||||||
|
{{ RSVPTypeMap[type] }} {{ counts[type] }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<div class="comments-header">
|
||||||
|
<el-icon><ChatDotRound /></el-icon>
|
||||||
|
评论 ({{ comments.length }})
|
||||||
|
</div>
|
||||||
|
<p v-if="comments.length === 0" class="no-comments">暂无评论,来说两句吧</p>
|
||||||
|
<div v-else class="comments-list">
|
||||||
|
<div v-for="c in comments" :key="c.id" class="comment-item">
|
||||||
|
<img :src="c.expand?.user?.avatar ? `${pb.baseUrl}/api/files/users/${c.user}/${c.expand.user.avatar}` : '/default-avatar.svg'" class="comment-avatar" />
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-author">{{ displayName(c.expand?.user) }}</span>
|
||||||
|
<span class="comment-time">{{ formatDateTime(c.created) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="comment-text">{{ c.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-input-row">
|
||||||
|
<input
|
||||||
|
v-model="commentInput"
|
||||||
|
class="comment-input"
|
||||||
|
placeholder="写个评论..."
|
||||||
|
@keyup.enter="() => { emit('comment', event.id, commentInput); commentInput = '' }"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="comment-send-btn"
|
||||||
|
@click.stop="() => { emit('comment', event.id, commentInput); commentInput = '' }"
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="canManage || canDelete" class="event-actions">
|
||||||
|
<button v-if="canManage" class="action-link" @click.stop="emit('edit', event)">编辑</button>
|
||||||
|
<button v-if="canDelete" class="action-link action-link--danger" @click.stop="emit('delete', event)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-status-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-relative {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 18px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-expanded {
|
||||||
|
padding: 0 18px 16px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-top: 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-bg);
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn:hover {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rsvp-btn--active {
|
||||||
|
background: var(--gg-primary);
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-comments {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-bg);
|
||||||
|
color: var(--gg-text);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input:focus {
|
||||||
|
border-color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-send-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
color: white;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-send-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-link--danger {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { useEventStore } from '@/stores/event'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import type { Event } from '@/types'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import CreateEventDialog from './CreateEventDialog.vue'
|
||||||
|
import EventCard from './EventCard.vue'
|
||||||
|
import { subscribeEvents, subscribeEventComments, subscribeEventRSVPs } from '@/api/events'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const eventStore = useEventStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const groupId = route.params.id as string
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const editingEvent = ref<Event | null>(null)
|
||||||
|
const expandedEventId = ref<string | null>(null)
|
||||||
|
|
||||||
|
const isMember = computed(() => !!groupStore.currentGroup)
|
||||||
|
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
let unsubscribeCommentsFn: (() => void) | null = null
|
||||||
|
let unsubscribeRSVPsFn: (() => void) | null = null
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await eventStore.loadEvents(groupId)
|
||||||
|
unsubscribeFn = await subscribeEvents(groupId, () => {
|
||||||
|
eventStore.loadEvents(groupId)
|
||||||
|
if (expandedEventId.value) {
|
||||||
|
eventStore.loadCommentsAndRSVPs(expandedEventId.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubscribeFn) unsubscribeFn()
|
||||||
|
if (unsubscribeCommentsFn) unsubscribeCommentsFn()
|
||||||
|
if (unsubscribeRSVPsFn) unsubscribeRSVPsFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
unsubscribeCommentsFn = null
|
||||||
|
unsubscribeRSVPsFn = null
|
||||||
|
eventStore.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function toggleExpand(event: Event) {
|
||||||
|
if (expandedEventId.value === event.id) {
|
||||||
|
expandedEventId.value = null
|
||||||
|
if (unsubscribeCommentsFn) { unsubscribeCommentsFn(); unsubscribeCommentsFn = null }
|
||||||
|
if (unsubscribeRSVPsFn) { unsubscribeRSVPsFn(); unsubscribeRSVPsFn = null }
|
||||||
|
} else {
|
||||||
|
expandedEventId.value = event.id
|
||||||
|
await eventStore.loadCommentsAndRSVPs(event.id)
|
||||||
|
if (unsubscribeCommentsFn) unsubscribeCommentsFn()
|
||||||
|
if (unsubscribeRSVPsFn) unsubscribeRSVPsFn()
|
||||||
|
unsubscribeCommentsFn = await subscribeEventComments(event.id, () => {
|
||||||
|
eventStore.loadCommentsAndRSVPs(event.id)
|
||||||
|
})
|
||||||
|
unsubscribeRSVPsFn = await subscribeEventRSVPs(event.id, () => {
|
||||||
|
eventStore.loadCommentsAndRSVPs(event.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRSVP(eventId: string, type: 'going' | 'interested' | 'maybe') {
|
||||||
|
const current = eventStore.rsvps.find(r => r.event === eventId && r.user === pb.authStore.model?.id)
|
||||||
|
if (current?.type === type) {
|
||||||
|
await eventStore.cancelRSVP(eventId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await eventStore.rsvp(eventId, type)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComment(eventId: string, content: string) {
|
||||||
|
const text = content.trim()
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
await eventStore.addComment(eventId, text)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '评论失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(event: Event) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定要删除这个活动吗?', '确认删除', { type: 'warning' })
|
||||||
|
await eventStore.removeEvent(event.id)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
} catch {
|
||||||
|
// 取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(event: Event) {
|
||||||
|
editingEvent.value = event
|
||||||
|
showCreateDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDialogClosed() {
|
||||||
|
editingEvent.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="event-list">
|
||||||
|
<div class="list-header">
|
||||||
|
<h2 class="list-title">群组活动</h2>
|
||||||
|
<button v-if="isMember" class="create-btn" @click="showCreateDialog = true">
|
||||||
|
<el-icon><Plus /></el-icon> 发起活动
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="eventStore.loading && eventStore.events.length === 0" class="loading-state">
|
||||||
|
<div class="loading-spinner" />
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="eventStore.events.length === 0" class="empty-state">
|
||||||
|
<p>暂无活动</p>
|
||||||
|
<p v-if="isMember" class="empty-hint">点击右上角发起一个活动吧</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="event-sections">
|
||||||
|
<div v-if="eventStore.upcomingEvents.length > 0" class="section">
|
||||||
|
<h3 class="section-label">即将开始</h3>
|
||||||
|
<div class="event-cards">
|
||||||
|
<EventCard
|
||||||
|
v-for="event in eventStore.upcomingEvents"
|
||||||
|
:key="event.id"
|
||||||
|
:event="event"
|
||||||
|
:expanded="expandedEventId === event.id"
|
||||||
|
@toggle="toggleExpand"
|
||||||
|
@rsvp="handleRSVP"
|
||||||
|
@comment="handleComment"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@edit="handleEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="eventStore.pastEvents.length > 0" class="section">
|
||||||
|
<h3 class="section-label">历史活动</h3>
|
||||||
|
<div class="event-cards">
|
||||||
|
<EventCard
|
||||||
|
v-for="event in eventStore.pastEvents"
|
||||||
|
:key="event.id"
|
||||||
|
:event="event"
|
||||||
|
:expanded="expandedEventId === event.id"
|
||||||
|
@toggle="toggleExpand"
|
||||||
|
@rsvp="handleRSVP"
|
||||||
|
@comment="handleComment"
|
||||||
|
@delete="handleDelete"
|
||||||
|
@edit="handleEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateEventDialog
|
||||||
|
v-model="showCreateDialog"
|
||||||
|
:group-id="groupId"
|
||||||
|
:edit-event="editingEvent"
|
||||||
|
@created="eventStore.loadEvents(groupId)"
|
||||||
|
@updated="eventStore.loadEvents(groupId)"
|
||||||
|
@update:model-value="onDialogClosed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid var(--gg-border);
|
||||||
|
border-top-color: var(--gg-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-sections {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,8 @@ import { addGame } from '@/api/games'
|
|||||||
import type { GamePlatform } from '@/types'
|
import type { GamePlatform } from '@/types'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getAllPlatforms } from '@/api/games'
|
import { getAllPlatforms } from '@/api/games'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -23,10 +25,24 @@ const visible = computed({
|
|||||||
const name = ref('')
|
const name = ref('')
|
||||||
const platform = ref<GamePlatform | ''>('')
|
const platform = ref<GamePlatform | ''>('')
|
||||||
const tagsInput = ref('')
|
const tagsInput = ref('')
|
||||||
const cover = ref('')
|
const coverFile = ref<File | null>(null)
|
||||||
|
const coverPreview = ref('')
|
||||||
|
const uploadRef = ref<UploadInstance>()
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const platforms = getAllPlatforms()
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
function handleFileChange(uploadFile: UploadFile) {
|
||||||
|
if (uploadFile.raw) {
|
||||||
|
coverFile.value = uploadFile.raw
|
||||||
|
coverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileRemove() {
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (!name.value.trim()) {
|
if (!name.value.trim()) {
|
||||||
ElMessage.warning('请输入游戏名称')
|
ElMessage.warning('请输入游戏名称')
|
||||||
@@ -39,13 +55,15 @@ async function handleSubmit() {
|
|||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
platform: platform.value || undefined,
|
platform: platform.value || undefined,
|
||||||
tags: tags.length > 0 ? tags : undefined,
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
cover: cover.value.trim() || undefined
|
coverFile: coverFile.value || undefined
|
||||||
})
|
})
|
||||||
ElMessage.success('添加成功')
|
ElMessage.success('添加成功')
|
||||||
name.value = ''
|
name.value = ''
|
||||||
platform.value = ''
|
platform.value = ''
|
||||||
tagsInput.value = ''
|
tagsInput.value = ''
|
||||||
cover.value = ''
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
emit('created')
|
emit('created')
|
||||||
visible.value = false
|
visible.value = false
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -74,8 +92,24 @@ async function handleSubmit() {
|
|||||||
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>封面图 URL</label>
|
<label>封面图</label>
|
||||||
<el-input v-model="cover" placeholder="https://example.com/cover.jpg" />
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
accept="image/*"
|
||||||
|
:on-change="handleFileChange"
|
||||||
|
:on-remove="handleFileRemove"
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<div v-if="coverPreview" class="cover-preview">
|
||||||
|
<img :src="coverPreview" alt="封面预览" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-placeholder">
|
||||||
|
<el-icon :size="28"><Plus /></el-icon>
|
||||||
|
<span>点击或拖拽上传封面</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -89,4 +123,19 @@ async function handleSubmit() {
|
|||||||
.form-fields { display: flex; flex-direction: column; gap: 18px; }
|
.form-fields { display: flex; flex-direction: column; gap: 18px; }
|
||||||
.field { display: flex; flex-direction: column; gap: 6px; }
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
||||||
|
.cover-preview img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
import type { Game } from '@/types'
|
import type { Game, GamePlatform } from '@/types'
|
||||||
import { toggleFavorite, isFavorite, deleteGame } from '@/api/games'
|
import { toggleFavorite, isFavorite, deleteGame, updateGame, getGameCoverUrl, getAllPlatforms } from '@/api/games'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { TrendCharts, StarFilled, Star, Delete } from '@element-plus/icons-vue'
|
import { TrendCharts, StarFilled, Star, Delete, Edit, Plus } from '@element-plus/icons-vue'
|
||||||
|
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||||
import GameComments from './GameComments.vue'
|
import GameComments from './GameComments.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -18,12 +21,40 @@ const emit = defineEmits<{
|
|||||||
'deleted': []
|
'deleted': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (val) => emit('update:modelValue', val)
|
set: (val) => emit('update:modelValue', val)
|
||||||
})
|
})
|
||||||
|
|
||||||
const favorited = ref(false)
|
const favorited = ref(false)
|
||||||
|
const editing = ref(false)
|
||||||
|
const editName = ref('')
|
||||||
|
const editAliases = ref('')
|
||||||
|
const editPlatform = ref<GamePlatform | ''>('')
|
||||||
|
const editTags = ref('')
|
||||||
|
const editCoverFile = ref<File | null>(null)
|
||||||
|
const editCoverPreview = ref('')
|
||||||
|
const uploadRef = ref<UploadInstance>()
|
||||||
|
const saving = 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.modelValue, (val) => {
|
||||||
|
if (val) {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (props.game) {
|
if (props.game) {
|
||||||
@@ -31,6 +62,59 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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(', ')
|
||||||
|
editCoverFile.value = null
|
||||||
|
editCoverPreview.value = getGameCoverUrl(props.game) || ''
|
||||||
|
editing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditFileChange(uploadFile: UploadFile) {
|
||||||
|
if (uploadFile.raw) {
|
||||||
|
editCoverFile.value = uploadFile.raw
|
||||||
|
editCoverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditFileRemove() {
|
||||||
|
editCoverFile.value = null
|
||||||
|
editCoverPreview.value = getGameCoverUrl(props.game!) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!props.game || !editName.value.trim()) {
|
||||||
|
ElMessage.warning('请输入游戏名称')
|
||||||
|
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,
|
||||||
|
coverFile: editCoverFile.value || undefined
|
||||||
|
})
|
||||||
|
ElMessage.success('修改成功')
|
||||||
|
editing.value = false
|
||||||
|
emit('deleted') // trigger reload
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '修改失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleFavorite() {
|
async function handleFavorite() {
|
||||||
if (!props.game) return
|
if (!props.game) return
|
||||||
favorited.value = await toggleFavorite(props.game.id)
|
favorited.value = await toggleFavorite(props.game.id)
|
||||||
@@ -60,13 +144,66 @@ function handleCreateTeam() {
|
|||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="visible" width="480px" :show-close="true">
|
<el-dialog v-model="visible" width="480px" :show-close="true">
|
||||||
<template v-if="game">
|
<template v-if="game">
|
||||||
<div class="game-detail">
|
<!-- 编辑模式 -->
|
||||||
|
<div v-if="editing" class="game-detail">
|
||||||
|
<div class="form-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>游戏名称 *</label>
|
||||||
|
<el-input v-model="editName" placeholder="输入游戏名称" maxlength="100" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>别名</label>
|
||||||
|
<el-input v-model="editAliases" placeholder="逗号分隔,如: LOL,英雄联盟,撸啊撸" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>平台</label>
|
||||||
|
<el-select v-model="editPlatform" placeholder="选择平台" clearable style="width: 100%">
|
||||||
|
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>标签</label>
|
||||||
|
<el-input v-model="editTags" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>封面图</label>
|
||||||
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
accept="image/*"
|
||||||
|
:on-change="handleEditFileChange"
|
||||||
|
:on-remove="handleEditFileRemove"
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<div v-if="editCoverPreview" class="cover-preview">
|
||||||
|
<img :src="editCoverPreview" alt="封面预览" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-placeholder">
|
||||||
|
<el-icon :size="28"><Plus /></el-icon>
|
||||||
|
<span>点击或拖拽上传封面</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<el-button @click="cancelEdit">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情模式 -->
|
||||||
|
<div v-else class="game-detail">
|
||||||
<div class="detail-cover-wrap">
|
<div class="detail-cover-wrap">
|
||||||
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
|
<img :src="getGameCoverUrl(game) || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="detail-name">{{ game.name }}</h2>
|
<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">
|
<div class="detail-meta">
|
||||||
<span v-if="game.platform" class="platform-badge">{{ game.platform }}</span>
|
<span v-if="game.platform" class="platform-badge">{{ game.platform }}</span>
|
||||||
<span class="popularity"><el-icon><TrendCharts /></el-icon> {{ game.popularCount }} 人游玩</span>
|
<span class="popularity"><el-icon><TrendCharts /></el-icon> {{ game.popularCount }} 人游玩</span>
|
||||||
@@ -81,7 +218,8 @@ function handleCreateTeam() {
|
|||||||
<el-icon><StarFilled v-if="favorited" /><Star v-else /></el-icon>
|
<el-icon><StarFilled v-if="favorited" /><Star v-else /></el-icon>
|
||||||
{{ favorited ? '已收藏' : '收藏' }}
|
{{ favorited ? '已收藏' : '收藏' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
|
<button v-if="canModify" class="edit-btn" @click="startEdit"><el-icon><Edit /></el-icon> 编辑</button>
|
||||||
|
<button v-if="canModify" class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
|
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
|
||||||
@@ -102,6 +240,9 @@ function handleCreateTeam() {
|
|||||||
|
|
||||||
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
|
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
|
||||||
|
|
||||||
|
.detail-aliases { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
||||||
|
.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; }
|
.detail-meta { display: flex; align-items: center; gap: 12px; }
|
||||||
.platform-badge { padding: 4px 14px; background: rgba(5, 150, 105, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
|
.platform-badge { padding: 4px 14px; background: rgba(5, 150, 105, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
|
||||||
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
|
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
|
||||||
@@ -117,6 +258,12 @@ function handleCreateTeam() {
|
|||||||
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
|
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
|
||||||
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
|
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||||
|
background: transparent; color: var(--gg-text-secondary); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.edit-btn:hover { border-color: var(--gg-primary); color: var(--gg-primary); }
|
||||||
|
|
||||||
.delete-detail-btn {
|
.delete-detail-btn {
|
||||||
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||||
background: transparent; color: var(--gg-text-muted); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
background: transparent; color: var(--gg-text-muted); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
||||||
@@ -124,4 +271,15 @@ function handleCreateTeam() {
|
|||||||
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
|
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
|
||||||
|
|
||||||
.team-btn { width: 100%; margin-top: 4px; }
|
.team-btn { width: 100%; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* 编辑表单 */
|
||||||
|
.form-fields { display: flex; flex-direction: column; gap: 16px; width: 100%; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
||||||
|
.cover-preview img { width: 100%; max-height: 200px; object-fit: contain; border-radius: 6px; }
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 20px;
|
||||||
|
color: var(--gg-text-muted); font-size: 13px;
|
||||||
|
}
|
||||||
|
.edit-actions { display: flex; gap: 12px; width: 100%; justify-content: flex-end; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { addGame, updateGame, getGameCoverUrl } from '@/api/games'
|
||||||
|
import type { Game, GamePlatform } from '@/types'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getAllPlatforms } from '@/api/games'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import type { UploadFile, UploadInstance } from 'element-plus'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
groupId: string
|
||||||
|
game?: Game | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.game)
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const aliasesInput = ref('')
|
||||||
|
const platform = ref<GamePlatform | ''>('')
|
||||||
|
const tagsInput = ref('')
|
||||||
|
const coverFile = ref<File | null>(null)
|
||||||
|
const coverPreview = ref('')
|
||||||
|
const uploadRef = ref<UploadInstance>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
if (val && props.game) {
|
||||||
|
name.value = props.game.name
|
||||||
|
aliasesInput.value = (props.game.aliases || []).join(', ')
|
||||||
|
platform.value = props.game.platform || ''
|
||||||
|
tagsInput.value = (props.game.tags || []).join(', ')
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = getGameCoverUrl(props.game) || ''
|
||||||
|
} else if (val) {
|
||||||
|
name.value = ''
|
||||||
|
aliasesInput.value = ''
|
||||||
|
platform.value = ''
|
||||||
|
tagsInput.value = ''
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleFileChange(uploadFile: UploadFile) {
|
||||||
|
if (uploadFile.raw) {
|
||||||
|
coverFile.value = uploadFile.raw
|
||||||
|
coverPreview.value = URL.createObjectURL(uploadFile.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileRemove() {
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
ElMessage.warning('请输入游戏名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const aliases = aliasesInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
|
||||||
|
if (isEdit.value && props.game) {
|
||||||
|
await updateGame(props.game.id, {
|
||||||
|
name: name.value.trim(),
|
||||||
|
aliases: aliases.length > 0 ? aliases : undefined,
|
||||||
|
platform: platform.value || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
coverFile: coverFile.value || undefined
|
||||||
|
})
|
||||||
|
ElMessage.success('修改成功')
|
||||||
|
} else {
|
||||||
|
await addGame(props.groupId, {
|
||||||
|
name: name.value.trim(),
|
||||||
|
aliases: aliases.length > 0 ? aliases : undefined,
|
||||||
|
platform: platform.value || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
coverFile: coverFile.value || undefined
|
||||||
|
})
|
||||||
|
ElMessage.success('添加成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
name.value = ''
|
||||||
|
aliasesInput.value = ''
|
||||||
|
platform.value = ''
|
||||||
|
tagsInput.value = ''
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
uploadRef.value?.clearFiles()
|
||||||
|
emit('saved')
|
||||||
|
visible.value = false
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || (isEdit.value ? '修改失败' : '添加失败'))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog v-model="visible" :title="isEdit ? '编辑游戏' : '添加游戏'" width="440px">
|
||||||
|
<div class="form-fields">
|
||||||
|
<div class="field">
|
||||||
|
<label>游戏名称 *</label>
|
||||||
|
<el-input v-model="name" placeholder="输入游戏名称" maxlength="100" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>别名</label>
|
||||||
|
<el-input v-model="aliasesInput" placeholder="逗号分隔,如: LOL,英雄联盟,撸啊撸" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>平台</label>
|
||||||
|
<el-select v-model="platform" placeholder="选择平台" clearable style="width: 100%">
|
||||||
|
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>标签</label>
|
||||||
|
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>封面图</label>
|
||||||
|
<el-upload
|
||||||
|
ref="uploadRef"
|
||||||
|
:auto-upload="false"
|
||||||
|
:limit="1"
|
||||||
|
accept="image/*"
|
||||||
|
:on-change="handleFileChange"
|
||||||
|
:on-remove="handleFileRemove"
|
||||||
|
drag
|
||||||
|
>
|
||||||
|
<div v-if="coverPreview" class="cover-preview">
|
||||||
|
<img :src="coverPreview" alt="封面预览" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-placeholder">
|
||||||
|
<el-icon :size="28"><Plus /></el-icon>
|
||||||
|
<span>点击或拖拽上传封面</span>
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleSubmit">{{ isEdit ? '保存' : '添加' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-fields { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
|
||||||
|
.cover-preview img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.upload-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -52,7 +52,7 @@ function parseCSV(text: string) {
|
|||||||
const values = lines[i].split(',').map(v => v.trim())
|
const values = lines[i].split(',').map(v => v.trim())
|
||||||
const obj: any = {}
|
const obj: any = {}
|
||||||
headers.forEach((h, idx) => {
|
headers.forEach((h, idx) => {
|
||||||
if (h === 'tags' && values[idx]) {
|
if ((h === 'tags' || h === 'aliases') && values[idx]) {
|
||||||
obj[h] = values[idx].split(';').map((t: string) => t.trim())
|
obj[h] = values[idx].split(';').map((t: string) => t.trim())
|
||||||
} else {
|
} else {
|
||||||
obj[h] = values[idx] || undefined
|
obj[h] = values[idx] || undefined
|
||||||
@@ -97,8 +97,8 @@ function reset() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="format-hint">
|
<div class="format-hint">
|
||||||
<p><strong>JSON 格式:</strong> [{"name":"LOL","platform":"PC","tags":["MOBA"]}]</p>
|
<p><strong>JSON 格式:</strong> [{"name":"英雄联盟","aliases":["LOL","撸啊撸"],"platform":"PC","tags":["MOBA"]}]</p>
|
||||||
<p><strong>CSV 格式:</strong> name,platform,tags,cover(tags 用分号分隔)</p>
|
<p><strong>CSV 格式:</strong> name,aliases,platform,tags,cover(aliases/tags 用分号分隔)</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="previewData.length > 0" class="preview">
|
<div v-if="previewData.length > 0" class="preview">
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { deleteBlacklistEntry } from '@/api/gameBlacklist'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||||
|
import type { BlacklistEntry, BlacklistSeverity } from '@/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
entries: BlacklistEntry[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
deleted: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
// 当前用户 ID
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id)
|
||||||
|
|
||||||
|
// 是否为群主
|
||||||
|
const isOwner = computed(() => groupStore.isGroupOwner)
|
||||||
|
|
||||||
|
// 是否可以删除(举报人本人或群主)
|
||||||
|
function canDelete(entry: BlacklistEntry): boolean {
|
||||||
|
return entry.reporter === currentUserId.value || isOwner.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffDays > 30) {
|
||||||
|
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
|
} else if (diffDays > 0) {
|
||||||
|
return `${diffDays}天前`
|
||||||
|
} else if (diffHours > 0) {
|
||||||
|
return `${diffHours}小时前`
|
||||||
|
} else if (diffMinutes > 0) {
|
||||||
|
return `${diffMinutes}分钟前`
|
||||||
|
}
|
||||||
|
return '刚刚'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取举报人显示名称
|
||||||
|
function reporterName(entry: BlacklistEntry): string {
|
||||||
|
if (entry.expand?.reporter) {
|
||||||
|
return displayName(entry.expand.reporter)
|
||||||
|
}
|
||||||
|
return '未知用户'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 严重程度 CSS class
|
||||||
|
function severityClass(severity: BlacklistSeverity): string {
|
||||||
|
return `entry__severity--${severity}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除记录
|
||||||
|
async function handleDelete(entryId: string) {
|
||||||
|
try {
|
||||||
|
await deleteBlacklistEntry(entryId)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
emit('deleted')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="entry-list">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in entries"
|
||||||
|
:key="entry.id"
|
||||||
|
:class="['entry', { 'entry--bordered': index > 0 }]"
|
||||||
|
>
|
||||||
|
<!-- 顶部:举报人 + 时间 + 删除 -->
|
||||||
|
<div class="entry__header">
|
||||||
|
<div class="entry__reporter">
|
||||||
|
<div class="entry__avatar">
|
||||||
|
{{ reporterName(entry).charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<span class="entry__name">{{ reporterName(entry) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry__header-right">
|
||||||
|
<span class="entry__time">{{ formatTime(entry.created) }}</span>
|
||||||
|
<el-popconfirm
|
||||||
|
v-if="canDelete(entry)"
|
||||||
|
title="确定删除这条黑名单记录?"
|
||||||
|
confirm-button-text="删除"
|
||||||
|
cancel-button-text="取消"
|
||||||
|
@confirm="handleDelete(entry.id)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<button class="entry__delete-btn" title="删除">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="entry__delete-icon">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签行 -->
|
||||||
|
<div class="entry__tags">
|
||||||
|
<span class="entry__reason-tag">
|
||||||
|
{{ BlacklistReasonMap[entry.reason] }}
|
||||||
|
</span>
|
||||||
|
<span class="entry__severity" :class="severityClass(entry.severity)">
|
||||||
|
{{ BlacklistSeverityMap[entry.severity] }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<div class="entry__desc">
|
||||||
|
{{ entry.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.entry-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry--bordered {
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部行 */
|
||||||
|
.entry__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__reporter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__delete-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__delete-btn:hover {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__delete-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签行 */
|
||||||
|
.entry__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__reason-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__severity {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__severity--mild {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__severity--medium {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__severity--severe {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描述 */
|
||||||
|
.entry__desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||||
|
import type { BlacklistEntry, BlacklistSeverity } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
gameName: string
|
||||||
|
entries: BlacklistEntry[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 被标记次数
|
||||||
|
const reportCount = computed(() => props.entries.length)
|
||||||
|
|
||||||
|
// 最新一条记录
|
||||||
|
const latestEntry = computed(() => props.entries[0])
|
||||||
|
|
||||||
|
// 最近标记原因
|
||||||
|
const latestReason = computed(() => {
|
||||||
|
const entry = latestEntry.value
|
||||||
|
if (!entry) return ''
|
||||||
|
return BlacklistReasonMap[entry.reason]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 严重程度排序:severe > medium > mild
|
||||||
|
const severityOrder: Record<BlacklistSeverity, number> = {
|
||||||
|
mild: 1,
|
||||||
|
medium: 2,
|
||||||
|
severe: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 严重程度最高的一条
|
||||||
|
const maxSeverity = computed(() => {
|
||||||
|
let max: BlacklistSeverity = 'mild'
|
||||||
|
for (const entry of props.entries) {
|
||||||
|
if (severityOrder[entry.severity] > severityOrder[max]) {
|
||||||
|
max = entry.severity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxSeverityLabel = computed(() => BlacklistSeverityMap[maxSeverity.value])
|
||||||
|
|
||||||
|
// 严重程度对应的 CSS class
|
||||||
|
const severityClass = computed(() => `game-card__severity--${maxSeverity.value}`)
|
||||||
|
|
||||||
|
// 第一条记录描述预览
|
||||||
|
const previewDesc = computed(() => {
|
||||||
|
const entry = latestEntry.value
|
||||||
|
if (!entry) return ''
|
||||||
|
return entry.description
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="game-card" @click="emit('click')">
|
||||||
|
<!-- 左侧:游戏信息 -->
|
||||||
|
<div class="game-card__info">
|
||||||
|
<div class="game-card__name-row">
|
||||||
|
<span class="game-card__name">{{ gameName }}</span>
|
||||||
|
<span class="game-card__badge">{{ reportCount }} 次标记</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签行 -->
|
||||||
|
<div class="game-card__tags">
|
||||||
|
<span class="game-card__reason-tag">
|
||||||
|
{{ latestReason }}
|
||||||
|
</span>
|
||||||
|
<span class="game-card__severity" :class="severityClass">
|
||||||
|
{{ maxSeverityLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述预览 -->
|
||||||
|
<div v-if="previewDesc" class="game-card__desc">
|
||||||
|
{{ previewDesc }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:展开指示 -->
|
||||||
|
<div class="game-card__arrow">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="game-card__arrow-icon">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.game-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 16px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧信息 */
|
||||||
|
.game-card__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 游戏名 + 次数 */
|
||||||
|
.game-card__name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card__name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card__badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标签行 */
|
||||||
|
.game-card__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card__reason-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card__severity {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card__severity--mild {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card__severity--medium {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card__severity--severe {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 描述预览 */
|
||||||
|
.game-card__desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开箭头 */
|
||||||
|
.game-card__arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card__arrow-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:hover .game-card__arrow-icon {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { listBlacklist, subscribeBlacklist } from '@/api/gameBlacklist'
|
||||||
|
import BlacklistGameCard from './BlacklistGameCard.vue'
|
||||||
|
import BlacklistEntryList from './BlacklistEntryList.vue'
|
||||||
|
import CreateBlacklistDialog from './CreateBlacklistDialog.vue'
|
||||||
|
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||||
|
import type { BlacklistEntry, BlacklistReason, BlacklistSeverity } from '@/types'
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const allEntries = ref<BlacklistEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const expandedGame = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 筛选条件
|
||||||
|
const filterReason = ref<BlacklistReason | ''>('')
|
||||||
|
const filterSeverity = ref<BlacklistSeverity | ''>('')
|
||||||
|
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
|
||||||
|
// 按游戏名聚合
|
||||||
|
const groupedByGame = computed(() => {
|
||||||
|
const map = new Map<string, BlacklistEntry[]>()
|
||||||
|
for (const entry of allEntries.value) {
|
||||||
|
const key = entry.gameName
|
||||||
|
if (!map.has(key)) {
|
||||||
|
map.set(key, [])
|
||||||
|
}
|
||||||
|
map.get(key)!.push(entry)
|
||||||
|
}
|
||||||
|
// 每个游戏内的条目按时间倒序
|
||||||
|
for (const entries of map.values()) {
|
||||||
|
entries.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
// 游戏列表(按被标记次数降序)
|
||||||
|
const gameGroups = computed(() => {
|
||||||
|
const groups: { gameName: string; entries: BlacklistEntry[] }[] = []
|
||||||
|
for (const [gameName, entries] of groupedByGame.value) {
|
||||||
|
groups.push({ gameName, entries })
|
||||||
|
}
|
||||||
|
groups.sort((a, b) => b.entries.length - a.entries.length)
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const options: { reason?: string; severity?: string } = {}
|
||||||
|
if (filterReason.value) options.reason = filterReason.value
|
||||||
|
if (filterSeverity.value) options.severity = filterSeverity.value
|
||||||
|
|
||||||
|
allEntries.value = await listBlacklist(groupId, options)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载黑名单失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时订阅
|
||||||
|
async function startSubscription() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
|
||||||
|
unsubscribeFn = await subscribeBlacklist(groupId, () => {
|
||||||
|
loadEntries()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscription() {
|
||||||
|
if (unsubscribeFn) {
|
||||||
|
unsubscribeFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开/收起某个游戏的详细记录
|
||||||
|
function toggleGame(gameName: string) {
|
||||||
|
expandedGame.value = expandedGame.value === gameName ? null : gameName
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreated() {
|
||||||
|
showCreate.value = false
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
loadEntries()
|
||||||
|
startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
expandedGame.value = null
|
||||||
|
stopSubscription()
|
||||||
|
loadEntries()
|
||||||
|
startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 筛选条件变化时重新加载
|
||||||
|
watch([filterReason, filterSeverity], () => {
|
||||||
|
loadEntries()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopSubscription()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="blacklist-main">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="blacklist-main__header">
|
||||||
|
<h3 class="blacklist-main__title">游戏黑名单</h3>
|
||||||
|
<div class="blacklist-main__actions">
|
||||||
|
<div class="blacklist-main__filters">
|
||||||
|
<el-select
|
||||||
|
v-model="filterReason"
|
||||||
|
placeholder="原因筛选"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="blacklist-main__filter-select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(label, key) in BlacklistReasonMap"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="filterSeverity"
|
||||||
|
placeholder="严重程度"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="blacklist-main__filter-select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(label, key) in BlacklistSeverityMap"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<button class="blacklist-main__create-btn" @click="showCreate = true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-main__create-icon">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
标记游戏
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreateBlacklistDialog
|
||||||
|
v-model="showCreate"
|
||||||
|
@created="handleCreated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 游戏卡片列表 -->
|
||||||
|
<div v-if="gameGroups.length > 0" class="blacklist-main__content">
|
||||||
|
<div v-for="group in gameGroups" :key="group.gameName" class="blacklist-main__game-group">
|
||||||
|
<BlacklistGameCard
|
||||||
|
:game-name="group.gameName"
|
||||||
|
:entries="group.entries"
|
||||||
|
@click="toggleGame(group.gameName)"
|
||||||
|
/>
|
||||||
|
<!-- 展开的详细记录 -->
|
||||||
|
<div v-if="expandedGame === group.gameName" class="blacklist-main__expanded">
|
||||||
|
<BlacklistEntryList :entries="group.entries" @deleted="loadEntries" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-else-if="loading" class="blacklist-main__loading">
|
||||||
|
<p class="blacklist-main__loading-text">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-else class="blacklist-main__empty">
|
||||||
|
<svg class="blacklist-main__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||||
|
</svg>
|
||||||
|
<p class="blacklist-main__empty-text">暂无黑名单记录</p>
|
||||||
|
<p class="blacklist-main__empty-hint">标记体验差的游戏,提醒队友避开</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.blacklist-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部操作栏 */
|
||||||
|
.blacklist-main__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__filter-select {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__create-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__create-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区域 */
|
||||||
|
.blacklist-main__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__game-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__expanded {
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载状态 */
|
||||||
|
.blacklist-main__loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__loading-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.blacklist-main__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.blacklist-main__header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blacklist-main__filter-select {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { createBlacklistEntry } from '@/api/gameBlacklist'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { BlacklistReasonMap, BlacklistSeverityMap } from '@/types'
|
||||||
|
import type { BlacklistReason, BlacklistSeverity } from '@/types'
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
gameName: '',
|
||||||
|
reason: '' as BlacklistReason | '',
|
||||||
|
severity: '' as BlacklistSeverity | '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
gameName: '',
|
||||||
|
reason: '',
|
||||||
|
severity: '',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
// 校验必填字段
|
||||||
|
if (!form.value.gameName.trim()) {
|
||||||
|
ElMessage.warning('请输入游戏名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.reason) {
|
||||||
|
ElMessage.warning('请选择原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.severity) {
|
||||||
|
ElMessage.warning('请选择严重程度')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.description.trim()) {
|
||||||
|
ElMessage.warning('请填写描述')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) {
|
||||||
|
ElMessage.error('请先选择群组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await createBlacklistEntry({
|
||||||
|
group: groupId,
|
||||||
|
gameName: form.value.gameName.trim(),
|
||||||
|
reason: form.value.reason,
|
||||||
|
severity: form.value.severity,
|
||||||
|
description: form.value.description.trim(),
|
||||||
|
})
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
resetForm()
|
||||||
|
ElMessage.success('标记成功')
|
||||||
|
emit('created')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '标记失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="标记坑游戏"
|
||||||
|
width="460px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<div class="create-form">
|
||||||
|
<!-- 游戏名称 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>游戏名称 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.gameName"
|
||||||
|
placeholder="输入游戏名称"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 原因选择 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>原因 <span class="required">*</span></label>
|
||||||
|
<el-select
|
||||||
|
v-model="form.reason"
|
||||||
|
placeholder="选择原因"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(label, key) in BlacklistReasonMap"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 严重程度 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>严重程度 <span class="required">*</span></label>
|
||||||
|
<el-select
|
||||||
|
v-model="form.severity"
|
||||||
|
placeholder="选择严重程度"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(label, key) in BlacklistSeverityMap"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>描述 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="描述一下为什么这游戏坑"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||||
|
{{ loading ? '提交中...' : '提交' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,31 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
import { computed, ref, watch, onUnmounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { getGroupJoinRequests, updateGroupApproval, subscribeJoinRequests } from '@/api/groups'
|
import {
|
||||||
|
getGroupJoinRequests,
|
||||||
|
updateGroupApproval,
|
||||||
|
subscribeJoinRequests,
|
||||||
|
transferGroupOwnership,
|
||||||
|
addGroupAdmin,
|
||||||
|
removeGroupAdmin,
|
||||||
|
updateGroupMemberManage
|
||||||
|
} from '@/api/groups'
|
||||||
import { ElSwitch } from 'element-plus'
|
import { ElSwitch } from 'element-plus'
|
||||||
import type { JoinRequest } from '@/types'
|
import type { JoinRequest } from '@/types'
|
||||||
import JoinRequestCard from './JoinRequestCard.vue'
|
import JoinRequestCard from './JoinRequestCard.vue'
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
const emit = defineEmits<{ requestHandled: [] }>()
|
||||||
|
|
||||||
const group = computed(() => groupStore.currentGroup)
|
const group = computed(() => groupStore.currentGroup)
|
||||||
const members = computed(() => groupStore.currentMembers)
|
const members = computed(() => groupStore.currentMembers)
|
||||||
const isOwner = computed(() => group.value?.owner === userStore.userId)
|
const isOwner = computed(() => groupStore.isGroupOwner)
|
||||||
|
const isAdmin = computed(() => groupStore.isGroupAdmin)
|
||||||
|
const canManage = computed(() => groupStore.canManageGroup)
|
||||||
|
|
||||||
const joinRequests = ref<JoinRequest[]>([])
|
const joinRequests = ref<JoinRequest[]>([])
|
||||||
const approvalLoading = ref(false)
|
const approvalLoading = ref(false)
|
||||||
|
const memberManageLoading = ref(false)
|
||||||
|
const inviteLink = computed(() => {
|
||||||
|
if (!group.value?.id) return ''
|
||||||
|
return `${window.location.origin}/join/group/${group.value.id}`
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
let subscribedGroupId = ''
|
||||||
if (group.value && isOwner.value) {
|
|
||||||
|
watch(group, async (g) => {
|
||||||
|
if (g && canManage.value && g.id !== subscribedGroupId) {
|
||||||
|
subscribedGroupId = g.id
|
||||||
await loadJoinRequests()
|
await loadJoinRequests()
|
||||||
subscribeJoinRequests(group.value.id, () => {
|
subscribeJoinRequests(g.id, () => {
|
||||||
loadJoinRequests()
|
loadJoinRequests()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
}, { immediate: true })
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// cleanup handled by PocketBase
|
// cleanup handled by PocketBase
|
||||||
@@ -38,22 +57,38 @@ async function loadJoinRequests() {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyGroupId() {
|
function copyInviteLink() {
|
||||||
if (group.value?.id) {
|
if (!group.value?.id) return
|
||||||
navigator.clipboard.writeText(group.value.id)
|
const text = inviteLink.value
|
||||||
ElMessage.success('群组 ID 已复制,分享给好友即可加入')
|
if (navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
ElMessage.success('邀请链接已复制,分享给好友即可加入')
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
ElMessage.success('邀请链接已复制,分享给好友即可加入')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeMember(userId: string, username: string) {
|
async function removeMember(userId: string, username: string) {
|
||||||
if (!isOwner.value) return
|
if (!canManage.value) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
|
await ElMessageBox.confirm(`确定要将 ${username} 移出群组吗?`, '确认', { type: 'warning' })
|
||||||
const grp = groupStore.currentGroup
|
const grp = groupStore.currentGroup
|
||||||
if (!grp) return
|
if (!grp) return
|
||||||
const { pb } = await import('@/api/pocketbase')
|
const { pb } = await import('@/api/pocketbase')
|
||||||
const newMembers = grp.members.filter(id => id !== userId)
|
const newMembers = grp.members.filter(id => id !== userId)
|
||||||
await pb.collection('groups').update(grp.id, { members: newMembers })
|
const newAdmins = (grp.admins || []).filter(id => id !== userId)
|
||||||
|
await pb.collection('groups').update(grp.id, { members: newMembers, admins: newAdmins })
|
||||||
await groupStore.setCurrentGroup(grp.id)
|
await groupStore.setCurrentGroup(grp.id)
|
||||||
ElMessage.success('已移除成员')
|
ElMessage.success('已移除成员')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -75,11 +110,64 @@ async function handleApprovalChange(val: string | number | boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMemberManageChange(val: string | number | boolean) {
|
||||||
|
if (!group.value || !isOwner.value) return
|
||||||
|
memberManageLoading.value = true
|
||||||
|
try {
|
||||||
|
await updateGroupMemberManage(group.value.id, !!val)
|
||||||
|
await groupStore.setCurrentGroup(group.value.id)
|
||||||
|
ElMessage.success(val ? '已开启全员管理' : '已关闭全员管理')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('更新设置失败')
|
||||||
|
} finally {
|
||||||
|
memberManageLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onJoinRequestResponded(requestId: string) {
|
async function onJoinRequestResponded(requestId: string) {
|
||||||
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
|
joinRequests.value = joinRequests.value.filter(r => r.id !== requestId)
|
||||||
if (group.value) {
|
if (group.value) {
|
||||||
await groupStore.setCurrentGroup(group.value.id)
|
await groupStore.setCurrentGroup(group.value.id)
|
||||||
}
|
}
|
||||||
|
emit('requestHandled')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransferOwnership(userId: string, username: string) {
|
||||||
|
if (!isOwner.value) return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
`确定要将群主转让给 ${username} 吗?转让后你将成为管理员。`,
|
||||||
|
'确认转让群主',
|
||||||
|
{ type: 'warning' }
|
||||||
|
)
|
||||||
|
await transferGroupOwnership(group.value!.id, userId)
|
||||||
|
await groupStore.setCurrentGroup(group.value!.id)
|
||||||
|
ElMessage.success('群主转让成功')
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message) ElMessage.error(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddAdmin(userId: string, username: string) {
|
||||||
|
if (!isOwner.value) return
|
||||||
|
try {
|
||||||
|
await addGroupAdmin(group.value!.id, userId)
|
||||||
|
await groupStore.setCurrentGroup(group.value!.id)
|
||||||
|
ElMessage.success(`${username} 已成为管理员`)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '操作失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveAdmin(userId: string, username: string) {
|
||||||
|
if (!isOwner.value) return
|
||||||
|
try {
|
||||||
|
await removeGroupAdmin(group.value!.id, userId)
|
||||||
|
await groupStore.setCurrentGroup(group.value!.id)
|
||||||
|
ElMessage.success(`${username} 已取消管理员身份`)
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage.error(err.message || '操作失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -89,17 +177,17 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
<h3>群组信息</h3>
|
<h3>群组信息</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分享群组 ID -->
|
<!-- 分享邀请链接 -->
|
||||||
<div class="share-section">
|
<div class="share-section">
|
||||||
<label>群组 ID(分享给好友)</label>
|
<label>邀请链接(分享给好友)</label>
|
||||||
<div class="share-row">
|
<div class="share-row">
|
||||||
<code class="group-id">{{ group.id }}</code>
|
<code class="group-id">{{ inviteLink }}</code>
|
||||||
<button class="copy-btn" @click="copyGroupId">复制</button>
|
<button class="copy-btn" @click="copyInviteLink">复制</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 审核开关(仅群主可见) -->
|
<!-- 审核开关(管理员可见) -->
|
||||||
<div v-if="isOwner" class="approval-row">
|
<div v-if="canManage" class="approval-row">
|
||||||
<span class="info-label">加入需审核</span>
|
<span class="info-label">加入需审核</span>
|
||||||
<el-switch
|
<el-switch
|
||||||
:model-value="group.requireApproval"
|
:model-value="group.requireApproval"
|
||||||
@@ -108,13 +196,23 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 全员管理开关(仅群主可见) -->
|
||||||
|
<div v-if="isOwner" class="approval-row">
|
||||||
|
<span class="info-label">全员管理</span>
|
||||||
|
<el-switch
|
||||||
|
:model-value="group.allowMemberManage"
|
||||||
|
:loading="memberManageLoading"
|
||||||
|
@change="handleMemberManageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">成员</span>
|
<span class="info-label">成员</span>
|
||||||
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
|
<span class="info-value">{{ members.length }} / {{ group.maxMembers }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 待审核申请(仅群主可见) -->
|
<!-- 待审核申请(管理员可见) -->
|
||||||
<div v-if="isOwner && joinRequests.length > 0" class="requests-section">
|
<div v-if="canManage && joinRequests.length > 0" class="requests-section">
|
||||||
<h4 class="requests-title">待审核申请 ({{ joinRequests.length }})</h4>
|
<h4 class="requests-title">待审核申请 ({{ joinRequests.length }})</h4>
|
||||||
<div class="requests-list">
|
<div class="requests-list">
|
||||||
<JoinRequestCard
|
<JoinRequestCard
|
||||||
@@ -132,9 +230,40 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
<div class="member-info">
|
<div class="member-info">
|
||||||
<span class="member-name">{{ member.name || member.username }}</span>
|
<span class="member-name">{{ member.name || member.username }}</span>
|
||||||
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
|
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
|
||||||
|
<span v-else-if="(group.admins || []).includes(member.id)" class="admin-badge">管理</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 管理操作菜单 -->
|
||||||
|
<div v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId" class="member-actions">
|
||||||
<button
|
<button
|
||||||
v-if="isOwner && member.id !== group.owner && member.id !== userStore.userId"
|
v-if="member.id !== group.owner && !(group.admins || []).includes(member.id)"
|
||||||
|
class="action-btn action-btn--admin"
|
||||||
|
@click="handleAddAdmin(member.id, member.name || member.username)"
|
||||||
|
>
|
||||||
|
设管
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="(group.admins || []).includes(member.id)"
|
||||||
|
class="action-btn action-btn--admin"
|
||||||
|
@click="handleRemoveAdmin(member.id, member.name || member.username)"
|
||||||
|
>
|
||||||
|
取消管理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--transfer"
|
||||||
|
@click="handleTransferOwnership(member.id, member.name || member.username)"
|
||||||
|
>
|
||||||
|
转让
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="action-btn action-btn--remove"
|
||||||
|
@click="removeMember(member.id, member.name || member.username)"
|
||||||
|
>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- 管理员移除成员 -->
|
||||||
|
<button
|
||||||
|
v-else-if="isAdmin && member.id !== group.owner && member.id !== userStore.userId"
|
||||||
class="remove-btn"
|
class="remove-btn"
|
||||||
@click="removeMember(member.id, member.name || member.username)"
|
@click="removeMember(member.id, member.name || member.username)"
|
||||||
>
|
>
|
||||||
@@ -253,6 +382,16 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.requests-section--highlight {
|
||||||
|
animation: highlight-pulse 2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes highlight-pulse {
|
||||||
|
0% { background: rgba(245, 158, 11, 0.06); }
|
||||||
|
30% { background: rgba(245, 158, 11, 0.3); }
|
||||||
|
100% { background: rgba(245, 158, 11, 0.06); }
|
||||||
|
}
|
||||||
|
|
||||||
.members-list {
|
.members-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -284,10 +423,14 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-name {
|
.member-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.owner-badge {
|
.owner-badge {
|
||||||
@@ -296,6 +439,63 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
background: var(--gg-gradient);
|
background: var(--gg-gradient);
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #3b82f6;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--admin {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: #3b82f6;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--admin:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--transfer {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: #f59e0b;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--transfer:hover {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--remove {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--gg-danger);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--remove:hover {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-btn {
|
.remove-btn {
|
||||||
@@ -308,6 +508,7 @@ async function onJoinRequestResponded(requestId: string) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove-btn:hover {
|
.remove-btn:hover {
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useLedgerStore } from '@/stores/ledger'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import type { Ledger, LedgerType, LedgerCategory } from '@/types'
|
||||||
|
import { LedgerCategoryMap } from '@/types'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
groupId: string
|
||||||
|
editLedger?: Ledger
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
saved: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const ledgerStore = useLedgerStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
type: 'expense' as LedgerType,
|
||||||
|
amount: 0,
|
||||||
|
category: 'other' as LedgerCategory,
|
||||||
|
description: '',
|
||||||
|
relatedMembers: [] as string[],
|
||||||
|
occurredAt: '' as string | Date,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.editLedger)
|
||||||
|
const dialogTitle = computed(() => (isEditing.value ? '编辑账目' : '新建账目'))
|
||||||
|
|
||||||
|
// 分类选项
|
||||||
|
const categoryOptions = Object.entries(LedgerCategoryMap).map(([value, label]) => ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 群组成员选项
|
||||||
|
const memberOptions = computed(() => {
|
||||||
|
return groupStore.currentMembers.map((m) => ({
|
||||||
|
label: displayName(m),
|
||||||
|
value: m.id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 格式化今天日期
|
||||||
|
function getTodayStr(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置表单
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
type: 'expense',
|
||||||
|
amount: 0,
|
||||||
|
category: 'other',
|
||||||
|
description: '',
|
||||||
|
relatedMembers: [],
|
||||||
|
occurredAt: getTodayStr(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 填充编辑数据
|
||||||
|
function fillForm(ledger: Ledger) {
|
||||||
|
form.value = {
|
||||||
|
type: ledger.type,
|
||||||
|
amount: ledger.amount,
|
||||||
|
category: ledger.category,
|
||||||
|
description: ledger.description || '',
|
||||||
|
relatedMembers: [...(ledger.relatedMembers || [])],
|
||||||
|
occurredAt: ledger.occurredAt?.slice(0, 10) || getTodayStr(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对话框打开
|
||||||
|
function handleOpen() {
|
||||||
|
if (props.editLedger) {
|
||||||
|
fillForm(props.editLedger)
|
||||||
|
} else {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!props.groupId) {
|
||||||
|
ElMessage.error('缺少群组信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.value.amount <= 0) {
|
||||||
|
ElMessage.warning('金额必须大于0')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const occurredAt = form.value.occurredAt
|
||||||
|
? new Date(String(form.value.occurredAt)).toISOString()
|
||||||
|
: new Date().toISOString()
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
type: form.value.type,
|
||||||
|
amount: form.value.amount,
|
||||||
|
category: form.value.category,
|
||||||
|
description: form.value.description || '',
|
||||||
|
relatedMembers: form.value.relatedMembers,
|
||||||
|
occurredAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.editLedger) {
|
||||||
|
await ledgerStore.editLedger(props.editLedger.id, submitData)
|
||||||
|
ElMessage.success('账目更新成功')
|
||||||
|
} else {
|
||||||
|
await ledgerStore.addLedger({
|
||||||
|
group: props.groupId,
|
||||||
|
...submitData,
|
||||||
|
})
|
||||||
|
ElMessage.success('账目创建成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
emit('saved')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '操作失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="480px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<div class="create-form">
|
||||||
|
<!-- 类型 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>类型 <span class="required">*</span></label>
|
||||||
|
<div class="type-switch">
|
||||||
|
<button
|
||||||
|
:class="['type-btn', { active: form.type === 'income' }]"
|
||||||
|
@click="form.type = 'income'"
|
||||||
|
>
|
||||||
|
收入
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="['type-btn', { active: form.type === 'expense' }]"
|
||||||
|
@click="form.type = 'expense'"
|
||||||
|
>
|
||||||
|
支出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 金额 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>金额 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model.number="form.amount"
|
||||||
|
type="number"
|
||||||
|
:min="0.01"
|
||||||
|
:step="0.01"
|
||||||
|
placeholder="请输入金额"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<span class="amount-prefix">¥</span>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分类 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>分类 <span class="required">*</span></label>
|
||||||
|
<el-select
|
||||||
|
v-model="form.category"
|
||||||
|
placeholder="请选择分类"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in categoryOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 描述 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>描述</label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入账目描述(可选)"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 关联成员 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>关联成员</label>
|
||||||
|
<el-select
|
||||||
|
v-model="form.relatedMembers"
|
||||||
|
multiple
|
||||||
|
placeholder="选择关联成员(可选)"
|
||||||
|
style="width: 100%"
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in memberOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 发生日期 -->
|
||||||
|
<div class="form-field">
|
||||||
|
<label>发生日期</label>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="form.occurredAt"
|
||||||
|
type="date"
|
||||||
|
placeholder="选择日期"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
style="width: 100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||||
|
{{ loading ? '保存中...' : '保存' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-switch {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--gg-border, #dcdfe6);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--gg-bg, #fff);
|
||||||
|
color: var(--gg-text-secondary, #909399);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn:hover {
|
||||||
|
border-color: var(--gg-primary, #67c23a);
|
||||||
|
color: var(--gg-primary, #67c23a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn.active {
|
||||||
|
background: var(--gg-primary, #67c23a);
|
||||||
|
border-color: var(--gg-primary, #67c23a);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount-prefix {
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Edit, Delete } from '@element-plus/icons-vue'
|
||||||
|
import type { Ledger } from '@/types'
|
||||||
|
import { LedgerTypeMap, LedgerCategoryMap, displayName } from '@/types'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
ledger: Ledger
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: [ledgerId: string]
|
||||||
|
delete: [ledgerId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const isCreator = computed(() => {
|
||||||
|
return props.ledger.creator === pb.authStore.model?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const isGroupOwner = computed(() => {
|
||||||
|
return pb.authStore.model?.id === groupStore.currentGroup?.owner
|
||||||
|
})
|
||||||
|
|
||||||
|
const typeLabel = computed(() => LedgerTypeMap[props.ledger.type])
|
||||||
|
const categoryLabel = computed(() => LedgerCategoryMap[props.ledger.category])
|
||||||
|
const isIncome = computed(() => props.ledger.type === 'income')
|
||||||
|
|
||||||
|
const formattedAmount = computed(() => {
|
||||||
|
const prefix = isIncome.value ? '+' : '-'
|
||||||
|
return `${prefix}¥${props.ledger.amount.toFixed(2)}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedDate = computed(() => {
|
||||||
|
if (!props.ledger.occurredAt) return ''
|
||||||
|
return props.ledger.occurredAt.slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const creatorName = computed(() => {
|
||||||
|
return displayName(props.ledger.expand?.creator)
|
||||||
|
})
|
||||||
|
|
||||||
|
const relatedMemberNames = computed(() => {
|
||||||
|
const members = props.ledger.expand?.relatedMembers
|
||||||
|
if (!members || members.length === 0) return []
|
||||||
|
return members.map((m) => displayName(m))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ledger-card">
|
||||||
|
<div class="ledger-card__main">
|
||||||
|
<!-- 左侧:类型标签 + 金额 -->
|
||||||
|
<div class="ledger-card__left">
|
||||||
|
<span
|
||||||
|
class="ledger-card__type-tag"
|
||||||
|
:class="isIncome ? 'ledger-card__type-tag--income' : 'ledger-card__type-tag--expense'"
|
||||||
|
>
|
||||||
|
{{ typeLabel }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ledger-card__amount"
|
||||||
|
:class="isIncome ? 'ledger-card__amount--income' : 'ledger-card__amount--expense'"
|
||||||
|
>
|
||||||
|
{{ formattedAmount }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中间:描述、分类、日期 -->
|
||||||
|
<div class="ledger-card__center">
|
||||||
|
<div class="ledger-card__desc">{{ ledger.description || '无备注' }}</div>
|
||||||
|
<div class="ledger-card__meta">
|
||||||
|
<span class="ledger-card__category-tag">{{ categoryLabel }}</span>
|
||||||
|
<span class="ledger-card__date">{{ formattedDate }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:关联成员 + 创建者 -->
|
||||||
|
<div class="ledger-card__right">
|
||||||
|
<div v-if="relatedMemberNames.length > 0" class="ledger-card__members">
|
||||||
|
<span
|
||||||
|
v-for="(name, index) in relatedMemberNames.slice(0, 3)"
|
||||||
|
:key="index"
|
||||||
|
class="ledger-card__member"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="relatedMemberNames.length > 3" class="ledger-card__member ledger-card__member--more">
|
||||||
|
+{{ relatedMemberNames.length - 3 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-card__creator">
|
||||||
|
{{ creatorName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div v-if="isCreator || isGroupOwner" class="ledger-card__actions">
|
||||||
|
<button v-if="isCreator" class="ledger-card__action-btn" @click.stop="emit('edit', ledger.id)">
|
||||||
|
<el-icon><Edit /></el-icon>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button class="ledger-card__action-btn ledger-card__action-btn--danger" @click.stop="emit('delete', ledger.id)">
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ledger-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md, 8px);
|
||||||
|
padding: 14px 18px;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 0 16px rgba(5, 150, 105, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧 */
|
||||||
|
.ledger-card__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__type-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__type-tag--income {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__type-tag--expense {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__amount {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__amount--income {
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__amount--expense {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 中间 */
|
||||||
|
.ledger-card__center {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__desc {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__category-tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
background: rgba(5, 150, 105, 0.08);
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__date {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧 */
|
||||||
|
.ledger-card__right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__members {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__member {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 18px;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
max-width: 60px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__member--more {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__creator {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
|
.ledger-card__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__action-btn:hover {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
background: rgba(5, 150, 105, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__action-btn--danger:hover {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ledger-card__main {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__left {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-card__right {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
|
import { useLedgerStore } from '@/stores/ledger'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import type { LedgerType, LedgerCategory } from '@/types'
|
||||||
|
import { LedgerTypeMap, LedgerCategoryMap } from '@/types'
|
||||||
|
import LedgerSummary from './LedgerSummary.vue'
|
||||||
|
import LedgerCard from './LedgerCard.vue'
|
||||||
|
import CreateLedgerDialog from './CreateLedgerDialog.vue'
|
||||||
|
|
||||||
|
const ledgerStore = useLedgerStore()
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const showCreate = ref(false)
|
||||||
|
const editingLedger = ref<any>(undefined)
|
||||||
|
const filterType = ref<LedgerType | ''>('')
|
||||||
|
const filterCategory = ref<LedgerCategory | ''>('')
|
||||||
|
|
||||||
|
// 月份选择器值
|
||||||
|
const selectedMonth = ref(new Date())
|
||||||
|
|
||||||
|
// 类型筛选选项
|
||||||
|
const typeOptions = computed(() => [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
...Object.entries(LedgerTypeMap).map(([value, label]) => ({ label, value }))
|
||||||
|
])
|
||||||
|
|
||||||
|
// 分类筛选选项
|
||||||
|
const categoryOptions = computed(() => [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
...Object.entries(LedgerCategoryMap).map(([value, label]) => ({ label, value }))
|
||||||
|
])
|
||||||
|
|
||||||
|
// 获取月份字符串
|
||||||
|
function getMonthString(date: Date): string {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${year}-${month}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
async function loadData() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
|
||||||
|
const month = getMonthString(selectedMonth.value)
|
||||||
|
await ledgerStore.loadLedgers(groupId, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选后的账目列表
|
||||||
|
const filteredLedgers = computed(() => {
|
||||||
|
let list = [...ledgerStore.ledgers]
|
||||||
|
if (filterType.value) {
|
||||||
|
list = list.filter((l) => l.type === filterType.value)
|
||||||
|
}
|
||||||
|
if (filterCategory.value) {
|
||||||
|
list = list.filter((l) => l.category === filterCategory.value)
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按日期分组
|
||||||
|
const groupedByDate = computed(() => {
|
||||||
|
const groups: Record<string, typeof filteredLedgers.value> = {}
|
||||||
|
for (const ledger of filteredLedgers.value) {
|
||||||
|
const date = ledger.occurredAt?.slice(0, 10) || '未知日期'
|
||||||
|
if (!groups[date]) {
|
||||||
|
groups[date] = []
|
||||||
|
}
|
||||||
|
groups[date].push(ledger)
|
||||||
|
}
|
||||||
|
// 按日期降序排列
|
||||||
|
const sortedKeys = Object.keys(groups).sort((a, b) => b.localeCompare(a))
|
||||||
|
return sortedKeys.map((date) => ({ date, items: groups[date] }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 月份变更
|
||||||
|
function handleMonthChange(val: Date) {
|
||||||
|
selectedMonth.value = val
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选变更
|
||||||
|
function handleFilterChange() {
|
||||||
|
// 筛选在前端完成,无需重新加载
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建
|
||||||
|
function handleCreate() {
|
||||||
|
editingLedger.value = undefined
|
||||||
|
showCreate.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
function handleEdit(ledgerId: string) {
|
||||||
|
const ledger = ledgerStore.ledgers.find((l) => l.id === ledgerId)
|
||||||
|
if (ledger) {
|
||||||
|
editingLedger.value = ledger
|
||||||
|
showCreate.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
async function handleDelete(ledgerId: string) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确定删除这条账目记录吗?删除后不可恢复。', '删除确认', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
await ledgerStore.removeLedger(ledgerId)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
} catch {
|
||||||
|
// 用户取消
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存成功
|
||||||
|
function handleSaved() {
|
||||||
|
showCreate.value = false
|
||||||
|
editingLedger.value = undefined
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 实时订阅(store 内部管理生命周期)
|
||||||
|
async function startSubscription() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
await ledgerStore.startSubscription(groupId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
loadData()
|
||||||
|
startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
ledgerStore.stopSubscription()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
loadData()
|
||||||
|
startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ledger-list">
|
||||||
|
<!-- 顶部操作栏 -->
|
||||||
|
<div class="ledger-list__header">
|
||||||
|
<h3 class="ledger-list__title">账本</h3>
|
||||||
|
<button class="ledger-list__create-btn" @click="handleCreate">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新建账目
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<div class="ledger-list__filters">
|
||||||
|
<el-date-picker
|
||||||
|
:model-value="selectedMonth"
|
||||||
|
type="month"
|
||||||
|
placeholder="选择月份"
|
||||||
|
format="YYYY年MM月"
|
||||||
|
value-format="YYYY-MM"
|
||||||
|
@update:model-value="handleMonthChange"
|
||||||
|
class="ledger-list__month-picker"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="filterType"
|
||||||
|
placeholder="类型"
|
||||||
|
clearable
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="ledger-list__filter-select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in typeOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="filterCategory"
|
||||||
|
placeholder="分类"
|
||||||
|
clearable
|
||||||
|
@change="handleFilterChange"
|
||||||
|
class="ledger-list__filter-select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in categoryOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 汇总 -->
|
||||||
|
<LedgerSummary />
|
||||||
|
|
||||||
|
<!-- 账目列表 -->
|
||||||
|
<div v-loading="ledgerStore.loading" class="ledger-list__content">
|
||||||
|
<section
|
||||||
|
v-for="group in groupedByDate"
|
||||||
|
:key="group.date"
|
||||||
|
class="ledger-list__date-group"
|
||||||
|
>
|
||||||
|
<div class="ledger-list__date-header">
|
||||||
|
<span class="ledger-list__date-dot"></span>
|
||||||
|
<span class="ledger-list__date-label">{{ group.date }}</span>
|
||||||
|
<span class="ledger-list__date-count">{{ group.items.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-list__items">
|
||||||
|
<LedgerCard
|
||||||
|
v-for="ledger in group.items"
|
||||||
|
:key="ledger.id"
|
||||||
|
:ledger="ledger"
|
||||||
|
@edit="handleEdit"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div
|
||||||
|
v-if="groupedByDate.length === 0 && !ledgerStore.loading"
|
||||||
|
class="ledger-list__empty"
|
||||||
|
>
|
||||||
|
<svg class="ledger-list__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4" />
|
||||||
|
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5" />
|
||||||
|
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z" />
|
||||||
|
</svg>
|
||||||
|
<p class="ledger-list__empty-text">暂无账目</p>
|
||||||
|
<p class="ledger-list__empty-hint">该月份还没有账目记录</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新建/编辑对话框 -->
|
||||||
|
<CreateLedgerDialog
|
||||||
|
v-model="showCreate"
|
||||||
|
:group-id="groupStore.currentGroupId"
|
||||||
|
:edit-ledger="editingLedger"
|
||||||
|
@saved="handleSaved"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ledger-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部操作栏 */
|
||||||
|
.ledger-list__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-gradient-green);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__create-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 筛选栏 */
|
||||||
|
.ledger-list__filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__month-picker {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__filter-select {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区 */
|
||||||
|
.ledger-list__content {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 日期分组 */
|
||||||
|
.ledger-list__date-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__date-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__date-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__date-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__date-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__items {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 */
|
||||||
|
.ledger-list__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ledger-list__filters {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-list__month-picker,
|
||||||
|
.ledger-list__filter-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useLedgerStore } from '@/stores/ledger'
|
||||||
|
|
||||||
|
const ledgerStore = useLedgerStore()
|
||||||
|
|
||||||
|
const totalIncome = computed(() => ledgerStore.summary.totalIncome)
|
||||||
|
const totalExpense = computed(() => ledgerStore.summary.totalExpense)
|
||||||
|
const balance = computed(() => ledgerStore.summary.balance)
|
||||||
|
|
||||||
|
function formatAmount(value: number): string {
|
||||||
|
return '¥' + value.toFixed(2)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ledger-summary">
|
||||||
|
<div class="ledger-summary__stat ledger-summary__stat--income">
|
||||||
|
<span class="ledger-summary__label">总收入</span>
|
||||||
|
<span class="ledger-summary__amount ledger-summary__amount--income">
|
||||||
|
{{ formatAmount(totalIncome) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-summary__stat ledger-summary__stat--expense">
|
||||||
|
<span class="ledger-summary__label">总支出</span>
|
||||||
|
<span class="ledger-summary__amount ledger-summary__amount--expense">
|
||||||
|
{{ formatAmount(totalExpense) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ledger-summary__stat ledger-summary__stat--balance">
|
||||||
|
<span class="ledger-summary__label">余额</span>
|
||||||
|
<span
|
||||||
|
class="ledger-summary__amount"
|
||||||
|
:class="balance >= 0 ? 'ledger-summary__amount--income' : 'ledger-summary__amount--expense'"
|
||||||
|
>
|
||||||
|
{{ formatAmount(balance) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ledger-summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-summary__stat {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-lg, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-summary__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-summary__amount {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-summary__amount--income {
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-summary__amount--expense {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ledger-summary {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-summary__stat {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { createPlayerBlacklistEntry } from '@/api/playerBlacklist'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { PlayerTagMap, BlacklistSeverityMap } from '@/types'
|
||||||
|
import type { PlayerTag, BlacklistSeverity } from '@/types'
|
||||||
|
|
||||||
|
const visible = defineModel<boolean>({ default: false })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
playerId: '',
|
||||||
|
platform: '',
|
||||||
|
tags: [] as PlayerTag[],
|
||||||
|
customTag: '',
|
||||||
|
severity: '' as BlacklistSeverity | '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.value = {
|
||||||
|
playerId: '',
|
||||||
|
platform: '',
|
||||||
|
tags: [],
|
||||||
|
customTag: '',
|
||||||
|
severity: '',
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.playerId.trim()) {
|
||||||
|
ElMessage.warning('请输入玩家ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.platform.trim()) {
|
||||||
|
ElMessage.warning('请输入游戏/平台')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.value.tags.length === 0) {
|
||||||
|
ElMessage.warning('请至少选择一个标签')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.severity) {
|
||||||
|
ElMessage.warning('请选择严重程度')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.description.trim()) {
|
||||||
|
ElMessage.warning('请填写描述')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) {
|
||||||
|
ElMessage.error('请先选择群组')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await createPlayerBlacklistEntry({
|
||||||
|
group: groupId,
|
||||||
|
playerId: form.value.playerId.trim(),
|
||||||
|
platform: form.value.platform.trim(),
|
||||||
|
tags: form.value.tags,
|
||||||
|
customTag: form.value.customTag.trim() || undefined,
|
||||||
|
description: form.value.description.trim(),
|
||||||
|
severity: form.value.severity,
|
||||||
|
})
|
||||||
|
|
||||||
|
visible.value = false
|
||||||
|
resetForm()
|
||||||
|
ElMessage.success('标记成功')
|
||||||
|
emit('created')
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '标记失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="标记坑玩家"
|
||||||
|
width="460px"
|
||||||
|
@open="handleOpen"
|
||||||
|
>
|
||||||
|
<div class="create-form">
|
||||||
|
<div class="form-field">
|
||||||
|
<label>玩家ID <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.playerId"
|
||||||
|
placeholder="输入玩家的游戏内ID或昵称"
|
||||||
|
maxlength="200"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label>游戏/平台 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.platform"
|
||||||
|
placeholder="如:英雄联盟、Steam、绝地求生"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label>标签 <span class="required">*</span></label>
|
||||||
|
<el-checkbox-group v-model="form.tags">
|
||||||
|
<el-checkbox
|
||||||
|
v-for="(label, key) in PlayerTagMap"
|
||||||
|
:key="key"
|
||||||
|
:value="key"
|
||||||
|
:label="label"
|
||||||
|
/>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label>自定义标签</label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.customTag"
|
||||||
|
placeholder="补充标签(选填)"
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label>严重程度 <span class="required">*</span></label>
|
||||||
|
<el-select v-model="form.severity" placeholder="选择严重程度" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="(label, key) in BlacklistSeverityMap"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label>描述 <span class="required">*</span></label>
|
||||||
|
<el-input
|
||||||
|
v-model="form.description"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="描述一下遇到的情况"
|
||||||
|
:rows="3"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<button class="submit-btn" :disabled="loading" @click="handleSubmit">
|
||||||
|
{{ loading ? '提交中...' : '提交' }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-field label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { listPlayerBlacklist, subscribePlayerBlacklist } from '@/api/playerBlacklist'
|
||||||
|
import { deletePlayerBlacklistEntry } from '@/api/playerBlacklist'
|
||||||
|
import { ElMessage, ElPopconfirm } from 'element-plus'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import { displayName } from '@/types'
|
||||||
|
import { PlayerTagMap, BlacklistSeverityMap } from '@/types'
|
||||||
|
import type { PlayerBlacklistEntry, PlayerTag, BlacklistSeverity } from '@/types'
|
||||||
|
import CreatePlayerBlacklistDialog from './CreatePlayerBlacklistDialog.vue'
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const allEntries = ref<PlayerBlacklistEntry[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showCreate = ref(false)
|
||||||
|
|
||||||
|
// 筛选
|
||||||
|
const filterTag = ref<PlayerTag | ''>('')
|
||||||
|
const filterSeverity = ref<BlacklistSeverity | ''>('')
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const searchPlayerId = ref('')
|
||||||
|
|
||||||
|
let unsubscribeFn: (() => void) | null = null
|
||||||
|
|
||||||
|
// 按玩家ID聚合
|
||||||
|
const groupedByPlayer = computed(() => {
|
||||||
|
let filtered = allEntries.value
|
||||||
|
if (searchPlayerId.value.trim()) {
|
||||||
|
const q = searchPlayerId.value.trim().toLowerCase()
|
||||||
|
filtered = filtered.filter(e => e.playerId.toLowerCase().includes(q))
|
||||||
|
}
|
||||||
|
|
||||||
|
const map = new Map<string, PlayerBlacklistEntry[]>()
|
||||||
|
for (const entry of filtered) {
|
||||||
|
const key = `${entry.playerId}|${entry.platform}`
|
||||||
|
if (!map.has(key)) map.set(key, [])
|
||||||
|
map.get(key)!.push(entry)
|
||||||
|
}
|
||||||
|
for (const entries of map.values()) {
|
||||||
|
entries.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
// 玩家列表(按被标记次数降序)
|
||||||
|
const playerGroups = computed(() => {
|
||||||
|
const groups: { playerId: string; platform: string; entries: PlayerBlacklistEntry[] }[] = []
|
||||||
|
for (const [key, entries] of groupedByPlayer.value) {
|
||||||
|
const [playerId, platform] = key.split('|')
|
||||||
|
groups.push({ playerId, platform, entries })
|
||||||
|
}
|
||||||
|
groups.sort((a, b) => b.entries.length - a.entries.length)
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
// 展开的玩家
|
||||||
|
const expandedPlayer = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const options: { tag?: string; severity?: string } = {}
|
||||||
|
if (filterTag.value) options.tag = filterTag.value
|
||||||
|
if (filterSeverity.value) options.severity = filterSeverity.value
|
||||||
|
allEntries.value = await listPlayerBlacklist(groupId, options)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载玩家黑名单失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSubscription() {
|
||||||
|
const groupId = groupStore.currentGroupId
|
||||||
|
if (!groupId) return
|
||||||
|
unsubscribeFn = await subscribePlayerBlacklist(groupId, () => loadEntries())
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscription() {
|
||||||
|
if (unsubscribeFn) {
|
||||||
|
unsubscribeFn()
|
||||||
|
unsubscribeFn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlayer(key: string) {
|
||||||
|
expandedPlayer.value = expandedPlayer.value === key ? null : key
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreated() {
|
||||||
|
showCreate.value = false
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id)
|
||||||
|
const isOwner = computed(() => groupStore.isGroupOwner)
|
||||||
|
|
||||||
|
function canDelete(entry: PlayerBlacklistEntry): boolean {
|
||||||
|
return entry.reporter === currentUserId.value || isOwner.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(entryId: string) {
|
||||||
|
try {
|
||||||
|
await deletePlayerBlacklistEntry(entryId)
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
loadEntries()
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error(error.message || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
if (diffDays > 30) return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
|
if (diffDays > 0) return `${diffDays}天前`
|
||||||
|
if (diffHours > 0) return `${diffHours}小时前`
|
||||||
|
if (diffMinutes > 0) return `${diffMinutes}分钟前`
|
||||||
|
return '刚刚'
|
||||||
|
}
|
||||||
|
|
||||||
|
function reporterName(entry: PlayerBlacklistEntry): string {
|
||||||
|
return entry.expand?.reporter ? displayName(entry.expand.reporter) : '未知用户'
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityClass(severity: BlacklistSeverity): string {
|
||||||
|
return `severity--${severity}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
loadEntries()
|
||||||
|
startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => groupStore.currentGroupId, (newId, oldId) => {
|
||||||
|
if (newId && newId !== oldId) {
|
||||||
|
expandedPlayer.value = null
|
||||||
|
stopSubscription()
|
||||||
|
loadEntries()
|
||||||
|
startSubscription()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([filterTag, filterSeverity], () => loadEntries())
|
||||||
|
|
||||||
|
onUnmounted(() => stopSubscription())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="player-blacklist-main">
|
||||||
|
<div class="player-blacklist-main__header">
|
||||||
|
<h3 class="player-blacklist-main__title">玩家黑名单</h3>
|
||||||
|
<div class="player-blacklist-main__actions">
|
||||||
|
<div class="player-blacklist-main__filters">
|
||||||
|
<el-input
|
||||||
|
v-model="searchPlayerId"
|
||||||
|
placeholder="搜索玩家ID"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="player-blacklist-main__search"
|
||||||
|
/>
|
||||||
|
<el-select
|
||||||
|
v-model="filterTag"
|
||||||
|
placeholder="标签筛选"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="player-blacklist-main__filter-select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(label, key) in PlayerTagMap"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-select
|
||||||
|
v-model="filterSeverity"
|
||||||
|
placeholder="严重程度"
|
||||||
|
clearable
|
||||||
|
size="small"
|
||||||
|
class="player-blacklist-main__filter-select"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="(label, key) in BlacklistSeverityMap"
|
||||||
|
:key="key"
|
||||||
|
:label="label"
|
||||||
|
:value="key"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<button class="player-blacklist-main__create-btn" @click="showCreate = true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="player-blacklist-main__create-icon">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
标记玩家
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreatePlayerBlacklistDialog v-model="showCreate" @created="handleCreated" />
|
||||||
|
|
||||||
|
<!-- 玩家卡片列表 -->
|
||||||
|
<div v-if="playerGroups.length > 0" class="player-blacklist-main__content">
|
||||||
|
<div v-for="group in playerGroups" :key="group.playerId + group.platform" class="player-blacklist-main__player-group">
|
||||||
|
<!-- 玩家卡片 -->
|
||||||
|
<div class="player-card" @click="togglePlayer(group.playerId + group.platform)">
|
||||||
|
<div class="player-card__info">
|
||||||
|
<div class="player-card__name-row">
|
||||||
|
<span class="player-card__name">{{ group.playerId }}</span>
|
||||||
|
<span class="player-card__platform">{{ group.platform }}</span>
|
||||||
|
<span class="player-card__badge">{{ group.entries.length }} 次标记</span>
|
||||||
|
</div>
|
||||||
|
<div class="player-card__tags">
|
||||||
|
<span
|
||||||
|
v-for="tag in [...new Set(group.entries.flatMap(e => e.tags))].slice(0, 4)"
|
||||||
|
:key="tag"
|
||||||
|
class="player-card__tag"
|
||||||
|
>
|
||||||
|
{{ PlayerTagMap[tag] }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-for="entry in [...new Set(group.entries.map(e => e.customTag).filter(Boolean))].slice(0, 2)"
|
||||||
|
:key="entry"
|
||||||
|
class="player-card__tag player-card__tag--custom"
|
||||||
|
>
|
||||||
|
{{ entry }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="group.entries[0]?.description" class="player-card__desc">
|
||||||
|
{{ group.entries[0].description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="player-card__arrow">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="player-card__arrow-icon">
|
||||||
|
<polyline points="6 9 12 15 18 9" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开的详细记录 -->
|
||||||
|
<div v-if="expandedPlayer === group.playerId + group.platform" class="player-blacklist-main__expanded">
|
||||||
|
<div
|
||||||
|
v-for="(entry, index) in group.entries"
|
||||||
|
:key="entry.id"
|
||||||
|
:class="['entry', { 'entry--bordered': index > 0 }]"
|
||||||
|
>
|
||||||
|
<div class="entry__header">
|
||||||
|
<div class="entry__reporter">
|
||||||
|
<div class="entry__avatar">{{ reporterName(entry).charAt(0) }}</div>
|
||||||
|
<span class="entry__name">{{ reporterName(entry) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry__header-right">
|
||||||
|
<span class="entry__time">{{ formatTime(entry.created) }}</span>
|
||||||
|
<el-popconfirm
|
||||||
|
v-if="canDelete(entry)"
|
||||||
|
title="确定删除?"
|
||||||
|
confirm-button-text="删除"
|
||||||
|
cancel-button-text="取消"
|
||||||
|
@confirm="handleDelete(entry.id)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<button class="entry__delete-btn" title="删除" @click.stop>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="entry__delete-icon">
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="entry__tags">
|
||||||
|
<span v-for="tag in entry.tags" :key="tag" class="entry__tag">{{ PlayerTagMap[tag] }}</span>
|
||||||
|
<span v-if="entry.customTag" class="entry__tag entry__tag--custom">{{ entry.customTag }}</span>
|
||||||
|
<span class="entry__severity" :class="severityClass(entry.severity)">{{ BlacklistSeverityMap[entry.severity] }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="entry__desc">{{ entry.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="player-blacklist-main__loading">
|
||||||
|
<p class="player-blacklist-main__loading-text">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="player-blacklist-main__empty">
|
||||||
|
<svg class="player-blacklist-main__empty-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
<p class="player-blacklist-main__empty-text">暂无玩家黑名单记录</p>
|
||||||
|
<p class="player-blacklist-main__empty-hint">标记坑人的玩家,下次遇到有准备</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.player-blacklist-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--gg-text);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__filters {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__search {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__filter-select {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: var(--gg-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__create-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__create-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__player-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__expanded {
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 var(--gg-radius-md) var(--gg-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 玩家卡片 */
|
||||||
|
.player-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
padding: 16px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card:hover {
|
||||||
|
border-color: var(--gg-primary-light);
|
||||||
|
box-shadow: 0 0 20px rgba(5, 150, 105, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__platform {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__tag--custom {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__arrow {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card__arrow-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card:hover .player-card__arrow-icon {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 记录条目 */
|
||||||
|
.entry {
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry--bordered {
|
||||||
|
border-top: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__reporter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gg-primary);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__delete-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__delete-btn:hover {
|
||||||
|
color: var(--gg-danger);
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__delete-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__tag--custom {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__severity {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity--mild {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--gg-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity--medium {
|
||||||
|
background: rgba(245, 158, 11, 0.1);
|
||||||
|
color: var(--gg-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.severity--severe {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--gg-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry__desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载/空状态 */
|
||||||
|
.player-blacklist-main__loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__loading-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__empty-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__empty-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__empty-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.player-blacklist-main__header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__search {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-blacklist-main__filter-select {
|
||||||
|
width: 90px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { searchGames } from '@/api/games'
|
import { searchGames, getGameCoverUrl } from '@/api/games'
|
||||||
import type { Game } from '@/types'
|
import type { Game } from '@/types'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
|
groupId?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -37,7 +38,7 @@ async function handleSearch() {
|
|||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
games.value = await searchGames(searchQuery.value)
|
games.value = await searchGames(searchQuery.value, props.groupId)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('搜索游戏失败:', error)
|
console.error('搜索游戏失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,7 +83,7 @@ function confirmCustomGame() {
|
|||||||
@click="selectGame(game)"
|
@click="selectGame(game)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="game.cover || '/game-placeholder.svg'"
|
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
|
||||||
:alt="game.name"
|
:alt="game.name"
|
||||||
class="game-cover"
|
class="game-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ async function sendInvite(teamSessionId: string) {
|
|||||||
|
|
||||||
<GameSelectDialog
|
<GameSelectDialog
|
||||||
v-model="showGameSelect"
|
v-model="showGameSelect"
|
||||||
|
:group-id="groupStore.currentGroupId"
|
||||||
@select="handleGameSelect"
|
@select="handleGameSelect"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- src/components/team/TeamSessionPanel.vue -->
|
<!-- src/components/team/TeamSessionPanel.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import { useTeamStore } from '@/stores/team'
|
import { useTeamStore } from '@/stores/team'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
@@ -15,6 +16,7 @@ const userStore = useUserStore()
|
|||||||
const session = computed(() => teamStore.currentSession)
|
const session = computed(() => teamStore.currentSession)
|
||||||
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
|
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
|
||||||
const showGameSelect = ref(false)
|
const showGameSelect = ref(false)
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const memberDetails = computed(() => {
|
const memberDetails = computed(() => {
|
||||||
if (!session.value) return []
|
if (!session.value) return []
|
||||||
@@ -48,6 +50,33 @@ async function endGame() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteLink = computed(() => {
|
||||||
|
if (!session.value) return ''
|
||||||
|
return `${window.location.origin}/join/team/${session.value.id}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function copyInviteLink() {
|
||||||
|
if (!inviteLink.value) return
|
||||||
|
const text = inviteLink.value
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
ElMessage.success('邀请链接已复制')
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const ta = document.createElement('textarea')
|
||||||
|
ta.value = text
|
||||||
|
ta.style.position = 'fixed'
|
||||||
|
ta.style.opacity = '0'
|
||||||
|
document.body.appendChild(ta)
|
||||||
|
ta.select()
|
||||||
|
document.execCommand('copy')
|
||||||
|
document.body.removeChild(ta)
|
||||||
|
ElMessage.success('邀请链接已复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGameSelected(gameName: string) {
|
async function handleGameSelected(gameName: string) {
|
||||||
let group = groupStore.currentGroup
|
let group = groupStore.currentGroup
|
||||||
if (!group) {
|
if (!group) {
|
||||||
@@ -90,6 +119,12 @@ async function handleGameSelected(gameName: string) {
|
|||||||
<span class="game-name">{{ session.gameName }}</span>
|
<span class="game-name">{{ session.gameName }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="invite-link-row">
|
||||||
|
<span class="link-label">邀请:</span>
|
||||||
|
<code class="link-code">{{ inviteLink }}</code>
|
||||||
|
<button class="link-copy-btn" @click="copyInviteLink">复制</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="members-section">
|
<div class="members-section">
|
||||||
<h4 class="members-title">成员 ({{ memberDetails.length }})</h4>
|
<h4 class="members-title">成员 ({{ memberDetails.length }})</h4>
|
||||||
<div class="members-list">
|
<div class="members-list">
|
||||||
@@ -114,6 +149,13 @@ async function handleGameSelected(gameName: string) {
|
|||||||
<button v-else-if="session.status === 'playing'" class="end-game-btn" @click="endGame">
|
<button v-else-if="session.status === 'playing'" class="end-game-btn" @click="endGame">
|
||||||
游戏结束
|
游戏结束
|
||||||
</button>
|
</button>
|
||||||
|
<router-link
|
||||||
|
v-if="session.status === 'recruiting' || session.status === 'playing'"
|
||||||
|
:to="`/group/${route.params.id}/voice/${session.id}`"
|
||||||
|
class="voice-btn"
|
||||||
|
>
|
||||||
|
语音房间
|
||||||
|
</router-link>
|
||||||
<button v-if="session.status === 'recruiting'" class="dissolve-btn" @click="endGame">
|
<button v-if="session.status === 'recruiting'" class="dissolve-btn" @click="endGame">
|
||||||
解散小组
|
解散小组
|
||||||
</button>
|
</button>
|
||||||
@@ -126,7 +168,7 @@ async function handleGameSelected(gameName: string) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GameSelectDialog v-model="showGameSelect" @select="handleGameSelected" />
|
<GameSelectDialog v-model="showGameSelect" :group-id="groupStore.currentGroupId" @select="handleGameSelected" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -172,6 +214,49 @@ async function handleGameSelected(gameName: string) {
|
|||||||
border: 1px solid var(--gg-border);
|
border: 1px solid var(--gg-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-link-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--gg-bg);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-label {
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-code {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copy-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--gg-gradient);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: white;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-copy-btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.game-label {
|
.game-label {
|
||||||
color: var(--gg-text-secondary);
|
color: var(--gg-text-secondary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -315,4 +400,25 @@ async function handleGameSelected(gameName: string) {
|
|||||||
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
|
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<!-- 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 {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<!-- src/components/voice/VoiceMemberGrid.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
|
||||||
|
|
||||||
|
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="'/default-avatar.svg'"
|
||||||
|
:alt="p.name"
|
||||||
|
class="avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="name">{{ p.name }}</span>
|
||||||
|
<span v-if="p.isMuted" class="mic-status muted">静音</span>
|
||||||
|
<span v-else-if="p.isSpeaking" class="mic-status 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-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-status.active {
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-status.muted {
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// src/composables/useVoiceRoom.ts
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Room, RoomEvent, Participant, RemoteTrackPublication } from 'livekit-client'
|
||||||
|
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
|
||||||
|
|
||||||
|
export interface VoiceParticipant {
|
||||||
|
identity: string
|
||||||
|
name: string
|
||||||
|
isSpeaking: boolean
|
||||||
|
isMuted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
function syncParticipant(participant: Participant) {
|
||||||
|
const audioPub = participant.audioTrackPublications.values().next().value
|
||||||
|
participants.value.set(participant.identity, {
|
||||||
|
identity: participant.identity,
|
||||||
|
name: participant.name || participant.identity,
|
||||||
|
isSpeaking: participant.isSpeaking,
|
||||||
|
isMuted: audioPub?.isMuted ?? true,
|
||||||
|
})
|
||||||
|
participants.value = new Map(participants.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(sessionId: string) {
|
||||||
|
try {
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
if (!navigator.mediaDevices?.getUserMedia) {
|
||||||
|
const isElectron = /Electron/.test(navigator.userAgent)
|
||||||
|
if (isElectron) {
|
||||||
|
throw new Error('麦克风权限未获取,请检查 Electron 是否已正确配置安全源和权限处理器。')
|
||||||
|
}
|
||||||
|
throw new Error('浏览器不允许在 HTTP 下使用麦克风。请在 Chrome 地址栏输入 chrome://flags/#unsafely-treat-insecure-origin-as-secure,启用后将 http://192.168.1.14:7033 加入白名单,然后重启浏览器。')
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await fetchVoiceToken(sessionId)
|
||||||
|
const livekitUrl = getLiveKitUrl()
|
||||||
|
|
||||||
|
const newRoom = new Room()
|
||||||
|
|
||||||
|
newRoom.on(RoomEvent.ParticipantConnected, syncParticipant)
|
||||||
|
newRoom.on(RoomEvent.ParticipantDisconnected, (p) => {
|
||||||
|
participants.value.delete(p.identity)
|
||||||
|
participants.value = new Map(participants.value)
|
||||||
|
})
|
||||||
|
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||||
|
const ids = new Set(speakers.map(s => s.identity))
|
||||||
|
for (const [id, p] of participants.value) {
|
||||||
|
p.isSpeaking = ids.has(id)
|
||||||
|
}
|
||||||
|
participants.value = new Map(participants.value)
|
||||||
|
})
|
||||||
|
newRoom.on(RoomEvent.TrackMuted, (_pub, p) => syncParticipant(p))
|
||||||
|
newRoom.on(RoomEvent.TrackUnmuted, (_pub, p) => syncParticipant(p))
|
||||||
|
newRoom.on(RoomEvent.TrackSubscribed, (_track, _pub, p) => syncParticipant(p))
|
||||||
|
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, _pub, p) => syncParticipant(p))
|
||||||
|
|
||||||
|
await newRoom.connect(livekitUrl, token)
|
||||||
|
|
||||||
|
syncParticipant(newRoom.localParticipant as unknown as Participant)
|
||||||
|
for (const p of newRoom.remoteParticipants.values()) {
|
||||||
|
syncParticipant(p as unknown as Participant)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMic() {
|
||||||
|
if (!room.value) return
|
||||||
|
const enabled = !micEnabled.value
|
||||||
|
await room.value.localParticipant.setMicrophoneEnabled(enabled)
|
||||||
|
micEnabled.value = enabled
|
||||||
|
syncParticipant(room.value.localParticipant as unknown as Participant)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 instanceof RemoteTrackPublication) {
|
||||||
|
pub.setEnabled(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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,34 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/GroupView.vue'),
|
component: () => import('@/views/GroupView.vue'),
|
||||||
props: true
|
props: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'group/:groupId/blacklist',
|
||||||
|
name: 'BlacklistView',
|
||||||
|
component: () => import('@/views/BlacklistView.vue'),
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'group/:groupId/voice/:sessionId',
|
||||||
|
name: 'VoiceRoom',
|
||||||
|
component: () => import('@/views/VoiceRoom.vue'),
|
||||||
|
props: true,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'games',
|
path: 'games',
|
||||||
name: 'GamesLibrary',
|
name: 'GamesLibrary',
|
||||||
@@ -55,6 +83,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/join/group/:groupId',
|
||||||
|
name: 'JoinGroup',
|
||||||
|
component: () => import('@/views/JoinGroupPage.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/join/team/:sessionId',
|
||||||
|
name: 'JoinTeam',
|
||||||
|
component: () => import('@/views/JoinTeamPage.vue'),
|
||||||
|
props: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'NotFound',
|
name: 'NotFound',
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Asset } from '@/types'
|
||||||
|
import {
|
||||||
|
listAssets,
|
||||||
|
createAsset,
|
||||||
|
updateAsset,
|
||||||
|
transferAsset,
|
||||||
|
deleteAsset,
|
||||||
|
subscribeAssets
|
||||||
|
} from '@/api/assets'
|
||||||
|
import type { CreateAssetData, UpdateAssetData } from '@/api/assets'
|
||||||
|
|
||||||
|
export const useAssetStore = defineStore('asset', () => {
|
||||||
|
const assets = ref<Asset[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
let unsubFn: (() => Promise<void> | void) | null = null
|
||||||
|
|
||||||
|
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: CreateAssetData) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
await createAsset(data)
|
||||||
|
await loadAssets(data.group)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建资产失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editAsset(assetId: string, data: UpdateAssetData, image?: File) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
await updateAsset(assetId, data, image)
|
||||||
|
// 找到当前资产的 groupId 以刷新列表
|
||||||
|
const asset = assets.value.find(a => a.id === assetId)
|
||||||
|
if (asset) {
|
||||||
|
await loadAssets(asset.group)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新资产失败:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transfer(assetId: string, userId: string) {
|
||||||
|
try {
|
||||||
|
await transferAsset(assetId, userId)
|
||||||
|
const asset = assets.value.find(a => a.id === assetId)
|
||||||
|
if (asset) {
|
||||||
|
await loadAssets(asset.group)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('转移资产失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAsset(assetId: string) {
|
||||||
|
try {
|
||||||
|
await deleteAsset(assetId)
|
||||||
|
const asset = assets.value.find(a => a.id === assetId)
|
||||||
|
assets.value = assets.value.filter(a => a.id !== assetId)
|
||||||
|
// 返回 groupId 以便调用方需要时使用
|
||||||
|
return asset?.group
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除资产失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSubscription(groupId: string) {
|
||||||
|
stopSubscription()
|
||||||
|
unsubFn = await subscribeAssets(groupId, () => {
|
||||||
|
loadAssets(groupId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSubscription() {
|
||||||
|
if (unsubFn) {
|
||||||
|
unsubFn()
|
||||||
|
unsubFn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
assets,
|
||||||
|
loading,
|
||||||
|
loadAssets,
|
||||||
|
addAsset,
|
||||||
|
editAsset,
|
||||||
|
transfer,
|
||||||
|
removeAsset,
|
||||||
|
startSubscription,
|
||||||
|
stopSubscription
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { BulletinPost, BulletinRead } from '@/types'
|
||||||
|
import {
|
||||||
|
listBulletins,
|
||||||
|
listMyReads,
|
||||||
|
markBulletinAsRead,
|
||||||
|
createBulletin,
|
||||||
|
updateBulletin,
|
||||||
|
deleteBulletin,
|
||||||
|
} from '@/api/bulletins'
|
||||||
|
|
||||||
|
export const useBulletinStore = defineStore('bulletin', () => {
|
||||||
|
const posts = ref<BulletinPost[]>([])
|
||||||
|
const reads = ref<BulletinRead[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const now = computed(() => new Date())
|
||||||
|
|
||||||
|
const activePosts = computed(() => {
|
||||||
|
return posts.value.filter((p) => {
|
||||||
|
if (!p.expiresAt) return true
|
||||||
|
return new Date(p.expiresAt) > now.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const pinnedPosts = computed(() =>
|
||||||
|
activePosts.value
|
||||||
|
.filter((p) => p.pinned)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const prioOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
|
||||||
|
return prioOrder[a.priority] - prioOrder[b.priority]
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const normalPosts = computed(() =>
|
||||||
|
activePosts.value
|
||||||
|
.filter((p) => !p.pinned)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const prioOrder = { urgent: 0, high: 1, normal: 2, low: 3 }
|
||||||
|
const prioDiff = prioOrder[a.priority] - prioOrder[b.priority]
|
||||||
|
if (prioDiff !== 0) return prioDiff
|
||||||
|
return new Date(b.created).getTime() - new Date(a.created).getTime()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const readPostIds = computed(() => new Set(reads.value.map((r) => r.post)))
|
||||||
|
|
||||||
|
function isRead(postId: string): boolean {
|
||||||
|
return readPostIds.value.has(postId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function unreadCount(groupId: string): number {
|
||||||
|
return activePosts.value.filter(
|
||||||
|
(p) => p.group === groupId && !isRead(p.id)
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPosts(groupId: string) {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const [fetchedPosts, fetchedReads] = await Promise.all([
|
||||||
|
listBulletins(groupId),
|
||||||
|
listMyReads(),
|
||||||
|
])
|
||||||
|
posts.value = fetchedPosts
|
||||||
|
reads.value = fetchedReads
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载公告列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAsRead(postId: string) {
|
||||||
|
if (isRead(postId)) return
|
||||||
|
try {
|
||||||
|
const record = await markBulletinAsRead(postId)
|
||||||
|
if (record) {
|
||||||
|
reads.value.push(record)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('标记已读失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllAsRead(groupId: string) {
|
||||||
|
const unread = activePosts.value.filter(
|
||||||
|
(p) => p.group === groupId && !isRead(p.id)
|
||||||
|
)
|
||||||
|
await Promise.all(unread.map((p) => markAsRead(p.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create(data: {
|
||||||
|
group: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
priority: 'low' | 'normal' | 'high' | 'urgent'
|
||||||
|
pinned: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const post = await createBulletin(data)
|
||||||
|
posts.value.unshift(post)
|
||||||
|
return post
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('创建公告失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(
|
||||||
|
postId: string,
|
||||||
|
data: {
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
priority?: 'low' | 'normal' | 'high' | 'urgent'
|
||||||
|
pinned?: boolean
|
||||||
|
expiresAt?: string
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const post = await updateBulletin(postId, data)
|
||||||
|
const index = posts.value.findIndex((p) => p.id === postId)
|
||||||
|
if (index !== -1) {
|
||||||
|
posts.value[index] = { ...posts.value[index], ...post }
|
||||||
|
}
|
||||||
|
return post
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('更新公告失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(postId: string) {
|
||||||
|
try {
|
||||||
|
await deleteBulletin(postId)
|
||||||
|
posts.value = posts.value.filter((p) => p.id !== postId)
|
||||||
|
reads.value = reads.value.filter((r) => r.post !== postId)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('删除公告失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
posts.value = []
|
||||||
|
reads.value = []
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
reads,
|
||||||
|
loading,
|
||||||
|
activePosts,
|
||||||
|
pinnedPosts,
|
||||||
|
normalPosts,
|
||||||
|
readPostIds,
|
||||||
|
isRead,
|
||||||
|
unreadCount,
|
||||||
|
loadPosts,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
create,
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
}
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user