Compare commits
11 Commits
60ad9a04cd
...
4c7152ff50
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c7152ff50 | |||
| 10574845f6 | |||
| 6b9fef1d69 | |||
| c01aef48bd | |||
| 2ed582faf0 | |||
| f96652a8aa | |||
| 5d434ead6f | |||
| 9d224e2fcd | |||
| 81abb4b220 | |||
| c3d34c4660 | |||
| d528358867 |
@@ -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-uat.sh # 构建 + 部署 UAT 前端 + 后端 (端口 7034/8712)
|
||||
./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 前必须等用户确认。
|
||||
@@ -48,16 +53,16 @@ Docker Compose 文件:`docker-compose.backend.yml`、`docker-compose.dev.yml`
|
||||
```
|
||||
pocketbase.ts (PB 客户端初始化)
|
||||
→ router guards (isAuthenticated 检查)
|
||||
→ stores (user/group/team/notification)
|
||||
→ api/ (PocketBase CRUD 封装)
|
||||
→ stores (user/group/team/notification/poll/ledger/asset/memory)
|
||||
→ api/ (PocketBase CRUD 封装,每个领域一个文件)
|
||||
→ components + views
|
||||
```
|
||||
|
||||
- **`api/pocketbase.ts`** — 单例 PocketBase 客户端,导出 `pb`、`getCurrentUser()`、`isAuthenticated()`、`logout()`
|
||||
- **`api/`** — 每个领域一个文件(`users.ts`, `groups.ts`, `sessions.ts`, `invitations.ts`, `games.ts`),封装 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', () => {...})`)
|
||||
- **`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)`
|
||||
- 路由守卫:`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 将 `/api` 代理到 PocketBase(去掉 `/api` 前缀)。生产环境 nginx 做同样代理,SSE realtime 连接额外禁用 buffering。
|
||||
@@ -78,6 +87,15 @@ pocketbase.ts (PB 客户端初始化)
|
||||
- **games** — 游戏库,归属 group,含平台、标签、封面
|
||||
- **game_comments** / **game_favorites** — 评论和收藏
|
||||
- **join_requests** — 入群申请
|
||||
- **polls** / **poll_options** / **poll_votes** — 投票(选项投票/点名),含匿名、截止时间
|
||||
- **bets** / **bet_options** / **bet_entries** — 积分竞猜,含下注范围和结算
|
||||
- **point_logs** — 积分流水(vote/team/memory/bet 行为)
|
||||
- **memories** — 多媒体记忆(图片/视频/音频/文档),归属 group
|
||||
- **ledgers** — 群组账本(收入/支出),按游戏/聚餐/设备/交通分类
|
||||
- **assets** — 群组资产(游戏账号/主机/设备/配件),含当前持有者
|
||||
- **game_blacklist** — 游戏黑名单(行为/外挂/坑货/环境差)
|
||||
- **player_blacklist** — 玩家黑名单(标签:挂机/送人头/喷人等)
|
||||
- **notifications** — 站内通知(投票/组队/入群等事件)
|
||||
|
||||
### PocketBase 注意事项
|
||||
|
||||
@@ -86,3 +104,4 @@ pocketbase.ts (PB 客户端初始化)
|
||||
- 数据迁移在 `backend/pb_migrations/`,由管理面板操作自动生成。**不要**为 `username` 等系统字段创建 `addField` 迁移,会导致 `duplicate column` 错误
|
||||
- PocketBase 管理面板:`admin@example.com` / `admin123456`
|
||||
- 前端 `.env` 文件:`VITE_PB_URL` 配置后端地址,`VITE_PORT` 配置开发端口
|
||||
- API 调用添加 `$autoCancel: false` 避免 PocketBase SDK 自动取消请求
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const collection = new Collection({
|
||||
"id": "pblacklist_col",
|
||||
"created": "2026-04-19 10:00:01.000Z",
|
||||
"updated": "2026-04-19 10:00:01.000Z",
|
||||
"name": "player_blacklist",
|
||||
"type": "base",
|
||||
"system": false,
|
||||
"schema": [
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_group",
|
||||
"name": "group",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "es63bkyiblpnxdf",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_reporter",
|
||||
"name": "reporter",
|
||||
"type": "relation",
|
||||
"required": true,
|
||||
"options": {
|
||||
"collectionId": "_pb_users_auth_",
|
||||
"cascadeDelete": true,
|
||||
"minSelect": null,
|
||||
"maxSelect": 1,
|
||||
"displayFields": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_playerid",
|
||||
"name": "playerId",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 200,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_platform",
|
||||
"name": "platform",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_tags",
|
||||
"name": "tags",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 5,
|
||||
"values": ["afk", "feeder", "toxic", "cheater", "quitter", "noob", "fragile", "other"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_customtag",
|
||||
"name": "customTag",
|
||||
"type": "text",
|
||||
"required": false,
|
||||
"options": {
|
||||
"min": null,
|
||||
"max": 50,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_desc",
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"required": true,
|
||||
"options": {
|
||||
"min": 1,
|
||||
"max": 500,
|
||||
"pattern": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"system": false,
|
||||
"id": "pb_severity",
|
||||
"name": "severity",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"options": {
|
||||
"maxSelect": 1,
|
||||
"values": ["mild", "medium", "severe"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"indexes": [],
|
||||
"listRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"viewRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"createRule": "@request.auth.id != \"\" && group.members ~ @request.auth.id",
|
||||
"updateRule": null,
|
||||
"deleteRule": "reporter = @request.auth.id || group.owner = @request.auth.id",
|
||||
"options": {}
|
||||
});
|
||||
|
||||
return Dao(db).saveCollection(collection);
|
||||
}, (db) => {
|
||||
const dao = new Dao(db);
|
||||
const collection = dao.findCollectionByNameOrId("pblacklist_col");
|
||||
return dao.deleteCollection(collection);
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --production
|
||||
COPY server.js .
|
||||
EXPOSE 7882
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "gamegroup-voice-token",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"livekit-server-sdk": "^2.0",
|
||||
"express": "^4.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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
|
||||
if (!authHeader) {
|
||||
return res.status(401).json({ error: '未登录' })
|
||||
}
|
||||
|
||||
// 验证用户 token — 调用 PocketBase
|
||||
const pbRes = await fetch(`${PB_URL}/api/collections/users/auth-refresh`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: authHeader },
|
||||
})
|
||||
if (!pbRes.ok) {
|
||||
return res.status(401).json({ error: '认证失败' })
|
||||
}
|
||||
const userData = await pbRes.json()
|
||||
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}`)
|
||||
})
|
||||
@@ -20,6 +20,36 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
gamegroup-net:
|
||||
driver: bridge
|
||||
|
||||
@@ -20,6 +20,36 @@ services:
|
||||
networks:
|
||||
- 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:
|
||||
build:
|
||||
context: ./frontend
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# 语音房间功能设计
|
||||
|
||||
## 概述
|
||||
|
||||
在组队功能中加入实时语音通话,基于 LiveKit(开源 WebRTC SFU),独立语音房间页面。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
Vue 前端 (VoiceRoom) ←→ PocketBase (token 签发 hook) ←→ LiveKit (音视频 SFU)
|
||||
```
|
||||
|
||||
## 技术选型
|
||||
|
||||
- **LiveKit Server** — 开源 WebRTC SFU,Docker 部署
|
||||
- **livekit-client** — 前端核心 SDK
|
||||
- **@livekit/components-vue** — Vue 组件封装
|
||||
- **livekit-server-sdk (Node.js)** — PocketBase hook 中签发 token
|
||||
|
||||
## 数据模型
|
||||
|
||||
team_sessions 新增字段:
|
||||
- `voiceRoom: string` — LiveKit 房间名
|
||||
- `voiceActive: boolean` — 语音房间是否活跃
|
||||
|
||||
## Token 签发
|
||||
|
||||
PocketBase JS hook (`/api/voice-token/{sessionId}`):
|
||||
1. 验证请求者是 session 成员
|
||||
2. 用 LiveKit API key/secret 签发 JWT
|
||||
3. room = `team-{sessionId}`,identity = userId
|
||||
4. 返回 `{ token: "..." }`
|
||||
|
||||
## 前端
|
||||
|
||||
### 路由
|
||||
|
||||
`/group/:groupId/voice/:sessionId` — 独立语音房间页面
|
||||
|
||||
### 页面结构 (VoiceRoom.vue)
|
||||
|
||||
- 顶部:房间名 + 离开按钮
|
||||
- 中部:成员头像网格,说话时绿圈动画
|
||||
- 底部:麦克风开关、扬声器开关、成员列表
|
||||
|
||||
### 入口
|
||||
|
||||
GroupView 组队卡片上加"语音"按钮,recruiting/playing 状态时显示。
|
||||
|
||||
## 文件变更
|
||||
|
||||
### 新增
|
||||
|
||||
- `frontend/src/views/VoiceRoom.vue`
|
||||
- `frontend/src/api/voice.ts`
|
||||
- `frontend/src/composables/useVoiceRoom.ts`
|
||||
- `frontend/src/components/voice/VoiceMemberGrid.vue`
|
||||
- `frontend/src/components/voice/VoiceControls.vue`
|
||||
- `backend/pb_hooks/voice-token.pb.js`
|
||||
- `backend/pb_hooks/package.json`
|
||||
|
||||
### 修改
|
||||
|
||||
- `frontend/src/types/index.ts` — TeamSession 加字段
|
||||
- `frontend/src/router/index.ts` — 加路由
|
||||
- `frontend/src/views/GroupView.vue` — 加入口按钮
|
||||
- `docker-compose.backend.yml` — 加 LiveKit 容器
|
||||
- `docker-compose.uat.yml` — 同步加 LiveKit
|
||||
|
||||
## 部署
|
||||
|
||||
- PocketBase 启用 JS hooks
|
||||
- LiveKit 端口:7880 (HTTP)、7881 (UDP/RTC)
|
||||
- 局域网无需 TURN 服务器
|
||||
@@ -0,0 +1,924 @@
|
||||
# 语音房间功能 Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 在组队功能中集成 LiveKit 实时语音通话,提供独立语音房间页面。
|
||||
|
||||
**Architecture:** LiveKit Server (Docker) 提供 WebRTC SFU 服务。PocketBase JS hook 签发 LiveKit token。前端用 livekit-client + @livekit/components-vue 实现语音房间 UI。
|
||||
|
||||
**Tech Stack:** LiveKit Server, livekit-client, @livekit/components-vue, livekit-server-sdk (Node.js)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Docker 基础设施 — 添加 LiveKit 容器
|
||||
|
||||
**Files:**
|
||||
- Modify: `docker-compose.backend.yml`
|
||||
- Modify: `docker-compose.uat.yml`
|
||||
|
||||
**Step 1: 在 docker-compose.backend.yml 添加 LiveKit 服务**
|
||||
|
||||
在 pocketbase 服务之后、networks 之前添加:
|
||||
|
||||
```yaml
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.7
|
||||
container_name: gamegroup-livekit
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881/udp"
|
||||
environment:
|
||||
- LIVEKIT_KEYS="APIyxZGQjM2: secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
command: --dev
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- gamegroup-net
|
||||
```
|
||||
|
||||
**Step 2: 在 docker-compose.uat.yml 同步添加**
|
||||
|
||||
在 pocketbase-uat 服务之后、frontend-uat 之前添加同样的 LiveKit 服务,端口相同(UAT 和 Dev 共享同一台机器的 Docker 网络)。
|
||||
|
||||
**Step 3: 启动验证**
|
||||
|
||||
Run: `docker compose -f docker-compose.backend.yml up -d livekit`
|
||||
Expected: 容器正常启动,`docker logs gamegroup-livekit` 显示 LiveKit listening on :7880
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docker-compose.backend.yml docker-compose.uat.yml
|
||||
git commit -m "feat(voice): add LiveKit server container to docker-compose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 安装前端 LiveKit 依赖
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/package.json`
|
||||
|
||||
**Step 1: 安装依赖**
|
||||
|
||||
Run: `cd frontend && npm install livekit-client @livekit/components-vue`
|
||||
|
||||
**Step 2: 验证安装**
|
||||
|
||||
Run: `cd frontend && npm ls livekit-client @livekit/components-vue`
|
||||
Expected: 两个包正常列出
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/package.json frontend/package-lock.json
|
||||
git commit -m "feat(voice): add livekit-client and components-vue dependencies"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 前端 API 层 — voice.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/api/voice.ts`
|
||||
|
||||
**Step 1: 创建 voice.ts API 封装**
|
||||
|
||||
```typescript
|
||||
// src/api/voice.ts
|
||||
import { pb } from './pocketbase'
|
||||
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://192.168.1.14:7880'
|
||||
|
||||
export function getLiveKitUrl(): string {
|
||||
return LIVEKIT_URL
|
||||
}
|
||||
|
||||
export async function fetchVoiceToken(sessionId: string): Promise<string> {
|
||||
const user = pb.authStore.model
|
||||
if (!user) throw new Error('未登录')
|
||||
|
||||
// 先验证用户是该 session 的成员
|
||||
const session = await pb.collection('team_sessions').getOne(sessionId)
|
||||
const members: string[] = (session as any).members || []
|
||||
if (!members.includes(user.id)) {
|
||||
throw new Error('你不是该小队的成员')
|
||||
}
|
||||
|
||||
// 调用 PocketBase hook 获取 token
|
||||
// 如果 hook 未部署,用前端 fallback 方案(仅开发用)
|
||||
try {
|
||||
const res = await pb.send(`/api/voice-token/${sessionId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return res.token
|
||||
} catch {
|
||||
// Hook 不可用时,提示用户
|
||||
throw new Error('语音服务暂不可用,请检查 LiveKit 配置')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: 添加 .env 配置**
|
||||
|
||||
在 `frontend/.env.dev` 和 `frontend/.env.uat` 中添加:
|
||||
```
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/api/voice.ts frontend/.env.dev frontend/.env.uat
|
||||
git commit -m "feat(voice): add voice API layer with LiveKit token fetch"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 类型更新 — TeamSession 加语音字段
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/index.ts:80-94`
|
||||
|
||||
**Step 1: 在 TeamSession interface 中添加字段**
|
||||
|
||||
在 `updated: string` 之后、`expand?` 之前添加:
|
||||
|
||||
```typescript
|
||||
voiceRoom?: string
|
||||
voiceActive?: boolean
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/types/index.ts
|
||||
git commit -m "feat(voice): add voiceRoom and voiceActive fields to TeamSession type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: composable — useVoiceRoom.ts
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/composables/useVoiceRoom.ts`
|
||||
|
||||
**Step 1: 创建 useVoiceRoom composable**
|
||||
|
||||
```typescript
|
||||
// src/composables/useVoiceRoom.ts
|
||||
import { ref, onUnmounted } from 'vue'
|
||||
import { Room, RoomEvent, Track, RemoteParticipant, LocalParticipant } from 'livekit-client'
|
||||
import { fetchVoiceToken, getLiveKitUrl } from '@/api/voice'
|
||||
|
||||
export interface VoiceParticipant {
|
||||
identity: string
|
||||
name: string
|
||||
isSpeaking: boolean
|
||||
isMuted: boolean
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export function useVoiceRoom() {
|
||||
const room = ref<Room | null>(null)
|
||||
const connected = ref(false)
|
||||
const participants = ref<Map<string, VoiceParticipant>>(new Map())
|
||||
const micEnabled = ref(true)
|
||||
const speakerEnabled = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function connect(sessionId: string, userId: string) {
|
||||
try {
|
||||
error.value = null
|
||||
const token = await fetchVoiceToken(sessionId)
|
||||
const livekitUrl = getLiveKitUrl()
|
||||
|
||||
const newRoom = new Room()
|
||||
|
||||
newRoom.on(RoomEvent.TrackSubscribed, (_track, pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (_track, pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, (participant) => {
|
||||
participants.value.delete(participant.identity)
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
|
||||
const speakerIds = new Set(speakers.map(s => s.identity))
|
||||
for (const [id, p] of participants.value) {
|
||||
p.isSpeaking = speakerIds.has(id)
|
||||
}
|
||||
participants.value = new Map(participants.value)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackMuted, (pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
newRoom.on(RoomEvent.TrackUnmuted, (pub, participant) => {
|
||||
updateParticipant(participant)
|
||||
})
|
||||
|
||||
await newRoom.connect(livekitUrl, token)
|
||||
|
||||
// 添加本地参与者
|
||||
updateParticipant(newRoom.localParticipant)
|
||||
|
||||
// 添加已有远端参与者
|
||||
for (const p of newRoom.remoteParticipants.values()) {
|
||||
updateParticipant(p)
|
||||
}
|
||||
|
||||
// 发布本地麦克风
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(true)
|
||||
|
||||
room.value = newRoom
|
||||
connected.value = true
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '连接语音房间失败'
|
||||
console.error('Voice room connect error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticipant(participant: LocalParticipant | RemoteParticipant) {
|
||||
const isLocal = participant instanceof LocalParticipant
|
||||
const audioTrack = isLocal
|
||||
? participant.audioTrackPublications.values().next().value
|
||||
: participant.audioTrackPublications.values().next().value
|
||||
|
||||
const vp: VoiceParticipant = {
|
||||
identity: participant.identity,
|
||||
name: participant.name || participant.identity,
|
||||
isSpeaking: participant.isSpeaking,
|
||||
isMuted: audioTrack?.isMuted ?? true,
|
||||
}
|
||||
|
||||
participants.value.set(participant.identity, vp)
|
||||
participants.value = new Map(participants.value)
|
||||
}
|
||||
|
||||
async function toggleMic() {
|
||||
if (!room.value) return
|
||||
const enabled = !micEnabled.value
|
||||
await room.value.localParticipant.setMicrophoneEnabled(enabled)
|
||||
micEnabled.value = enabled
|
||||
updateParticipant(room.value.localParticipant)
|
||||
}
|
||||
|
||||
async function toggleSpeaker() {
|
||||
if (!room.value) return
|
||||
speakerEnabled.value = !speakerEnabled.value
|
||||
for (const p of room.value.remoteParticipants.values()) {
|
||||
for (const pub of p.audioTrackPublications.values()) {
|
||||
if (pub.track) {
|
||||
pub.track.enabled = speakerEnabled.value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
if (room.value) {
|
||||
await room.value.disconnect()
|
||||
room.value = null
|
||||
}
|
||||
connected.value = false
|
||||
participants.value = new Map()
|
||||
micEnabled.value = true
|
||||
speakerEnabled.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
room,
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/composables/useVoiceRoom.ts
|
||||
git commit -m "feat(voice): add useVoiceRoom composable with LiveKit connection management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 语音组件 — VoiceMemberGrid + VoiceControls
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/voice/VoiceMemberGrid.vue`
|
||||
- Create: `frontend/src/components/voice/VoiceControls.vue`
|
||||
|
||||
**Step 1: 创建 VoiceMemberGrid.vue**
|
||||
|
||||
成员头像网格,说话时有绿圈呼吸动画。
|
||||
|
||||
```vue
|
||||
<!-- src/components/voice/VoiceMemberGrid.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
|
||||
import { displayName } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
participants: Map<string, VoiceParticipant>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-member-grid">
|
||||
<div
|
||||
v-for="[id, p] of participants"
|
||||
:key="id"
|
||||
class="voice-member"
|
||||
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
|
||||
>
|
||||
<div class="avatar-ring">
|
||||
<img
|
||||
:src="p.avatar || '/default-avatar.svg'"
|
||||
:alt="p.name"
|
||||
class="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span v-if="p.isMuted" class="mic-icon muted">🎤✕</span>
|
||||
<span v-else-if="p.isSpeaking" class="mic-icon active">🎤</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-member-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.voice-member {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
border: 2px solid var(--gg-border);
|
||||
transition: border-color 0.2s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.speaking .avatar-ring {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
|
||||
animation: pulse-ring 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.muted .avatar-ring {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
|
||||
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
text-align: center;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.mic-icon.active {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.mic-icon.muted {
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 2: 创建 VoiceControls.vue**
|
||||
|
||||
底部控制栏。
|
||||
|
||||
```vue
|
||||
<!-- src/components/voice/VoiceControls.vue -->
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
micEnabled: boolean
|
||||
speakerEnabled: boolean
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleMic: []
|
||||
toggleSpeaker: []
|
||||
leave: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-controls">
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: micEnabled, off: !micEnabled }"
|
||||
@click="emit('toggleMic')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
|
||||
<span class="ctrl-label">麦克风</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: speakerEnabled, off: !speakerEnabled }"
|
||||
@click="emit('toggleSpeaker')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
|
||||
<span class="ctrl-label">扬声器</span>
|
||||
</button>
|
||||
|
||||
<button class="ctrl-btn leave-btn" @click="emit('leave')">
|
||||
<span class="ctrl-icon">🚪</span>
|
||||
<span class="ctrl-label">离开</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--gg-bg-card);
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 24px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ctrl-btn.off {
|
||||
background: var(--gg-bg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ctrl-btn.active {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.leave-btn {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.leave-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: var(--gg-danger);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/voice/VoiceMemberGrid.vue frontend/src/components/voice/VoiceControls.vue
|
||||
git commit -m "feat(voice): add VoiceMemberGrid and VoiceControls components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 语音房间页面 — VoiceRoom.vue
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/views/VoiceRoom.vue`
|
||||
- Modify: `frontend/src/router/index.ts`
|
||||
|
||||
**Step 1: 创建 VoiceRoom.vue**
|
||||
|
||||
```vue
|
||||
<!-- src/views/VoiceRoom.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useVoiceRoom } from '@/composables/useVoiceRoom'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import VoiceMemberGrid from '@/components/voice/VoiceMemberGrid.vue'
|
||||
import VoiceControls from '@/components/voice/VoiceControls.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const teamStore = useTeamStore()
|
||||
const groupStore = useGroupStore()
|
||||
|
||||
const sessionId = route.params.sessionId as string
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const session = computed(() => teamStore.currentSession)
|
||||
const gameName = computed(() => session.value?.gameName || '语音房间')
|
||||
|
||||
const {
|
||||
connected,
|
||||
participants,
|
||||
micEnabled,
|
||||
speakerEnabled,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleSpeaker,
|
||||
} = useVoiceRoom()
|
||||
|
||||
onMounted(async () => {
|
||||
// 加载 session 数据
|
||||
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
|
||||
await teamStore.loadActiveSession()
|
||||
}
|
||||
|
||||
const userId = pb.authStore.model?.id
|
||||
if (!userId) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
await connect(sessionId, userId)
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
await disconnect()
|
||||
})
|
||||
|
||||
async function handleLeave() {
|
||||
await disconnect()
|
||||
router.replace(`/group/${groupId}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-room">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="voice-header">
|
||||
<button class="back-btn" @click="handleLeave">
|
||||
← 返回
|
||||
</button>
|
||||
<h1 class="room-title">{{ gameName }}</h1>
|
||||
<div class="conn-status" :class="{ on: connected }">
|
||||
{{ connected ? '已连接' : '连接中...' }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="error" class="error-banner">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- 成员区域 -->
|
||||
<main class="voice-body">
|
||||
<VoiceMemberGrid :participants="participants" />
|
||||
<div v-if="participants.size === 0" class="empty-hint">
|
||||
正在连接语音...
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<VoiceControls
|
||||
:mic-enabled="micEnabled"
|
||||
:speaker-enabled="speakerEnabled"
|
||||
:connected="connected"
|
||||
@toggle-mic="toggleMic"
|
||||
@toggle-speaker="toggleSpeaker"
|
||||
@leave="handleLeave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: var(--gg-bg);
|
||||
}
|
||||
|
||||
.voice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border-bottom: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.conn-status {
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
|
||||
.conn-status.on {
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
margin: 16px 24px;
|
||||
padding: 12px 16px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid var(--gg-danger);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
color: var(--gg-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.voice-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
color: var(--gg-text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
**Step 2: 添加路由**
|
||||
|
||||
在 `frontend/src/router/index.ts` 中,在 `blacklist` 路由之后、`games` 路由之前添加:
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: 'group/:groupId/voice/:sessionId',
|
||||
name: 'VoiceRoom',
|
||||
component: () => import('@/views/VoiceRoom.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/views/VoiceRoom.vue frontend/src/router/index.ts
|
||||
git commit -m "feat(voice): add VoiceRoom page and route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 入口按钮 — TeamSessionPanel 加语音入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/team/TeamSessionPanel.vue:110-120`
|
||||
|
||||
**Step 1: 在 TeamSessionPanel 中添加语音按钮**
|
||||
|
||||
在 `end-game-btn` 按钮之后、`dissolve-btn` 之前(约第 116 行区域),添加语音房间入口:
|
||||
|
||||
在 `<script setup>` 中导入 useRoute:
|
||||
```typescript
|
||||
import { useRoute } from 'vue-router'
|
||||
const route = useRoute()
|
||||
```
|
||||
|
||||
在 template 中,`start-game-btn` 按钮之后、`end-game-btn` 之前添加:
|
||||
|
||||
```html
|
||||
<router-link
|
||||
v-if="session.status === 'recruiting' || session.status === 'playing'"
|
||||
:to="`/group/${route.params.id}/voice/${session.id}`"
|
||||
class="voice-btn"
|
||||
>
|
||||
语音房间
|
||||
</router-link>
|
||||
```
|
||||
|
||||
添加对应样式:
|
||||
|
||||
```css
|
||||
.voice-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid var(--gg-primary);
|
||||
border-radius: var(--gg-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--gg-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.voice-btn:hover {
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/team/TeamSessionPanel.vue
|
||||
git commit -m "feat(voice): add voice room entry button to TeamSessionPanel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: PocketBase hook — 签发 LiveKit token
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/pb_hooks/main.js`
|
||||
- Create: `backend/pb_hooks/package.json`
|
||||
|
||||
**注意:** PocketBase 0.22.4 muchobien 镜像可能不支持 JS hooks(参见现有 main.js 的注释)。如果 hook 不工作,前端 voice.ts 已经有 fallback 错误提示。此 Task 为可选增强。
|
||||
|
||||
**Step 1: 创建 package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "gamegroup-pb-hooks",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"livekit-server-sdk": "^2.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `cd backend/pb_hooks && npm install`
|
||||
|
||||
**Step 2: 更新 main.js — 添加 token 签发路由**
|
||||
|
||||
在现有注释之后添加:
|
||||
|
||||
```javascript
|
||||
// LiveKit voice token endpoint
|
||||
routerAdd("POST", "/api/voice-token/{sessionId}", (c) => {
|
||||
const sessionId = c.pathParam("sessionId")
|
||||
const user = c.authRecord
|
||||
if (!user) {
|
||||
throw new BadRequestError("未登录")
|
||||
}
|
||||
|
||||
// 验证用户是 session 成员
|
||||
const session = $app.dao().findRecordById("team_sessions", sessionId)
|
||||
const members = session.getStringSlice("members")
|
||||
if (!members.includes(user.id)) {
|
||||
throw new ForbiddenError("你不是该小队的成员")
|
||||
}
|
||||
|
||||
// LiveKit token 签发
|
||||
// 注意:需要 livekit-server-sdk,如果 PocketBase JS VM 不支持 npm 模块,
|
||||
// 可以改用外部 API 或 PocketBase Go middleware
|
||||
const { AccessToken } = require("livekit-server-sdk")
|
||||
|
||||
const apiKey = "APIyxZGQjM2"
|
||||
const apiSecret = "secretNmU4ZDU3YjA0OWIxNDM4YjhlNWY3YTFjZGUzOWRi"
|
||||
|
||||
const at = new AccessToken(apiKey, apiSecret, {
|
||||
identity: user.id,
|
||||
name: user.getString("name") || user.getString("username"),
|
||||
})
|
||||
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: `team-${sessionId}`,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
})
|
||||
|
||||
const token = at.toJwt()
|
||||
|
||||
return c.json(200, { token: token })
|
||||
}, $apis.requireAuth())
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/pb_hooks/main.js backend/pb_hooks/package.json backend/pb_hooks/package-lock.json
|
||||
git commit -m "feat(voice): add PocketBase hook for LiveKit token issuance"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 构建验证 + 部署测试
|
||||
|
||||
**Files:** 无新文件
|
||||
|
||||
**Step 1: 前端构建**
|
||||
|
||||
Run: `cd frontend && npm run build`
|
||||
Expected: 构建成功,无 TypeScript 错误
|
||||
|
||||
**Step 2: 部署 Dev 环境**
|
||||
|
||||
Run: `./deploy-dev.sh`
|
||||
Expected: 前端容器构建并启动在 7033 端口
|
||||
|
||||
**Step 3: 功能验证**
|
||||
|
||||
打开 `http://192.168.1.14:7033`:
|
||||
1. 登录 → 进入群组 → 创建/查看临时小组
|
||||
2. 组队卡片中应显示"语音房间"按钮
|
||||
3. 点击进入语音房间页面(此时因 LiveKit 容器未启动会显示错误,属正常)
|
||||
4. 启动 LiveKit 容器后重试验证完整流程
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "feat(voice): voice room feature complete - v0.4.0"
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
||||
@@ -0,0 +1,47 @@
|
||||
const { app, BrowserWindow } = 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
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
|
||||
const url = getWindowUrl()
|
||||
win.loadURL(url)
|
||||
|
||||
// 页面标题同步
|
||||
win.on('page-title-updated', (event, title) => {
|
||||
event.preventDefault()
|
||||
win.setTitle(`Game Group - ${title}`)
|
||||
})
|
||||
}
|
||||
|
||||
app.whenReady().then(createWindow)
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
})
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// preload.js - 目前为空,预留用于未来需要暴露给渲染进程的 API
|
||||
const { contextBridge } = require('electron')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
platform: process.platform,
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
# Dev Environment
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7033
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# UAT Environment
|
||||
VITE_PB_URL=http://192.168.1.14:8711
|
||||
VITE_PORT=7034
|
||||
VITE_LIVEKIT_URL=ws://192.168.1.14:7880
|
||||
VITE_VOICE_TOKEN_URL=http://192.168.1.14:7882
|
||||
|
||||
@@ -30,6 +30,15 @@ server {
|
||||
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
|
||||
location /api/ {
|
||||
client_max_body_size 500m;
|
||||
|
||||
@@ -30,6 +30,15 @@ server {
|
||||
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
|
||||
location /api/ {
|
||||
proxy_pass http://192.168.1.14:8712;
|
||||
|
||||
+11
-10
@@ -10,20 +10,21 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.7",
|
||||
"element-plus": "^2.6.3",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"pocketbase": "^0.21.1"
|
||||
"element-plus": "^2.6.3",
|
||||
"livekit-client": "^2.18.3",
|
||||
"pinia": "^2.1.7",
|
||||
"pocketbase": "^0.21.1",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"typescript": "^5.4.5",
|
||||
"vue-tsc": "^2.0.11",
|
||||
"vite": "^5.2.8",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38"
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.8",
|
||||
"vue-tsc": "^2.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// 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
|
||||
|
||||
const res = await fetch(`/voice-api/voice-token/${sessionId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ error: '语音服务暂不可用' }))
|
||||
throw new Error(data.error || '语音服务暂不可用')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
return data.token
|
||||
}
|
||||
@@ -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 -->
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
@@ -15,6 +16,7 @@ const userStore = useUserStore()
|
||||
const session = computed(() => teamStore.currentSession)
|
||||
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
|
||||
const showGameSelect = ref(false)
|
||||
const route = useRoute()
|
||||
|
||||
const memberDetails = computed(() => {
|
||||
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>
|
||||
<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>
|
||||
@@ -315,4 +324,25 @@ async function handleGameSelected(gameName: string) {
|
||||
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<!-- src/components/voice/VoiceControls.vue -->
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
micEnabled: boolean
|
||||
speakerEnabled: boolean
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleMic: []
|
||||
toggleSpeaker: []
|
||||
leave: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-controls">
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: micEnabled, off: !micEnabled }"
|
||||
@click="emit('toggleMic')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ micEnabled ? '🎤' : '🔇' }}</span>
|
||||
<span class="ctrl-label">麦克风</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
:class="{ active: speakerEnabled, off: !speakerEnabled }"
|
||||
@click="emit('toggleSpeaker')"
|
||||
>
|
||||
<span class="ctrl-icon">{{ speakerEnabled ? '🔊' : '🔈' }}</span>
|
||||
<span class="ctrl-label">扬声器</span>
|
||||
</button>
|
||||
|
||||
<button class="ctrl-btn leave-btn" @click="emit('leave')">
|
||||
<span class="ctrl-icon">🚪</span>
|
||||
<span class="ctrl-label">离开</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: var(--gg-bg-card);
|
||||
border-top: 1px solid var(--gg-border);
|
||||
}
|
||||
|
||||
.ctrl-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 24px;
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-md);
|
||||
background: var(--gg-bg);
|
||||
color: var(--gg-text);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
border-color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.ctrl-btn.off {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.ctrl-btn.active {
|
||||
border-color: var(--gg-primary);
|
||||
background: rgba(5, 150, 105, 0.1);
|
||||
}
|
||||
|
||||
.ctrl-icon {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.ctrl-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.leave-btn {
|
||||
border-color: var(--gg-danger);
|
||||
color: var(--gg-danger);
|
||||
}
|
||||
|
||||
.leave-btn:hover {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
<!-- src/components/voice/VoiceMemberGrid.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { VoiceParticipant } from '@/composables/useVoiceRoom'
|
||||
|
||||
defineProps<{
|
||||
participants: Map<string, VoiceParticipant>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="voice-member-grid">
|
||||
<div
|
||||
v-for="[id, p] of participants"
|
||||
:key="id"
|
||||
class="voice-member"
|
||||
:class="{ speaking: p.isSpeaking, muted: p.isMuted }"
|
||||
>
|
||||
<div class="avatar-ring">
|
||||
<img
|
||||
:src="'/default-avatar.svg'"
|
||||
:alt="p.name"
|
||||
class="avatar"
|
||||
/>
|
||||
</div>
|
||||
<span class="name">{{ p.name }}</span>
|
||||
<span v-if="p.isMuted" class="mic-status muted">静音</span>
|
||||
<span v-else-if="p.isSpeaking" class="mic-status active">说话中</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-member-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.voice-member {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.avatar-ring {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
padding: 3px;
|
||||
border: 2px solid var(--gg-border);
|
||||
transition: border-color 0.2s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.speaking .avatar-ring {
|
||||
border-color: var(--gg-primary);
|
||||
box-shadow: 0 0 16px rgba(5, 150, 105, 0.4);
|
||||
animation: pulse-ring 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.muted .avatar-ring {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@keyframes pulse-ring {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(5, 150, 105, 0.2); }
|
||||
50% { box-shadow: 0 0 20px rgba(5, 150, 105, 0.5); }
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--gg-text);
|
||||
text-align: center;
|
||||
max-width: 90px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mic-status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mic-status.active {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.mic-status.muted {
|
||||
color: var(--gg-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
// 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) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,13 @@ const routes: RouteRecordRaw[] = [
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'group/:groupId/voice/:sessionId',
|
||||
name: 'VoiceRoom',
|
||||
component: () => import('@/views/VoiceRoom.vue'),
|
||||
props: true,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
name: 'GamesLibrary',
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface TeamSession {
|
||||
members: string[]
|
||||
status: TeamStatus
|
||||
dissolvedAt?: string
|
||||
voiceRoom?: string
|
||||
voiceActive?: boolean
|
||||
created: string
|
||||
updated: string
|
||||
expand?: {
|
||||
@@ -345,6 +347,20 @@ export const BlacklistReasonMap: Record<BlacklistReason, string> = {
|
||||
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'
|
||||
|
||||
@@ -373,6 +389,25 @@ export interface BlacklistEntry {
|
||||
}
|
||||
}
|
||||
|
||||
// 玩家黑名单
|
||||
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'
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<!-- src/views/BlacklistView.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
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()
|
||||
@@ -12,6 +13,8 @@ const groupId = route.params.groupId as string
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
|
||||
const activeTab = ref<'game' | 'player'>('game')
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
})
|
||||
@@ -25,13 +28,39 @@ onMounted(async () => {
|
||||
<el-icon><ArrowLeft /></el-icon> 返回群组
|
||||
</router-link>
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">游戏黑名单</h1>
|
||||
<h1 class="page-title">黑名单</h1>
|
||||
<span class="group-badge">{{ group?.name }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 黑名单内容 -->
|
||||
<BlacklistMain />
|
||||
<!-- 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>
|
||||
|
||||
@@ -99,4 +128,43 @@ onMounted(async () => {
|
||||
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>
|
||||
|
||||
@@ -10,6 +10,33 @@ interface 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',
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user