Compare commits

13 Commits

Author SHA1 Message Date
congsh 3c2b68bbc3 fix(electron): enable mediaDevices on HTTP origins and fix voice auth
- Add --unsafely-treat-insecure-origin-as-secure flag for dev/uat URLs
- Set auto-granted permission handlers for mic/camera in main process
- Adapt useVoiceRoom error message for Electron (no Chrome flags hint)
- Add debug logging to voice-token service and frontend voice API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-20 18:08:05 +08:00
congsh 4c7152ff50 feat(electron): add Electron desktop wrapper
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:54:27 +08:00
congsh 10574845f6 docs: add v0.3.2 changelog entry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:48:35 +08:00
congsh 6b9fef1d69 fix(voice): add clear error message for HTTP mediaDevices restriction
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:42:29 +08:00
congsh c01aef48bd fix(voice): set LiveKit node-ip to host IP for WebRTC ICE connectivity
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:39:01 +08:00
congsh 2ed582faf0 fix(voice): upgrade LiveKit server from v1.7 to v1.10 for client v2 compatibility
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:36:01 +08:00
congsh f96652a8aa fix(voice): fix LiveKit LIVEKIT_KEYS env format in docker-compose
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:29:47 +08:00
congsh 5d434ead6f feat(voice): add real-time voice room with LiveKit
- LiveKit WebRTC SFU container in docker-compose
- Voice token microservice (Node.js + Express)
- VoiceRoom page with member grid and controls
- useVoiceRoom composable for LiveKit connection
- Voice entry button in TeamSessionPanel
- Nginx proxy for voice-token service API

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:24:28 +08:00
congsh 9d224e2fcd feat(voice): add livekit-client dependency
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 22:12:51 +08:00
congsh 81abb4b220 feat(voice): add LiveKit server container to docker-compose
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 21:06:21 +08:00
congsh c3d34c4660 docs: add v0.3.1 changelog entry
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:30:55 +08:00
congsh d528358867 feat: 玩家黑名单 - 记录外部平台坑玩家
- 新增 player_blacklist collection 迁移
- 添加 PlayerTag/PlayerBlacklistEntry 类型定义和 API
- 创建 PlayerBlacklistMain + CreatePlayerBlacklistDialog 组件
- BlacklistView 支持 Tab 切换游戏/玩家黑名单
- 支持搜索、标签筛选、严重程度筛选、实时订阅

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 13:03:55 +08:00
congsh 60ad9a04cd feat: phase 4 - 积分竞猜和游戏黑名单 v0.3.0
竞猜功能:发起竞猜、下注、关闭、开奖、奖池分配
黑名单功能:标记游戏、按原因/严重程度筛选、详情展开
修复:双重结算、TOCTOU竞态、订阅泄漏、选项选择兼容性

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 00:21:43 +08:00
51 changed files with 7171 additions and 29 deletions
+8 -1
View File
@@ -35,4 +35,11 @@ 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/
+24 -5
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概述 ## 项目概述
Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库。 Game Group V2 — 游戏组队管理平台。用户创建/加入群组,组队开黑,管理游戏库、投票、积分竞猜、账本和资产
## 开发命令 ## 开发命令
@@ -20,6 +20,11 @@ cd frontend && npm run dev
./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033) ./deploy-dev.sh # 构建 + 部署 Dev 前端 (端口 7033)
./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712) ./deploy-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
./stop-all.sh # 停止所有服务 ./stop-all.sh # 停止所有服务
# 查看日志
docker logs -f gamegroup-pb # 后端
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 前必须等用户确认。
@@ -48,16 +53,16 @@ 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`
- **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})` - **`stores/`** — Pinia stores,组合式 API 风格(`defineStore('name', () => {...})`
- **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理 - **`composables/useRealtime.ts`** — 统一管理 PocketBase 实时订阅,组件卸载时自动清理
- **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 - **`types/index.ts`** — 所有接口集中定义 + `displayName()` 工具函数 + 状态映射常量(如 `UserStatusMap``TeamStatusMap`
### 认证流程 ### 认证流程
@@ -65,6 +70,10 @@ 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`), 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。
@@ -78,6 +87,15 @@ pocketbase.ts (PB 客户端初始化)
- **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
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
- **notifications** — 站内通知(投票/组队/入群等事件)
### PocketBase 注意事项 ### PocketBase 注意事项
@@ -86,3 +104,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 自动取消请求
@@ -0,0 +1,114 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "gblacklist_col",
"created": "2026-04-18 21:00:01.000Z",
"updated": "2026-04-18 21:00:01.000Z",
"name": "game_blacklist",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "gb_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gb_reporter",
"name": "reporter",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gb_game",
"name": "game",
"type": "relation",
"required": false,
"options": {
"collectionId": "x5adjlc0txf16r8",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gb_gamename",
"name": "gameName",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "gb_reason",
"name": "reason",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["behavior", "cheating", "abandonment", "toxic", "other"]
}
},
{
"system": false,
"id": "gb_desc",
"name": "description",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "gb_severity",
"name": "severity",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["mild", "medium", "severe"]
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": null,
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("gblacklist_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,149 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "bets_col",
"created": "2026-04-18 21:00:02.000Z",
"updated": "2026-04-18 21:00:02.000Z",
"name": "bets",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "bt_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "bt_creator",
"name": "creator",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "bt_title",
"name": "title",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "bt_desc",
"name": "description",
"type": "text",
"required": false,
"options": {
"min": null,
"max": 1000,
"pattern": ""
}
},
{
"system": false,
"id": "bt_minstake",
"name": "minStake",
"type": "number",
"required": true,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
},
{
"system": false,
"id": "bt_maxstake",
"name": "maxStake",
"type": "number",
"required": true,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
},
{
"system": false,
"id": "bt_status",
"name": "status",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["open", "closed", "settled"]
}
},
{
"system": false,
"id": "bt_result",
"name": "resultOption",
"type": "relation",
"required": false,
"options": {
"collectionId": "betopts_col",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "bt_deadline",
"name": "deadline",
"type": "date",
"required": true,
"options": {
"min": "",
"max": ""
}
},
{
"system": false,
"id": "bt_settledat",
"name": "settledAt",
"type": "date",
"required": false,
"options": {
"min": "",
"max": ""
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": "creator = @request.auth.id",
"deleteRule": "creator = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("bets_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,64 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "betopts_col",
"created": "2026-04-18 21:00:03.000Z",
"updated": "2026-04-18 21:00:03.000Z",
"name": "bet_options",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "bo_bet",
"name": "bet",
"type": "relation",
"required": true,
"options": {
"collectionId": "bets_col",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "bo_content",
"name": "content",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "bo_order",
"name": "order",
"type": "number",
"required": true,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"updateRule": "bet.creator = @request.auth.id",
"deleteRule": "bet.creator = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("betopts_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,88 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "betentries_col",
"created": "2026-04-18 21:00:04.000Z",
"updated": "2026-04-18 21:00:04.000Z",
"name": "bet_entries",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "be_bet",
"name": "bet",
"type": "relation",
"required": true,
"options": {
"collectionId": "bets_col",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "be_user",
"name": "user",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "be_option",
"name": "option",
"type": "relation",
"required": true,
"options": {
"collectionId": "betopts_col",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "be_stake",
"name": "stake",
"type": "number",
"required": true,
"options": {
"min": 1,
"max": null,
"noDecimal": true
}
},
{
"system": false,
"id": "be_won",
"name": "won",
"type": "bool",
"required": false,
"options": {}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && bet.group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && user = @request.auth.id && bet.group.members ~ @request.auth.id",
"updateRule": "bet.creator = @request.auth.id",
"deleteRule": "user = @request.auth.id && bet.status = \"open\"",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("betentries_col");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,116 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "sd27cbh8",
"name": "action",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"vote",
"team",
"memory",
"bet",
"settle"
]
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "iszqa13h",
"name": "points",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"noDecimal": false
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "pnipfzbd",
"name": "relatedId",
"type": "text",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("h5adxdw9gm0aw8s")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "sd27cbh8",
"name": "action",
"type": "select",
"required": true,
"presentable": false,
"unique": false,
"options": {
"maxSelect": 1,
"values": [
"vote",
"team",
"memory"
]
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "iszqa13h",
"name": "points",
"type": "number",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": 1,
"max": null,
"noDecimal": false
}
}))
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "pnipfzbd",
"name": "relatedId",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
}))
return dao.saveCollection(collection)
})
@@ -0,0 +1,124 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "pblacklist_col",
"created": "2026-04-19 10:00:01.000Z",
"updated": "2026-04-19 10:00:01.000Z",
"name": "player_blacklist",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "pb_group",
"name": "group",
"type": "relation",
"required": true,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "pb_reporter",
"name": "reporter",
"type": "relation",
"required": true,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": true,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "pb_playerid",
"name": "playerId",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 200,
"pattern": ""
}
},
{
"system": false,
"id": "pb_platform",
"name": "platform",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 100,
"pattern": ""
}
},
{
"system": false,
"id": "pb_tags",
"name": "tags",
"type": "select",
"required": true,
"options": {
"maxSelect": 5,
"values": ["afk", "feeder", "toxic", "cheater", "quitter", "noob", "fragile", "other"]
}
},
{
"system": false,
"id": "pb_customtag",
"name": "customTag",
"type": "text",
"required": false,
"options": {
"min": null,
"max": 50,
"pattern": ""
}
},
{
"system": false,
"id": "pb_desc",
"name": "description",
"type": "text",
"required": true,
"options": {
"min": 1,
"max": 500,
"pattern": ""
}
},
{
"system": false,
"id": "pb_severity",
"name": "severity",
"type": "select",
"required": true,
"options": {
"maxSelect": 1,
"values": ["mild", "medium", "severe"]
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
"updateRule": null,
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("pblacklist_col");
return dao.deleteCollection(collection);
})
+7
View File
@@ -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"]
+12
View File
@@ -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"
}
}
+87
View File
@@ -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}`)
})
+30
View File
@@ -20,6 +20,36 @@ services:
networks: networks:
- gamegroup-net - gamegroup-net
livekit:
image: livekit/livekit-server:v1.10
container_name: gamegroup-livekit
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:
build:
context: ./backend/voice-token-service
container_name: gamegroup-voice-token
ports:
- "7882:7882"
environment:
- LIVEKIT_API_KEY=APIyxZGQjM2
- LIVEKIT_API_SECRET=secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi
- PB_URL=http://gamegroup-pb:8090
restart: unless-stopped
depends_on:
- pocketbase
networks:
- gamegroup-net
networks: networks:
gamegroup-net: gamegroup-net:
driver: bridge driver: bridge
+30
View File
@@ -20,6 +20,36 @@ services:
networks: networks:
- gamegroup-net - gamegroup-net
livekit:
image: livekit/livekit-server:v1.10
container_name: gamegroup-livekit
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:
build:
context: ./backend/voice-token-service
container_name: gamegroup-voice-token
ports:
- "7882: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
@@ -0,0 +1,74 @@
# 语音房间功能设计
## 概述
在组队功能中加入实时语音通话,基于 LiveKit(开源 WebRTC SFU),独立语音房间页面。
## 架构
```
Vue 前端 (VoiceRoom) ←→ PocketBase (token 签发 hook) ←→ LiveKit (音视频 SFU)
```
## 技术选型
- **LiveKit Server** — 开源 WebRTC SFUDocker 部署
- **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"
```
+1
View File
@@ -0,0 +1 @@
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
+72
View File
@@ -0,0 +1,72 @@
const { app, BrowserWindow, session } = require('electron')
const path = require('path')
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
}
// 在 Chromium 启动前将 HTTP 内网地址标记为安全源,
// 否则 navigator.mediaDevices 在 HTTP 非 localhost 下会被置空
const insecureOrigins = Object.values(ENV_URLS).join(',')
app.commandLine.appendSwitch('unsafely-treat-insecure-origin-as-secure', insecureOrigins)
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,
},
})
const url = getWindowUrl()
win.loadURL(url)
// 页面标题同步
win.on('page-title-updated', (event, title) => {
event.preventDefault()
win.setTitle(`Game Group - ${title}`)
})
}
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
})
createWindow()
})
app.on('window-all-closed', () => {
app.quit()
})
+41
View File
@@ -0,0 +1,41 @@
{
"name": "gamegroup-electron",
"version": "0.3.2",
"description": "Game Group V2 桌面客户端",
"main": "main.js",
"scripts": {
"start": "electron .",
"start:dev": "electron . --env=dev",
"start:uat": "electron . --env=uat",
"build": "electron-builder --win",
"build:portable": "electron-builder --win portable"
},
"dependencies": {
"electron-store": "^8.2"
},
"devDependencies": {
"electron": "^35.0",
"electron-builder": "^26.0"
},
"build": {
"appId": "com.gamegroup.v2",
"productName": "GameGroup",
"directories": {
"output": "dist"
},
"win": {
"target": [
{
"target": "portable",
"arch": ["x64"]
}
],
"icon": "build/icon.ico"
},
"files": [
"main.js",
"preload.js",
"build/**/*"
]
}
}
+6
View File
@@ -0,0 +1,6 @@
// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
platform: process.platform,
})
+2
View File
@@ -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:7882
+2
View File
@@ -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:7880
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
+9
View File
@@ -30,6 +30,15 @@ server {
gzip off; gzip off;
} }
# Voice token service proxy
location /voice-api/ {
proxy_pass http://192.168.1.14:7882/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;
}
# API 代理到局域网 PocketBase # API 代理到局域网 PocketBase
location /api/ { location /api/ {
client_max_body_size 500m; client_max_body_size 500m;
+9
View File
@@ -30,6 +30,15 @@ server {
gzip off; gzip off;
} }
# Voice token service proxy
location /voice-api/ {
proxy_pass http://192.168.1.14:7882/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;
}
# API 代理到局域网 PocketBase # API 代理到局域网 PocketBase
location /api/ { location /api/ {
proxy_pass http://192.168.1.14:8712; proxy_pass http://192.168.1.14:8712;
+11 -10
View File
@@ -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"
} }
} }
+218
View File
@@ -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)
})
}
+57
View File
@@ -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)
})
}
+59
View File
@@ -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)
})
}
+2 -1
View File
@@ -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> {
+34
View File
@@ -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
}
+320
View File
@@ -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>
+650
View File
@@ -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>
+344
View File
@@ -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,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>
@@ -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,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 []
@@ -114,6 +116,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>
@@ -315,4 +324,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>
+126
View File
@@ -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,
}
}
+14
View File
@@ -47,6 +47,20 @@ const routes: RouteRecordRaw[] = [
props: true, props: true,
meta: { requiresAuth: 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',
+124 -1
View File
@@ -85,6 +85,8 @@ export interface TeamSession {
members: string[] members: string[]
status: TeamStatus status: TeamStatus
dissolvedAt?: string dissolvedAt?: string
voiceRoom?: string
voiceActive?: boolean
created: string created: string
updated: string updated: string
expand?: { expand?: {
@@ -235,7 +237,7 @@ export interface AppNotification {
} }
// 积分行为类型 // 积分行为类型
export type PointAction = 'vote' | 'team' | 'memory' export type PointAction = 'vote' | 'team' | 'memory' | 'bet'
// 积分流水 // 积分流水
export interface PointLog { export interface PointLog {
@@ -334,6 +336,127 @@ export interface Asset {
} }
} }
// 游戏黑名单原因
export type BlacklistReason = 'behavior' | 'cheating' | 'abandonment' | 'toxic' | 'other'
export const BlacklistReasonMap: Record<BlacklistReason, string> = {
behavior: '队友坑',
cheating: '外挂多',
abandonment: '坑货多',
toxic: '环境差',
other: '其他'
}
// 玩家黑名单标签
export type PlayerTag = 'afk' | 'feeder' | 'toxic' | 'cheater' | 'quitter' | 'noob' | 'fragile' | 'other'
export const PlayerTagMap: Record<PlayerTag, string> = {
afk: '挂机',
feeder: '送人头',
toxic: '喷人',
cheater: '外挂',
quitter: '始乱终弃',
noob: '坑货',
fragile: '玻璃心',
other: '其他'
}
// 黑名单严重程度
export type BlacklistSeverity = 'mild' | 'medium' | 'severe'
export const BlacklistSeverityMap: Record<BlacklistSeverity, string> = {
mild: '轻微',
medium: '中等',
severe: '严重'
}
// 游戏黑名单
export interface BlacklistEntry {
id: string
group: string
reporter: string
game?: string
gameName: string
reason: BlacklistReason
description: string
severity: BlacklistSeverity
created: string
updated: string
expand?: {
reporter?: User
group?: Group
game?: Game
}
}
// 玩家黑名单
export interface PlayerBlacklistEntry {
id: string
group: string
reporter: string
playerId: string
platform: string
tags: PlayerTag[]
customTag?: string
description: string
severity: BlacklistSeverity
created: string
updated: string
expand?: {
reporter?: User
group?: Group
}
}
// 竞猜状态
export type BetStatus = 'open' | 'closed' | 'settled'
// 竞猜
export interface Bet {
id: string
group: string
creator: string
title: string
description?: string
minStake: number
maxStake: number
status: BetStatus
resultOption?: string
deadline: string
settledAt?: string
created: string
updated: string
expand?: {
creator?: User
group?: Group
resultOption?: BetOption
}
}
// 竞猜选项
export interface BetOption {
id: string
bet: string
content: string
order: number
}
// 下注记录
export interface BetEntry {
id: string
bet: string
user: string
option: string
stake: number
won?: boolean
created: string
updated: string
expand?: {
user?: User
option?: BetOption
}
}
// 获取用户显示名称(优先 name,回退 username // 获取用户显示名称(优先 name,回退 username
export function displayName(user?: { name?: string; username: string } | null): string { export function displayName(user?: { name?: string; username: string } | null): string {
return user?.name || user?.username || '未知' return user?.name || user?.username || '未知'
+170
View File
@@ -0,0 +1,170 @@
<!-- src/views/BlacklistView.vue -->
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import BlacklistMain from '@/components/gameBlacklist/BlacklistMain.vue'
import PlayerBlacklistMain from '@/components/playerBlacklist/PlayerBlacklistMain.vue'
import { ArrowLeft } from '@element-plus/icons-vue'
const route = useRoute()
const groupStore = useGroupStore()
const groupId = route.params.groupId as string
const group = computed(() => groupStore.currentGroup)
const activeTab = ref<'game' | 'player'>('game')
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
})
</script>
<template>
<div class="blacklist-view">
<!-- 页面头部 -->
<section class="page-header">
<router-link :to="`/group/${groupId}`" class="back-link">
<el-icon><ArrowLeft /></el-icon> 返回群组
</router-link>
<div class="header-content">
<h1 class="page-title">黑名单</h1>
<span class="group-badge">{{ group?.name }}</span>
</div>
</section>
<!-- Tab 切换 -->
<div class="blacklist-tabs">
<button
:class="['blacklist-tab', { 'blacklist-tab--active': activeTab === 'game' }]"
@click="activeTab = 'game'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-tab__icon">
<rect x="2" y="6" width="20" height="12" rx="2" />
<path d="M6 12h4" />
<path d="M6 9h4" />
</svg>
游戏黑名单
</button>
<button
:class="['blacklist-tab', { 'blacklist-tab--active': activeTab === 'player' }]"
@click="activeTab = 'player'"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="blacklist-tab__icon">
<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>
玩家黑名单
</button>
</div>
<!-- 内容 -->
<BlacklistMain v-if="activeTab === 'game'" />
<PlayerBlacklistMain v-else />
</div>
</template>
<style scoped>
.blacklist-view {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
padding: 20px 24px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-lg);
position: relative;
overflow: hidden;
}
.page-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: var(--gg-gradient);
}
.back-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--gg-text-muted);
text-decoration: none;
font-size: 13px;
font-weight: 500;
transition: color 0.2s;
margin-bottom: 12px;
}
.back-link:hover {
color: var(--gg-primary);
}
.header-content {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.group-badge {
font-size: 13px;
padding: 4px 12px;
border-radius: 20px;
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary-light);
font-weight: 500;
}
/* Tab */
.blacklist-tabs {
display: flex;
gap: 8px;
}
.blacklist-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
background: var(--gg-bg-card);
color: var(--gg-text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
-webkit-tap-highlight-color: transparent;
}
.blacklist-tab:hover {
border-color: var(--gg-primary-light);
color: var(--gg-primary);
}
.blacklist-tab--active {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.08);
color: var(--gg-primary);
font-weight: 600;
}
.blacklist-tab__icon {
width: 16px;
height: 16px;
}
</style>
+48
View File
@@ -10,6 +10,54 @@ interface LogEntry {
} }
const logs = ref<LogEntry[]>([ const logs = ref<LogEntry[]>([
{
version: 'v0.3.2',
date: '2026-04-19',
title: '实时语音房间',
items: [
{ type: 'feat', text: '语音房间:组队中可进入独立语音房间,实时语音通话(基于 LiveKit WebRTC' },
{ type: 'feat', text: '成员头像网格:显示在线成员,说话时绿圈呼吸动画提示' },
{ type: 'feat', text: '麦克风/扬声器开关:独立控制麦克风和扬声器' },
{ type: 'feat', text: 'LiveKit 后端服务:Docker 部署 LiveKit SFU + Token 签发微服务' },
{ type: 'fix', text: 'LiveKit 服务端升级到 v1.10 兼容客户端 v2 SDK' },
{ type: 'fix', text: '修复 WebRTC ICE 连接失败,配置 node-ip 为宿主机地址' },
]
},
{
version: 'v0.3.1',
date: '2026-04-19',
title: '玩家黑名单',
items: [
{ type: 'feat', text: '玩家黑名单:标记外部平台坑玩家(挂机、送人头、喷人、外挂等),记录玩家ID和游戏平台' },
{ type: 'feat', text: '玩家卡片聚合:按玩家ID+平台聚合展示,显示被标记次数和所有标签' },
{ type: 'feat', text: '展开详情:点击玩家卡片展开查看所有标记记录,含举报人、时间、严重程度' },
{ type: 'feat', text: '搜索筛选:按玩家ID搜索、按标签和严重程度筛选' },
{ type: 'feat', text: '黑名单页面 Tab 切换:游戏黑名单与玩家黑名单同一页面切换展示' },
{ type: 'feat', text: '自定义标签:除预定义标签外支持填写自定义标签' },
{ type: 'feat', text: '实时订阅:玩家黑名单变更实时更新,无需刷新页面' },
]
},
{
version: 'v0.3.0',
date: '2026-04-19',
title: '四期功能:积分竞猜、游戏黑名单',
items: [
{ type: 'feat', text: '积分竞猜:群组成员可发起竞猜,设置选项、截止时间、下注积分范围' },
{ type: 'feat', text: '竞猜下注:成员选择选项并下注积分,每人仅限下注一次' },
{ type: 'feat', text: '竞猜开奖:发起人关闭竞猜后选择正确答案,奖池按比例分配给赢家' },
{ type: 'feat', text: '竞猜自动关闭:到达截止时间自动关闭竞猜' },
{ type: 'feat', text: '游戏黑名单:标记体验差的游戏(外挂、挂机、环境差等),提醒队友避坑' },
{ type: 'feat', text: '黑名单筛选:按原因(队友坑/外挂多/坑货多/环境差)和严重程度筛选' },
{ type: 'feat', text: '黑名单详情展开:点击游戏卡片展开查看所有标记记录' },
{ type: 'feat', text: '竞猜列表展示奖池进度条和「已下注」状态标签' },
{ type: 'fix', text: '修复下注选项在 Chrome/Edge 无法选中的问题,改为按钮卡片式选择' },
{ type: 'fix', text: '修复 point_logs schema 不支持竞猜动作和负数积分的问题' },
{ type: 'fix', text: '修复竞猜双重结算和 TOCTOU 竞态安全漏洞' },
{ type: 'fix', text: '修复黑名单条目允许非创建者修改的安全问题' },
{ type: 'fix', text: '修复 BetDetail 订阅累积和群组切换时订阅未清理的内存泄漏' },
{ type: 'fix', text: '修复 GroupView 定时器变量覆盖导致内存泄漏' },
]
},
{ {
version: 'v0.2.0', version: 'v0.2.0',
date: '2026-04-18', date: '2026-04-18',
+57 -10
View File
@@ -6,6 +6,7 @@ import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team' import { useTeamStore } from '@/stores/team'
import { pb } from '@/api/pocketbase' import { pb } from '@/api/pocketbase'
import { settlePoll } from '@/api/polls' import { settlePoll } from '@/api/polls'
import { closeBet } from '@/api/bets'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue' import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
import IdleMembersList from '@/components/team/IdleMembersList.vue' import IdleMembersList from '@/components/team/IdleMembersList.vue'
import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue' import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue'
@@ -13,18 +14,22 @@ import PollList from '@/components/poll/PollList.vue'
import PollDetail from '@/components/poll/PollDetail.vue' import PollDetail from '@/components/poll/PollDetail.vue'
import MemoryGrid from '@/components/memory/MemoryGrid.vue' import MemoryGrid from '@/components/memory/MemoryGrid.vue'
import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue' import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue'
import { Promotion, Opportunity, UserFilled, Wallet, Box } from '@element-plus/icons-vue' import BetList from '@/components/bet/BetList.vue'
import { DataLine, PictureFilled, TrendCharts } from '@element-plus/icons-vue' import BetDetail from '@/components/bet/BetDetail.vue'
import { Promotion, Opportunity, UserFilled, Wallet, Box, Warning } from '@element-plus/icons-vue'
import { DataLine, PictureFilled, TrendCharts, Trophy } from '@element-plus/icons-vue'
const route = useRoute() const route = useRoute()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const teamStore = useTeamStore() const teamStore = useTeamStore()
const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : 'activity') const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : route.query.tab === 'bets' ? 'bets' : 'activity')
const viewingPollId = ref<string | null>(null) const viewingPollId = ref<string | null>(null)
const viewingBetId = ref<string | null>(null)
const unsubFns: (() => Promise<void>)[] = [] const unsubFns: (() => Promise<void>)[] = []
let settleTimer: ReturnType<typeof setInterval> | null = null let pollTimer: ReturnType<typeof setInterval> | null = null
let betTimer: ReturnType<typeof setInterval> | null = null
const groupId = route.params.id as string const groupId = route.params.id as string
@@ -89,7 +94,10 @@ onMounted(async () => {
})) }))
// 投票结算定时器:每 60 秒检查过期投票 // 投票结算定时器:每 60 秒检查过期投票
settleTimer = setInterval(checkExpiredPolls, 60000) pollTimer = setInterval(checkExpiredPolls, 60000)
// 竞猜自动关闭定时器:每 60 秒检查
betTimer = setInterval(checkExpiredBets, 60000)
}) })
onUnmounted(async () => { onUnmounted(async () => {
@@ -97,9 +105,13 @@ onUnmounted(async () => {
try { await unsub() } catch (_) {} try { await unsub() } catch (_) {}
} }
unsubFns.length = 0 unsubFns.length = 0
if (settleTimer) { if (pollTimer) {
clearInterval(settleTimer) clearInterval(pollTimer)
settleTimer = null pollTimer = null
}
if (betTimer) {
clearInterval(betTimer)
betTimer = null
} }
}) })
@@ -113,8 +125,6 @@ async function checkExpiredPolls() {
if (!currentGroupId || !user) return if (!currentGroupId || !user) return
try { try {
// 优化:只有当前登录用户是投票发起人时,才主动去结算自己发起的投票
// 这避免了群里有 10 个成员在线时,10 个客户端同时发起请求的问题
const result = await pb.collection('polls').getList(1, 50, { const result = await pb.collection('polls').getList(1, 50, {
filter: `group="${currentGroupId}" && status="active" && deadline!="" && creator="${user.id}"`, filter: `group="${currentGroupId}" && status="active" && deadline!="" && creator="${user.id}"`,
$autoCancel: false $autoCancel: false
@@ -133,6 +143,31 @@ async function checkExpiredPolls() {
console.error('检查过期投票失败:', e) console.error('检查过期投票失败:', e)
} }
} }
async function checkExpiredBets() {
const currentGroupId = groupStore.currentGroupId
const user = pb.authStore.model
if (!currentGroupId || !user) return
try {
const result = await pb.collection('bets').getList(1, 50, {
filter: `group="${currentGroupId}" && status="open" && creator="${user.id}"`,
$autoCancel: false
})
const now = new Date()
for (const bet of result.items as any[]) {
if (bet.deadline && new Date(bet.deadline) <= now) {
try {
await closeBet(bet.id)
} catch (e) {
console.error('关闭竞猜失败:', bet.id, e)
}
}
}
} catch (e) {
console.error('检查过期竞猜失败:', e)
}
}
</script> </script>
<template> <template>
@@ -162,6 +197,10 @@ async function checkExpiredPolls() {
<router-link :to="`/group/${groupId}/assets`" class="meta-link"> <router-link :to="`/group/${groupId}/assets`" class="meta-link">
<el-icon><Box /></el-icon> 资产 <el-icon><Box /></el-icon> 资产
</router-link> </router-link>
<span class="meta-divider">|</span>
<router-link :to="`/group/${groupId}/blacklist`" class="meta-link">
<el-icon><Warning /></el-icon> 黑名单
</router-link>
</div> </div>
</section> </section>
@@ -251,6 +290,14 @@ async function checkExpiredPolls() {
</template> </template>
<GroupStatsPanel /> <GroupStatsPanel />
</el-tab-pane> </el-tab-pane>
<el-tab-pane name="bets">
<template #label>
<span class="fancy-tab"><el-icon><Trophy /></el-icon> 竞猜</span>
</template>
<BetDetail v-if="viewingBetId" :bet-id="viewingBetId" @back="viewingBetId = null" />
<BetList v-else @view-bet="viewingBetId = $event" />
</el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
</template> </template>
+160
View File
@@ -0,0 +1,160 @@
<!-- 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 { 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 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 () => {
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)
})
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>