925 lines
21 KiB
Markdown
925 lines
21 KiB
Markdown
|
|
# 语音房间功能 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"
|
|||
|
|
```
|