feat(voice): add real-time voice room with LiveKit

- LiveKit WebRTC SFU container in docker-compose
- Voice token microservice (Node.js + Express)
- VoiceRoom page with member grid and controls
- useVoiceRoom composable for LiveKit connection
- Voice entry button in TeamSessionPanel
- Nginx proxy for voice-token service API

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-19 22:24:28 +08:00
parent 9d224e2fcd
commit 5d434ead6f
20 changed files with 1715 additions and 5 deletions
+31
View File
@@ -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
}
@@ -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>
+117
View File
@@ -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<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
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,
}
}
+7
View File
@@ -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',
+2
View File
@@ -85,6 +85,8 @@ export interface TeamSession {
members: string[]
status: TeamStatus
dissolvedAt?: string
voiceRoom?: string
voiceActive?: boolean
created: string
updated: string
expand?: {
+160
View File
@@ -0,0 +1,160 @@
<!-- src/views/VoiceRoom.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTeamStore } from '@/stores/team'
import { useVoiceRoom } from '@/composables/useVoiceRoom'
import { pb } from '@/api/pocketbase'
import VoiceMemberGrid from '@/components/voice/VoiceMemberGrid.vue'
import VoiceControls from '@/components/voice/VoiceControls.vue'
const route = useRoute()
const router = useRouter()
const teamStore = useTeamStore()
const sessionId = route.params.sessionId as string
const groupId = route.params.groupId as string
const session = computed(() => teamStore.currentSession)
const gameName = computed(() => session.value?.gameName || '语音房间')
const {
connected,
participants,
micEnabled,
speakerEnabled,
error,
connect,
disconnect,
toggleMic,
toggleSpeaker,
} = useVoiceRoom()
onMounted(async () => {
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
await teamStore.loadActiveSession()
}
const userId = pb.authStore.model?.id
if (!userId) {
router.replace('/login')
return
}
await connect(sessionId)
})
onUnmounted(async () => {
await disconnect()
})
async function handleLeave() {
await disconnect()
router.replace(`/group/${groupId}`)
}
</script>
<template>
<div class="voice-room">
<header class="voice-header">
<button class="back-btn" @click="handleLeave"> 返回</button>
<h1 class="room-title">{{ gameName }}</h1>
<div class="conn-status" :class="{ on: connected }">
{{ connected ? '已连接' : '连接中...' }}
</div>
</header>
<div v-if="error" class="error-banner">{{ error }}</div>
<main class="voice-body">
<VoiceMemberGrid :participants="participants" />
<div v-if="participants.size === 0" class="empty-hint">正在连接语音...</div>
</main>
<VoiceControls
:mic-enabled="micEnabled"
:speaker-enabled="speakerEnabled"
:connected="connected"
@toggle-mic="toggleMic"
@toggle-speaker="toggleSpeaker"
@leave="handleLeave"
/>
</div>
</template>
<style scoped>
.voice-room {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--gg-bg);
}
.voice-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--gg-bg-card);
border-bottom: 1px solid var(--gg-border);
}
.back-btn {
padding: 8px 16px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.room-title {
font-size: 18px;
font-weight: 700;
margin: 0;
color: var(--gg-text);
}
.conn-status {
font-size: 13px;
padding: 4px 12px;
border-radius: 12px;
background: var(--gg-bg);
color: var(--gg-text-muted);
}
.conn-status.on {
background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary);
}
.error-banner {
margin: 16px 24px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--gg-danger);
border-radius: var(--gg-radius-sm);
color: var(--gg-danger);
font-size: 14px;
}
.voice-body {
flex: 1;
overflow-y: auto;
display: flex;
align-items: center;
justify-content: center;
}
.empty-hint {
color: var(--gg-text-muted);
font-size: 15px;
}
</style>