feat(mobile): stage 5 - voice room (VoiceRoomMobile)

- migrate VoiceRoomMobile.vue (member grid + mic/speaker/leave controls + app placeholder)
- router: wire VoiceRoom mobile view
- verified: users API getUser() + team store loadActiveSession() + types displayName() match uat

build verified: vue-tsc + vite build pass
This commit is contained in:
锦麟 王
2026-06-18 11:09:10 +08:00
parent 446dbf8ae0
commit 0ec868c949
2 changed files with 256 additions and 1 deletions
+1 -1
View File
@@ -119,7 +119,7 @@ const routes: RouteRecordRaw[] = [
name: 'VoiceRoom',
component: view(
() => import('@/views/VoiceRoom.vue'),
mobilePlaceholder
() => import('@/views-mobile/VoiceRoomMobile.vue')
),
props: true,
meta: { requiresAuth: true }
@@ -0,0 +1,255 @@
<!-- src/views-mobile/VoiceRoomMobile.vue -->
<!-- 手机端语音房成员网格 + 控制条 + 占位提示实际语音待 App -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTeamStore } from '@/stores/team'
import { getUser } from '@/api/users'
import { pb } from '@/api/pocketbase'
import type { User } from '@/types'
import { displayName } from '@/types'
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 members = ref<User[]>([])
const loading = ref(true)
// UI 状态(实际未连接 WebRTC
const micEnabled = ref(true)
const speakerEnabled = ref(true)
onMounted(async () => {
if (!teamStore.currentSession || teamStore.currentSession.id !== sessionId) {
await teamStore.loadActiveSession()
}
// 加载成员详情
const s = teamStore.currentSession
if (s?.members?.length) {
try {
const users = await Promise.all(s.members.map(id => getUser(id).catch(() => null)))
members.value = users.filter(Boolean) as User[]
} catch (e) {
console.error(e)
}
}
loading.value = false
})
function toggleMic() {
micEnabled.value = !micEnabled.value
}
function toggleSpeaker() {
speakerEnabled.value = !speakerEnabled.value
}
async function handleLeave() {
router.replace(`/group/${groupId}`)
}
const currentUserId = computed(() => pb.authStore.model?.id as string | undefined)
</script>
<template>
<div class="voice-room-mobile">
<!-- 顶部 -->
<div class="room-header">
<van-icon name="arrow-left" size="20" @click="handleLeave" />
<div class="room-title-area">
<div class="room-game">{{ gameName }}</div>
<div class="room-status">
<span class="status-dot online" />
<span>{{ members.length }} 人在线</span>
</div>
</div>
</div>
<!-- 占位提示 -->
<van-notice-bar
left-icon="info-o"
text="语音功能请在 App 中使用,当前为预览界面"
class="voice-notice"
/>
<!-- 成员网格 -->
<div v-if="loading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else class="members-grid">
<div
v-for="m in members"
:key="m.id"
class="member-tile"
:class="{ 'is-me': m.id === currentUserId }"
>
<div class="avatar-wrap">
<img :src="m.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="mic-indicator" :class="{ off: !micEnabled && m.id === currentUserId }">
<van-icon :name="micEnabled && m.id === currentUserId ? 'volume-o' : 'volume'" />
</div>
</div>
<div class="member-name">
{{ displayName(m) }}
<span v-if="m.id === currentUserId" class="me-tag"></span>
</div>
</div>
<div v-if="members.length === 0" class="empty-members">
<van-empty description="房间无人" image-size="80" />
</div>
</div>
<!-- 底部控制条 -->
<div class="control-bar">
<div class="control-btn" :class="{ active: micEnabled }" @click="toggleMic">
<van-icon :name="micEnabled ? 'volume-o' : 'volume'" size="26" />
<span>{{ micEnabled ? '麦克风' : '已静音' }}</span>
</div>
<div class="control-btn" :class="{ active: speakerEnabled }" @click="toggleSpeaker">
<van-icon :name="speakerEnabled ? 'ear' : 'closed-eye'" size="26" />
<span>{{ speakerEnabled ? '扬声器' : '已关闭' }}</span>
</div>
<div class="control-btn leave-btn" @click="handleLeave">
<van-icon name="cross" size="26" />
<span>退出</span>
</div>
</div>
</div>
</template>
<style scoped>
.voice-room-mobile {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, #0f172a 0%, #1e293b 100%);
color: #fff;
}
.room-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
}
.room-title-area { flex: 1; }
.room-game { font-size: 18px; font-weight: 700; }
.room-status { display: flex; align-items: center; gap: 6px; font-size: 12px; opacity: 0.8; margin-top: 4px; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; }
.status-dot.online { background: #10b981; }
.voice-notice { margin: 0 12px; border-radius: var(--gg-radius-sm); }
.loading-box { display: flex; justify-content: center; padding: 40px; }
.members-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 24px 16px;
align-content: start;
}
.member-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatar-wrap {
position: relative;
width: 72px;
height: 72px;
}
.member-avatar {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(255,255,255,0.2);
}
.member-tile.is-me .member-avatar {
border-color: var(--gg-primary);
}
.mic-indicator {
position: absolute;
bottom: 0;
right: 0;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--gg-success);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #1e293b;
font-size: 12px;
}
.mic-indicator.off {
background: var(--gg-text-muted);
}
.member-name {
font-size: 13px;
text-align: center;
display: flex;
align-items: center;
gap: 4px;
}
.me-tag {
font-size: 10px;
background: var(--gg-primary);
padding: 0 4px;
border-radius: 4px;
}
.empty-members { grid-column: 1 / -1; }
.control-bar {
display: flex;
justify-content: space-around;
padding: 16px 24px calc(16px + env(safe-area-inset-bottom, 0px));
background: rgba(0,0,0,0.3);
backdrop-filter: blur(10px);
}
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 11px;
opacity: 0.6;
transition: opacity 0.2s;
}
.control-btn.active {
opacity: 1;
}
.control-btn:active {
transform: scale(0.95);
}
.leave-btn {
color: var(--gg-danger);
opacity: 1;
}
</style>