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:
@@ -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>
|
||||
Reference in New Issue
Block a user