diff --git a/CLAUDE.md b/CLAUDE.md index 199561a..cf7399a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 自动取消请求 diff --git a/backend/voice-token-service/Dockerfile b/backend/voice-token-service/Dockerfile new file mode 100644 index 0000000..e70cc1f --- /dev/null +++ b/backend/voice-token-service/Dockerfile @@ -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"] diff --git a/backend/voice-token-service/package.json b/backend/voice-token-service/package.json new file mode 100644 index 0000000..9c8ff6b --- /dev/null +++ b/backend/voice-token-service/package.json @@ -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" + } +} diff --git a/backend/voice-token-service/server.js b/backend/voice-token-service/server.js new file mode 100644 index 0000000..651625a --- /dev/null +++ b/backend/voice-token-service/server.js @@ -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}`) +}) diff --git a/docker-compose.backend.yml b/docker-compose.backend.yml index a4a071e..9ff4c9c 100644 --- a/docker-compose.backend.yml +++ b/docker-compose.backend.yml @@ -33,6 +33,22 @@ services: 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 diff --git a/docker-compose.uat.yml b/docker-compose.uat.yml index e87fe18..7eb2455 100644 --- a/docker-compose.uat.yml +++ b/docker-compose.uat.yml @@ -33,6 +33,22 @@ services: 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 diff --git a/docs/plans/2026-04-19-voice-room-design.md b/docs/plans/2026-04-19-voice-room-design.md new file mode 100644 index 0000000..0d91bca --- /dev/null +++ b/docs/plans/2026-04-19-voice-room-design.md @@ -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 服务器 diff --git a/docs/plans/2026-04-19-voice-room-implementation.md b/docs/plans/2026-04-19-voice-room-implementation.md new file mode 100644 index 0000000..92554b2 --- /dev/null +++ b/docs/plans/2026-04-19-voice-room-implementation.md @@ -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 { + 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(null) + const connected = ref(false) + const participants = ref>(new Map()) + const micEnabled = ref(true) + const speakerEnabled = ref(true) + const error = ref(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 + + + + + + +``` + +**Step 2: 创建 VoiceControls.vue** + +底部控制栏。 + +```vue + + + + + + +``` + +**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 + + + + + + +``` + +**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 行区域),添加语音房间入口: + +在 ` + + + + diff --git a/frontend/src/components/voice/VoiceMemberGrid.vue b/frontend/src/components/voice/VoiceMemberGrid.vue new file mode 100644 index 0000000..ba4a4db --- /dev/null +++ b/frontend/src/components/voice/VoiceMemberGrid.vue @@ -0,0 +1,101 @@ + + + + + + diff --git a/frontend/src/composables/useVoiceRoom.ts b/frontend/src/composables/useVoiceRoom.ts new file mode 100644 index 0000000..2041960 --- /dev/null +++ b/frontend/src/composables/useVoiceRoom.ts @@ -0,0 +1,117 @@ +// 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(null) + const connected = ref(false) + const participants = ref>(new Map()) + const micEnabled = ref(true) + const speakerEnabled = ref(true) + const error = ref(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 + 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, + } +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 664b209..082a076 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 75a03b3..e442a35 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -85,6 +85,8 @@ export interface TeamSession { members: string[] status: TeamStatus dissolvedAt?: string + voiceRoom?: string + voiceActive?: boolean created: string updated: string expand?: { diff --git a/frontend/src/views/VoiceRoom.vue b/frontend/src/views/VoiceRoom.vue new file mode 100644 index 0000000..de9b765 --- /dev/null +++ b/frontend/src/views/VoiceRoom.vue @@ -0,0 +1,160 @@ + + + + + +