# 语音房间功能 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 行区域),添加语音房间入口: 在 `