群视频通话
This commit is contained in:
@@ -34,23 +34,24 @@
|
||||
<!-- 主内容区域 -->
|
||||
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
|
||||
|
||||
<!-- A. 头像背景层 -->
|
||||
<!-- A. 头像背景层 (使用 Avatar 组件) -->
|
||||
<div
|
||||
v-if="call.type === 'audio' || call.status !== 'connected' || call.remoteCamOff"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
|
||||
>
|
||||
<div class="relative" :class="call.minimized ? 'scale-50' : ''">
|
||||
<div
|
||||
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-2xl z-20 relative border-4 border-gray-800"
|
||||
:style="{ background: target?.color || '#6366f1' }"
|
||||
>
|
||||
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
|
||||
</div>
|
||||
<Avatar
|
||||
:name="target?.remark_name || target?.user?.name"
|
||||
:avatar="target?.user?.avatar"
|
||||
size="2xl"
|
||||
class="w-32 h-32 text-5xl border-4 border-gray-800 shadow-2xl relative z-20"
|
||||
/>
|
||||
|
||||
<div v-if="call.status === 'outgoing' || call.status === 'connected'"
|
||||
class="absolute inset-0 bg-white/20 rounded-full animate-ping z-10"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="!call.minimized" class="text-2xl font-bold text-gray-100 tracking-wide mt-2">
|
||||
<div v-if="!call.minimized" class="text-2xl font-bold text-gray-100 tracking-wide mt-6">
|
||||
{{ target?.remark_name || target?.user?.name || '未知用户' }}
|
||||
</div>
|
||||
|
||||
@@ -66,11 +67,6 @@
|
||||
</div>
|
||||
|
||||
<!-- B. 远程视频流 -->
|
||||
<!--
|
||||
关键修复:动态 class 切换 object-fit 模式
|
||||
正常模式:object-cover (填满)
|
||||
最小化模式:object-contain (完整显示)
|
||||
-->
|
||||
<video
|
||||
ref="remoteVideoRef"
|
||||
class="w-full h-full transition-opacity duration-500 absolute inset-0 z-0 bg-black"
|
||||
@@ -153,6 +149,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect, computed, onMounted, reactive, nextTick } from 'vue'
|
||||
import Avatar from '@/components/common/Avatar.vue' // 引入 Avatar
|
||||
import type { Contact } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
@@ -187,14 +184,12 @@ const localVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const windowRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// 显式本地视频显示逻辑
|
||||
const shouldShowLocalVideo = computed(() => {
|
||||
return props.call.type === 'video' &&
|
||||
!props.call.minimized &&
|
||||
(props.call.status === 'connected' || props.call.status === 'outgoing')
|
||||
})
|
||||
|
||||
// --- 窗口类名逻辑 ---
|
||||
const windowClass = computed(() => {
|
||||
if (props.call.minimized) {
|
||||
return 'w-48 h-64 rounded-xl border-gray-600 fixed shadow-xl'
|
||||
@@ -205,7 +200,6 @@ const windowClass = computed(() => {
|
||||
return 'w-[900px] h-[600px] rounded-2xl fixed'
|
||||
})
|
||||
|
||||
// --- 拖拽逻辑 ---
|
||||
const drag = reactive({
|
||||
isDragging: false,
|
||||
startX: 0,
|
||||
@@ -313,7 +307,6 @@ const formatDuration = (seconds: number) => {
|
||||
return h > 0 ? `${h.toString().padStart(2, '0')}:${m}:${s}` : `${m}:${s}`
|
||||
}
|
||||
|
||||
// --- 增强版视频流绑定 ---
|
||||
watchEffect(() => {
|
||||
const stream = props.localStream
|
||||
const videoEl = localVideoRef.value
|
||||
@@ -321,7 +314,6 @@ watchEffect(() => {
|
||||
if (videoEl && stream) {
|
||||
videoEl.muted = true
|
||||
if (videoEl.srcObject !== stream) {
|
||||
console.log('CallWindow: Binding local stream', stream.id)
|
||||
videoEl.srcObject = stream
|
||||
videoEl.play().catch(e => console.error('Local video play error:', e))
|
||||
}
|
||||
@@ -336,7 +328,6 @@ watchEffect(() => {
|
||||
|
||||
if (videoEl && stream) {
|
||||
if (videoEl.srcObject !== stream) {
|
||||
console.log('CallWindow: Binding remote stream', stream.id)
|
||||
videoEl.srcObject = stream
|
||||
videoEl.play().catch(e => console.error('Remote video play error:', e))
|
||||
}
|
||||
|
||||
63
src/components/chat/GroupCallBanner.vue
Normal file
63
src/components/chat/GroupCallBanner.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="showBanner"
|
||||
class="mx-4 mb-2 p-2 bg-gradient-to-r from-green-900/40 to-emerald-900/40 border border-green-500/20 rounded-xl flex items-center justify-between backdrop-blur-sm shadow-lg animate-fade-in-down"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-2">
|
||||
<!-- 动态波纹图标 -->
|
||||
<div class="relative w-8 h-8 flex items-center justify-center">
|
||||
<div class="absolute inset-0 bg-green-500 rounded-full opacity-20 animate-ping"></div>
|
||||
<div class="relative w-8 h-8 rounded-full bg-green-500/20 flex items-center justify-center border border-green-500/30">
|
||||
<i class="fas fa-video text-green-400 text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-green-100 text-xs font-bold tracking-wide">群通话进行中</p>
|
||||
<p class="text-green-400/70 text-[10px] flex items-center gap-1">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span>
|
||||
{{ participantsCount }} 人参与
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="px-3 py-1.5 bg-green-600 hover:bg-green-500 text-white text-xs font-bold rounded-lg transition shadow-lg shadow-green-900/30 flex items-center gap-1 active:scale-95"
|
||||
@click="groupWebRTC.joinCurrentCall()"
|
||||
>
|
||||
<i class="fas fa-phone-alt text-[10px]"></i>
|
||||
<span>加入</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
|
||||
const props = defineProps<{
|
||||
roomId: string
|
||||
}>()
|
||||
|
||||
const groupWebRTC = useGroupWebRTC()
|
||||
const { callState, participants } = groupWebRTC
|
||||
|
||||
const showBanner = computed(() => {
|
||||
return callState.groupId === props.roomId &&
|
||||
!callState.joined &&
|
||||
!callState.incoming &&
|
||||
callState.roomId !== ''
|
||||
})
|
||||
|
||||
const participantsCount = computed(() => participants.value.length)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animate-fade-in-down {
|
||||
animation: fadeInDown 0.3s ease-out;
|
||||
}
|
||||
@keyframes fadeInDown {
|
||||
from { opacity: 0; transform: translateY(-10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
74
src/components/chat/GroupCallIncoming.vue
Normal file
74
src/components/chat/GroupCallIncoming.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="fixed z-[9999] top-8 right-8 w-80 bg-[#1e1e24] border border-gray-700 rounded-2xl shadow-2xl overflow-hidden animate-slide-in"
|
||||
>
|
||||
<div class="p-6 flex flex-col items-center">
|
||||
<!-- 头像区 -->
|
||||
<div class="relative mb-4">
|
||||
<Avatar
|
||||
:name="callState.inviterName"
|
||||
size="xl"
|
||||
class="ring-4 ring-gray-800 shadow-lg"
|
||||
/>
|
||||
<div class="absolute -bottom-1 -right-1 w-6 h-6 bg-green-500 rounded-full border-4 border-gray-800 flex items-center justify-center">
|
||||
<i class="fas fa-video text-white text-[10px]"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-white font-bold text-lg mb-1">{{ callState.inviterName }}</h3>
|
||||
<p class="text-gray-400 text-sm mb-6">邀请你进行群视频通话...</p>
|
||||
|
||||
<!-- 按钮区 -->
|
||||
<div class="flex gap-4 w-full">
|
||||
<button
|
||||
class="flex-1 py-2.5 bg-red-500/10 hover:bg-red-500/20 text-red-500 rounded-xl font-medium transition flex items-center justify-center gap-2"
|
||||
@click="groupWebRTC.rejectInvite()"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
拒绝
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex-1 py-2.5 bg-green-500 hover:bg-green-600 text-white rounded-xl font-medium transition shadow-lg shadow-green-500/20 flex items-center justify-center gap-2"
|
||||
@click="groupWebRTC.acceptInvite()"
|
||||
>
|
||||
<i class="fas fa-video"></i>
|
||||
接听
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条倒计时 -->
|
||||
<div class="h-1 bg-gray-800 w-full">
|
||||
<div class="h-full bg-green-500 animate-progress origin-left"></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
|
||||
const groupWebRTC = useGroupWebRTC()
|
||||
const { callState } = groupWebRTC
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes slideIn {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
from { transform: scaleX(1); }
|
||||
to { transform: scaleX(0); }
|
||||
}
|
||||
.animate-progress {
|
||||
animation: progress 30s linear forwards;
|
||||
}
|
||||
</style>
|
||||
213
src/components/chat/GroupCallModal.vue
Normal file
213
src/components/chat/GroupCallModal.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/80 z-[10000] flex items-center justify-center p-4 backdrop-blur-sm animate-fade-in"
|
||||
@click.self="handleClose"
|
||||
>
|
||||
<div class="bg-[#1e1e24] rounded-2xl w-full max-w-2xl shadow-2xl border border-gray-700/50 overflow-hidden flex flex-col max-h-[85vh] font-sans select-none">
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="px-6 py-4 border-b border-white/5 flex justify-between items-center bg-gradient-to-r from-gray-800/50 to-transparent">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-white">发起群通话</h3>
|
||||
<p class="text-xs text-gray-400 mt-1">选择成员加入通话 ({{ selectedIds.size }}/{{ members.length }})</p>
|
||||
</div>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full hover:bg-white/10 text-gray-400 hover:text-white transition flex items-center justify-center"
|
||||
@click="handleClose"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索与全选 -->
|
||||
<div class="px-6 py-3 border-b border-white/5 bg-black/10 flex gap-4 items-center">
|
||||
<div class="relative flex-1">
|
||||
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500 text-xs"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="w-full bg-black/20 border border-gray-700 rounded-lg py-2 pl-9 pr-3 text-sm text-white focus:border-green-500 focus:outline-none placeholder-gray-600 transition-colors"
|
||||
placeholder="搜索成员..."
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
class="text-xs px-3 py-2 rounded-lg transition-colors border border-gray-700 hover:border-gray-500"
|
||||
:class="isAllSelected ? 'bg-green-500/20 text-green-400 border-green-500/30' : 'text-gray-400 hover:text-white'"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表 (网格布局) -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-6 bg-[#18181c]">
|
||||
<div v-if="filteredMembers.length === 0" class="h-40 flex flex-col items-center justify-center text-gray-500">
|
||||
<i class="fas fa-users-slash text-2xl mb-2"></i>
|
||||
<p class="text-sm">未找到成员</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 gap-4">
|
||||
<div
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.id || member.user_id"
|
||||
class="flex flex-col items-center gap-2 cursor-pointer group"
|
||||
@click="toggleSelection(member.id || member.user_id)"
|
||||
>
|
||||
<!-- 头像容器 -->
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:name="member.remark_name || member.user?.name || '?'"
|
||||
:avatar="member.user?.avatar"
|
||||
size="lg"
|
||||
rounded="full"
|
||||
class="transition-all duration-300 ring-offset-2 ring-offset-[#18181c]"
|
||||
:class="selectedIds.has(member.id || member.user_id)
|
||||
? 'ring-2 ring-green-500 opacity-100 scale-105'
|
||||
: 'opacity-60 grayscale-[0.5] group-hover:opacity-100 group-hover:grayscale-0'"
|
||||
/>
|
||||
|
||||
<!-- 选中标记 -->
|
||||
<div
|
||||
v-if="selectedIds.has(member.id || member.user_id)"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-green-500 rounded-full flex items-center justify-center shadow-md animate-bounce-in"
|
||||
>
|
||||
<i class="fas fa-check text-white text-[10px]"></i>
|
||||
</div>
|
||||
|
||||
<!-- 在线状态 -->
|
||||
<div
|
||||
v-else-if="member.user?.is_online"
|
||||
class="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 border-2 border-[#18181c] rounded-full"
|
||||
title="在线"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 名字 -->
|
||||
<span
|
||||
class="text-xs text-center truncate w-full px-1 transition-colors"
|
||||
:class="selectedIds.has(member.id || member.user_id) ? 'text-green-400 font-medium' : 'text-gray-400 group-hover:text-gray-300'"
|
||||
>
|
||||
{{ member.remark_name || member.user?.name || '未知成员' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="p-6 border-t border-white/5 bg-[#1e1e24] flex justify-between items-center">
|
||||
<div class="text-xs text-gray-500">
|
||||
已选择 <span class="text-green-400 font-bold text-sm">{{ selectedIds.size }}</span> 人
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<!-- 语音通话 -->
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2.5 rounded-xl transition-all font-medium bg-gray-700/50 hover:bg-gray-700 text-gray-200 hover:text-white"
|
||||
:disabled="selectedIds.size === 0"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': selectedIds.size === 0 }"
|
||||
@click="emitStartCall('audio')"
|
||||
>
|
||||
<i class="fas fa-phone-alt"></i>
|
||||
<span>语音通话</span>
|
||||
</button>
|
||||
|
||||
<!-- 视频通话 -->
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2.5 rounded-xl transition-all font-medium bg-green-600 hover:bg-green-500 text-white shadow-lg shadow-green-600/20 hover:shadow-green-500/30"
|
||||
:disabled="selectedIds.size === 0"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': selectedIds.size === 0 }"
|
||||
@click="emitStartCall('video')"
|
||||
>
|
||||
<i class="fas fa-video"></i>
|
||||
<span>视频通话</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import type { Contact } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
members: Contact[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
'start-call': [type: 'audio' | 'video', selectedIds: string[]]
|
||||
}>()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
|
||||
// 过滤后的成员
|
||||
const filteredMembers = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!query) return props.members
|
||||
return props.members.filter(m => {
|
||||
const name = m.remark_name || m.user?.name || ''
|
||||
return name.toLowerCase().includes(query)
|
||||
})
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return filteredMembers.value.length > 0 && selectedIds.value.size === filteredMembers.value.length
|
||||
})
|
||||
|
||||
function toggleSelection(id: string) {
|
||||
if (selectedIds.value.has(id)) {
|
||||
selectedIds.value.delete(id)
|
||||
} else {
|
||||
selectedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedIds.value.clear()
|
||||
} else {
|
||||
filteredMembers.value.forEach(m => {
|
||||
const id = m.id || m.user_id
|
||||
if (id) selectedIds.value.add(id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function emitStartCall(type: 'audio' | 'video') {
|
||||
if (selectedIds.value.size === 0) return
|
||||
emit('start-call', type, Array.from(selectedIds.value))
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: scale(0.98); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.animate-bounce-in {
|
||||
animation: bounceIn 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
@keyframes bounceIn {
|
||||
0% { transform: scale(0); opacity: 0; }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
214
src/components/chat/GroupCallWindow.vue
Normal file
214
src/components/chat/GroupCallWindow.vue
Normal file
@@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="callState.joined && !callState.incoming"
|
||||
ref="windowRef"
|
||||
class="fixed z-[999] bg-[#1e1e1e] transition-shadow duration-300 overflow-hidden flex flex-col shadow-2xl border border-gray-700 font-sans select-none"
|
||||
:class="windowClasses"
|
||||
:style="dragStyle"
|
||||
>
|
||||
<!-- 顶部功能栏 -->
|
||||
<div
|
||||
class="h-12 absolute top-0 left-0 right-0 z-50 flex items-center justify-between px-4 bg-gradient-to-b from-black/80 to-transparent cursor-move"
|
||||
@mousedown="startDrag"
|
||||
@touchstart="startDragTouch"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-white/90 drop-shadow-md pointer-events-none">
|
||||
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<span class="text-sm font-bold">群聊通话</span>
|
||||
<span class="text-xs font-mono opacity-70 border-l border-white/20 pl-2 ml-1">
|
||||
{{ formatDuration(callState.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3" @mousedown.stop @touchstart.stop>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center transition backdrop-blur cursor-pointer"
|
||||
@click="toggleMinimize"
|
||||
>
|
||||
<i class="fas" :class="callState.minimized ? 'fa-expand' : 'fa-compress'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体:视频网格 -->
|
||||
<div
|
||||
class="flex-1 bg-[#121212] relative overflow-hidden flex items-center justify-center p-2 pt-12"
|
||||
:class="{'!p-0 !pt-0': callState.minimized}"
|
||||
>
|
||||
<!-- 最小化模式 -->
|
||||
<div v-if="callState.minimized" class="w-full h-full relative group cursor-pointer" @click="toggleMinimize">
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center bg-gray-800">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<i class="fas fa-phone-volume text-green-400 text-2xl animate-pulse"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p class="absolute bottom-0 w-full text-center text-white text-[10px] bg-black/60 py-1 backdrop-blur-sm">
|
||||
{{ formatDuration(callState.duration) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 正常模式 -->
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-2 w-full h-full transition-all duration-300 ease-in-out"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<!-- A. 自己的画面 -->
|
||||
<div class="relative bg-gray-800 rounded-xl overflow-hidden shadow-lg group border border-white/5">
|
||||
<video
|
||||
ref="localVideoRef"
|
||||
class="w-full h-full object-cover transform -scale-x-100"
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
></video>
|
||||
|
||||
<div class="absolute bottom-2 left-2 max-w-[80%] bg-black/40 backdrop-blur-md px-2 py-1 rounded-lg flex items-center gap-2 z-20">
|
||||
<span class="text-white text-xs font-bold truncate">我</span>
|
||||
<i v-if="callState.isSelfMuted" class="fas fa-microphone-slash text-red-400 text-[10px]"></i>
|
||||
</div>
|
||||
|
||||
<div v-if="callState.isSelfCamOff" class="absolute inset-0 bg-[#252525] flex flex-col items-center justify-center z-10">
|
||||
<Avatar name="我" size="2xl" class="border-4 border-gray-600" />
|
||||
<p class="text-gray-400 text-xs mt-2">摄像头已关闭</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- B. 其他成员的画面 -->
|
||||
<div
|
||||
v-for="user in participants"
|
||||
:key="user.userId"
|
||||
class="relative bg-gray-800 rounded-xl overflow-hidden shadow-lg border border-white/5 transition-all"
|
||||
>
|
||||
<video
|
||||
v-if="user.stream && !user.isCamOff"
|
||||
:srcObject="user.stream"
|
||||
class="w-full h-full object-cover"
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
|
||||
<div v-else class="absolute inset-0 bg-[#252525] flex flex-col items-center justify-center z-10">
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:name="user.name"
|
||||
:avatar="user.avatar"
|
||||
size="2xl"
|
||||
class="border-4 border-gray-600"
|
||||
/>
|
||||
<div v-if="user.status === 'connecting'" class="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-spinner fa-spin text-white"></i>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-white mt-3 font-medium text-sm">{{ user.name }}</p>
|
||||
<p class="text-gray-500 text-xs mt-1">{{ user.status === 'connecting' ? '连接中...' : '摄像头关闭' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-2 left-2 max-w-[80%] bg-black/40 backdrop-blur-md px-2 py-1 rounded-lg flex items-center gap-2 z-20">
|
||||
<span class="text-white text-xs font-bold truncate">{{ user.name }}</span>
|
||||
<i v-if="user.isMuted" class="fas fa-microphone-slash text-red-400 text-[10px]"></i>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!user.isMuted"
|
||||
class="absolute inset-0 border-2 border-green-500/50 rounded-xl z-30 pointer-events-none opacity-0 transition-opacity duration-100"
|
||||
:class="{'opacity-100': user.volume && user.volume > 0.05}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 底部控制栏 -->
|
||||
<div
|
||||
v-if="!callState.minimized"
|
||||
class="h-24 bg-[#1e1e1e]/95 border-t border-white/10 flex items-center justify-center gap-8 z-40 pb-4 backdrop-blur"
|
||||
>
|
||||
<button class="flex flex-col items-center gap-1 group" @click="webRTC.toggleSelfMute()">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center transition-all shadow-lg"
|
||||
:class="callState.isSelfMuted ? 'bg-white text-black' : 'bg-gray-700 text-white hover:bg-gray-600'">
|
||||
<i class="fas" :class="callState.isSelfMuted ? 'fa-microphone-slash' : 'fa-microphone'"></i>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-400 font-medium">{{ callState.isSelfMuted ? '已静音' : '静音' }}</span>
|
||||
</button>
|
||||
|
||||
<button class="flex flex-col items-center gap-1 group" @click="webRTC.leaveCall()">
|
||||
<div class="w-16 h-16 rounded-2xl bg-red-600 text-white flex items-center justify-center text-2xl shadow-xl shadow-red-600/20 hover:bg-red-500 active:scale-95 transition-transform">
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-400 font-medium">挂断</span>
|
||||
</button>
|
||||
|
||||
<button class="flex flex-col items-center gap-1 group" @click="webRTC.toggleSelfCamera()">
|
||||
<div class="w-12 h-12 rounded-full flex items-center justify-center transition-all shadow-lg"
|
||||
:class="callState.isSelfCamOff ? 'bg-white text-black' : 'bg-gray-700 text-white hover:bg-gray-600'">
|
||||
<i class="fas" :class="callState.isSelfCamOff ? 'fa-video-slash' : 'fa-video'"></i>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-400 font-medium">{{ callState.isSelfCamOff ? '已关闭' : '摄像头' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, reactive, watchEffect, nextTick } from 'vue'
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
|
||||
const webRTC = useGroupWebRTC()
|
||||
const { callState, participants, formatDuration } = webRTC
|
||||
|
||||
const localVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const windowRef = ref<HTMLElement | null>(null)
|
||||
|
||||
watchEffect(() => {
|
||||
const stream = webRTC.localStream.value
|
||||
const videoEl = localVideoRef.value
|
||||
if (videoEl && stream) {
|
||||
videoEl.muted = true
|
||||
if (videoEl.srcObject !== stream) {
|
||||
videoEl.srcObject = stream
|
||||
videoEl.play().catch(e => console.error(e))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => {
|
||||
const count = participants.value.length + 1
|
||||
let cols = 1, rows = 1
|
||||
if (count === 2) { cols = 1; rows = 2 }
|
||||
else if (count <= 4) { cols = 2; rows = 2 }
|
||||
else if (count <= 6) { cols = 2; rows = 3 }
|
||||
else { cols = 3; rows = 3 }
|
||||
if (window.innerWidth > 768 && count === 2) { cols = 2; rows = 1 }
|
||||
return { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }
|
||||
})
|
||||
|
||||
const windowClasses = computed(() => callState.minimized ? 'w-32 h-48 right-4 top-20 rounded-lg' : 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[90vw] h-[80vh] rounded-2xl')
|
||||
|
||||
const drag = reactive({ isDragging: false, startX: 0, startY: 0, left: 0, top: 0, initialized: false })
|
||||
const dragStyle = computed(() => drag.initialized ? { left: `${drag.left}px`, top: `${drag.top}px`, transform: 'none' } : {})
|
||||
|
||||
function initPosition() {
|
||||
if(callState.minimized) return
|
||||
drag.left = (window.innerWidth - 1000) / 2
|
||||
drag.top = (window.innerHeight - 700) / 2
|
||||
if(drag.left < 0) drag.left = 0
|
||||
if(drag.top < 0) drag.top = 0
|
||||
drag.initialized = true
|
||||
}
|
||||
onMounted(() => initPosition())
|
||||
|
||||
function startDrag(e: MouseEvent) { e.preventDefault(); drag.isDragging = true; drag.startX = e.clientX - drag.left; drag.startY = e.clientY - drag.top; document.addEventListener('mousemove', onDrag); document.addEventListener('mouseup', stopDrag) }
|
||||
function onDrag(e: MouseEvent) { if(drag.isDragging) { drag.left = e.clientX - drag.startX; drag.top = e.clientY - drag.startY } }
|
||||
function stopDrag() { drag.isDragging = false; document.removeEventListener('mousemove', onDrag); document.removeEventListener('mouseup', stopDrag) }
|
||||
function startDragTouch(e: TouchEvent) { const t = e.touches[0]; drag.isDragging = true; drag.startX = t.clientX - drag.left; drag.startY = t.clientY - drag.top; document.addEventListener('touchmove', onDragTouch); document.addEventListener('touchend', stopDragTouch) }
|
||||
function onDragTouch(e: TouchEvent) { if(drag.isDragging) { const t = e.touches[0]; drag.left = t.clientX - drag.startX; drag.top = t.clientY - drag.startY } }
|
||||
function stopDragTouch() { drag.isDragging = false; document.removeEventListener('touchmove', onDragTouch); document.removeEventListener('touchend', stopDragTouch) }
|
||||
|
||||
function toggleMinimize() {
|
||||
callState.minimized = !callState.minimized
|
||||
if(!callState.minimized) initPosition()
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,508 +1,363 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-panel rounded-2xl w-full max-w-2xl shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[90vh] flex flex-col">
|
||||
<!-- 头部 -->
|
||||
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-white">群资料</h2>
|
||||
<button
|
||||
class="text-gray-400 hover:text-white transition text-xl"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/60 z-[9999] flex items-center justify-center p-4 backdrop-blur-md animate-fade-in"
|
||||
@click.self="$emit('close')"
|
||||
>
|
||||
<div class="bg-[#1e1e24] rounded-2xl w-full max-w-2xl shadow-2xl border border-gray-700/50 overflow-hidden flex flex-col max-h-[85vh] relative font-sans select-none">
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center py-20">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-circle-notch fa-spin text-4xl text-primary mb-4"></i>
|
||||
<p class="text-gray-400">加载中...</p>
|
||||
<!-- 背景装饰 -->
|
||||
<div class="absolute top-0 left-0 w-full h-32 bg-gradient-to-r from-blue-600/20 to-purple-600/20 z-0 pointer-events-none"></div>
|
||||
|
||||
<!-- 头部 -->
|
||||
<div class="px-6 py-4 flex justify-between items-center relative z-10 border-b border-white/5">
|
||||
<h2 class="text-lg font-bold text-white flex items-center gap-2">
|
||||
<i class="fas fa-info-circle text-gray-400"></i>
|
||||
群资料
|
||||
</h2>
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-gray-400 hover:text-white transition flex items-center justify-center"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div v-else-if="groupInfo" class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<!-- 群基本信息 -->
|
||||
<div class="px-6 py-6 border-b border-gray-800">
|
||||
<div class="flex flex-col items-center mb-6">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="flex-1 flex items-center justify-center min-h-[300px]">
|
||||
<i class="fas fa-circle-notch fa-spin text-2xl text-blue-500"></i>
|
||||
</div>
|
||||
|
||||
<!-- 内容区 -->
|
||||
<div v-else-if="groupInfo" class="flex-1 overflow-y-auto custom-scrollbar relative z-10 bg-[#1e1e24]">
|
||||
<!-- 核心信息区 -->
|
||||
<div class="p-8 flex flex-col items-center">
|
||||
<Avatar
|
||||
:name="groupInfo.name"
|
||||
:avatar="groupInfo.avatar || groupInfo.name?.charAt(0)"
|
||||
:avatar="groupInfo.avatar"
|
||||
size="xl"
|
||||
rounded="full"
|
||||
class="mb-4 border-4 border-gray-700"
|
||||
rounded="2xl"
|
||||
class="mb-4 shadow-2xl ring-4 ring-[#1e1e24] z-10 -mt-2"
|
||||
/>
|
||||
<h3 class="text-2xl font-bold text-white mb-2">{{ groupInfo.name }}</h3>
|
||||
<p class="text-sm text-gray-400">群ID: {{ groupInfo.room_id }}</p>
|
||||
<p class="text-sm text-gray-400 mt-1">成员数: {{ groupInfo.member_count || members.length }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 群操作按钮 -->
|
||||
<div class="flex gap-3 justify-center">
|
||||
<button
|
||||
v-if="canInvite"
|
||||
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition flex items-center gap-2"
|
||||
@click="showInviteModal = true"
|
||||
>
|
||||
<i class="fas fa-user-plus"></i>
|
||||
<span>邀请成员</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition flex items-center gap-2"
|
||||
@click="showEditModal = true"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
<span>编辑群信息</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isOwner"
|
||||
class="px-4 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white transition flex items-center gap-2"
|
||||
@click="showDissolveConfirmDialog"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
<span>解散群聊</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-2 rounded-xl bg-red-600 hover:bg-red-700 text-white transition flex items-center gap-2"
|
||||
@click="showQuitConfirmDialog"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span>退出群聊</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<div class="px-6 py-4">
|
||||
<h4 class="text-lg font-semibold text-white mb-4">群成员 ({{ members.length }})</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.user_id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 transition cursor-pointer group"
|
||||
@click="handleMemberClick(member)"
|
||||
>
|
||||
<Avatar
|
||||
:name="member.user?.name || member.nickname || '未知'"
|
||||
:avatar="member.user?.avatar || (member.user?.name || member.nickname)?.charAt(0)"
|
||||
size="sm"
|
||||
rounded="full"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-white truncate">
|
||||
{{ member.user?.name || member.nickname || '未知' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">
|
||||
<span v-if="member.role === 2" class="text-yellow-400">群主</span>
|
||||
<span v-else-if="member.role === 1" class="text-blue-400">管理员</span>
|
||||
<span v-else>成员</span>
|
||||
</p>
|
||||
<div class="text-center w-full mb-8">
|
||||
<h3 class="text-2xl font-bold text-white mb-2">{{ groupInfo.name }}</h3>
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<span class="text-gray-500 text-xs font-mono bg-black/30 px-3 py-1 rounded-full border border-white/5 select-all">ID: {{ groupInfo.room_id }}</span>
|
||||
<span class="text-gray-500 text-xs bg-black/30 px-3 py-1 rounded-full border border-white/5">{{ members.length }} 人</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="canManageMember(member)"
|
||||
class="opacity-0 group-hover:opacity-100 transition"
|
||||
>
|
||||
<button
|
||||
v-if="member.role !== 2"
|
||||
class="text-gray-400 hover:text-red-400 transition text-sm"
|
||||
@click.stop="showRemoveMemberConfirmDialog(member)"
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="grid grid-cols-5 gap-4 w-full max-w-lg mb-8">
|
||||
<!-- 1. 通话按钮 -->
|
||||
<button class="info-action-btn group" @click="handleStartCall">
|
||||
<div class="icon-box bg-green-500/10 text-green-400 group-hover:bg-green-500 group-hover:text-white border border-green-500/20">
|
||||
<i class="fas fa-video"></i>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">通话</span>
|
||||
</button>
|
||||
|
||||
<!-- 2. 邀请按钮 -->
|
||||
<button v-if="canInvite" class="info-action-btn group" @click="showInviteModal = true">
|
||||
<div class="icon-box bg-blue-500/10 text-blue-400 group-hover:bg-blue-500 group-hover:text-white border border-blue-500/20">
|
||||
<i class="fas fa-plus"></i>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">邀请</span>
|
||||
</button>
|
||||
|
||||
<!-- 3. 编辑按钮 -->
|
||||
<button v-if="canEdit" class="info-action-btn group" @click="showEditModal = true">
|
||||
<div class="icon-box bg-gray-700/30 text-gray-300 group-hover:bg-gray-600 group-hover:text-white border border-gray-600/30">
|
||||
<i class="fas fa-pen"></i>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">编辑</span>
|
||||
</button>
|
||||
|
||||
<!-- 4. 退出按钮 -->
|
||||
<button v-if="!isOwner" class="info-action-btn group" @click="showQuitConfirmDialog">
|
||||
<div class="icon-box bg-red-500/10 text-red-400 group-hover:bg-red-500 group-hover:text-white border border-red-500/20">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">退出</span>
|
||||
</button>
|
||||
|
||||
<!-- 5. 解散按钮 -->
|
||||
<button v-if="isOwner" class="info-action-btn group" @click="showDissolveConfirmDialog">
|
||||
<div class="icon-box bg-red-600/10 text-red-500 group-hover:bg-red-600 group-hover:text-white border border-red-600/20">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">解散</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 成员网格 -->
|
||||
<div class="w-full bg-black/20 rounded-2xl p-5 border border-white/5">
|
||||
<div class="flex justify-between items-center mb-4 px-2">
|
||||
<h4 class="text-sm font-bold text-gray-300">群成员</h4>
|
||||
<span class="text-xs text-gray-600">共 {{ members.length }} 人</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-5 sm:grid-cols-6 gap-4">
|
||||
<!-- 邀请快捷入口 -->
|
||||
<div
|
||||
v-if="canInvite"
|
||||
class="flex flex-col items-center cursor-pointer group"
|
||||
@click="showInviteModal = true"
|
||||
>
|
||||
<i class="fas fa-user-minus"></i>
|
||||
</button>
|
||||
<div class="w-10 h-10 rounded-full border border-dashed border-gray-600 flex items-center justify-center text-gray-500 group-hover:border-blue-400 group-hover:text-blue-400 transition">
|
||||
<i class="fas fa-plus"></i>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-600 mt-1 group-hover:text-blue-400">邀请</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member.user_id"
|
||||
class="flex flex-col items-center cursor-pointer group relative"
|
||||
@click="handleMemberClick(member)"
|
||||
>
|
||||
<div class="relative">
|
||||
<Avatar
|
||||
:name="member.user?.name || member.nickname"
|
||||
:avatar="member.user?.avatar"
|
||||
size="sm"
|
||||
rounded="full"
|
||||
class="group-hover:ring-2 ring-blue-500 transition duration-300"
|
||||
/>
|
||||
<!-- 群主标识 -->
|
||||
<div v-if="member.role === 2" class="absolute -top-1 -right-1 bg-yellow-500 text-black text-[8px] p-0.5 rounded-full w-3 h-3 flex items-center justify-center shadow-sm">
|
||||
<i class="fas fa-crown"></i>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] text-gray-400 mt-1 truncate w-14 text-center group-hover:text-white transition-colors">
|
||||
{{ member.nickname || member.user?.name || '未知' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else class="flex-1 flex items-center justify-center py-20">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-circle text-4xl text-red-500 mb-4"></i>
|
||||
<p class="text-gray-400">加载失败</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 弹窗 1: 邀请好友 -->
|
||||
<SelectContactsModal v-if="showInviteModal" :show="showInviteModal" :contacts="availableContacts" mode="invite" title="邀请好友加入" @close="showInviteModal = false" @invite="handleInviteMembers" />
|
||||
|
||||
<!-- 邀请成员弹窗 -->
|
||||
<SelectContactsModal
|
||||
v-if="showInviteModal"
|
||||
:show="showInviteModal"
|
||||
:contacts="availableContacts"
|
||||
@close="showInviteModal = false"
|
||||
@create="handleInviteMembers"
|
||||
/>
|
||||
<!-- 弹窗 2: 发起通话 -->
|
||||
<GroupCallModal
|
||||
v-if="showCallModal"
|
||||
:show="showCallModal"
|
||||
:members="callCandidates"
|
||||
@close="showCallModal = false"
|
||||
@start-call="handleConfirmStartCall"
|
||||
/>
|
||||
|
||||
<!-- 编辑群信息弹窗 -->
|
||||
<div
|
||||
v-if="showEditModal"
|
||||
class="fixed inset-0 bg-black/80 z-[70] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="showEditModal = false"
|
||||
>
|
||||
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
|
||||
<div class="px-6 py-4 border-b border-gray-800 flex justify-between items-center">
|
||||
<h3 class="text-lg font-bold text-white">编辑群信息</h3>
|
||||
<button class="text-gray-400 hover:text-white transition" @click="showEditModal = false">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-2">群名称</label>
|
||||
<input
|
||||
v-model="editForm.name"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none"
|
||||
placeholder="请输入群名称"
|
||||
maxlength="20"
|
||||
/>
|
||||
<!-- 弹窗 3: 编辑群信息 -->
|
||||
<div v-if="showEditModal" class="fixed inset-0 bg-black/80 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div class="bg-gray-800 rounded-xl w-full max-w-sm border border-gray-700 shadow-2xl">
|
||||
<div class="p-4 border-b border-gray-700"><h3 class="text-white font-bold">编辑群信息</h3></div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-gray-400 text-xs mb-1">群名称</label>
|
||||
<input v-model="editForm.name" type="text" class="w-full bg-black/20 border border-gray-600 rounded p-2 text-white text-sm focus:border-indigo-500 outline-none" placeholder="输入群名称">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-gray-400 text-xs mb-1">群头像 (文本/Emoji)</label>
|
||||
<input v-model="editForm.avatar" type="text" class="w-full bg-black/20 border border-gray-600 rounded p-2 text-white text-sm focus:border-indigo-500 outline-none" placeholder="输入1个字符">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-2">群头像(首字母)</label>
|
||||
<input
|
||||
v-model="editForm.avatar"
|
||||
type="text"
|
||||
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none"
|
||||
placeholder="输入单个字符作为头像"
|
||||
maxlength="1"
|
||||
/>
|
||||
<div class="p-4 border-t border-gray-700 flex justify-end gap-2">
|
||||
<button class="px-4 py-2 text-gray-400 text-sm hover:text-white" @click="showEditModal = false">取消</button>
|
||||
<button class="px-4 py-2 bg-indigo-600 text-white text-sm rounded hover:bg-indigo-500" @click="handleUpdateGroup">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-800 flex justify-end gap-3">
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition"
|
||||
@click="showEditModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition"
|
||||
@click="handleUpdateGroup"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 弹窗 4: 退出/解散确认 -->
|
||||
<div v-if="showQuitConfirm || showDissolveConfirm" class="fixed inset-0 bg-black/80 z-[1000] flex items-center justify-center p-4">
|
||||
<div class="bg-gray-800 rounded-xl p-6 w-80 text-center border border-gray-700 shadow-2xl">
|
||||
<h3 class="text-white font-bold mb-2">{{ showDissolveConfirm ? '解散群聊' : '退出群聊' }}</h3>
|
||||
<p class="text-gray-400 text-sm mb-6">{{ showDissolveConfirm ? '确定要解散该群聊吗?此操作不可恢复。' : '确定要退出该群聊吗?' }}</p>
|
||||
<div class="flex gap-3">
|
||||
<button class="flex-1 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 text-sm" @click="closeConfirmDialogs">取消</button>
|
||||
<button class="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm" @click="showDissolveConfirm ? handleDissolve() : handleQuit()">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认模态框 -->
|
||||
<ConfirmModal
|
||||
:show="showRemoveMemberConfirm"
|
||||
title="移除成员"
|
||||
:message="removeMemberMessage"
|
||||
type="danger"
|
||||
confirm-text="移除"
|
||||
cancel-text="取消"
|
||||
@confirm="handleRemoveMember"
|
||||
@cancel="showRemoveMemberConfirm = false; pendingMember = null"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
:show="showQuitConfirm"
|
||||
title="退出群聊"
|
||||
message="确定要退出该群聊吗?"
|
||||
type="warning"
|
||||
confirm-text="退出"
|
||||
cancel-text="取消"
|
||||
@confirm="handleQuit"
|
||||
@cancel="showQuitConfirm = false"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
:show="showDissolveConfirm"
|
||||
title="解散群聊"
|
||||
message="确定要解散该群聊吗?此操作不可恢复!"
|
||||
type="danger"
|
||||
confirm-text="解散"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDissolve"
|
||||
@cancel="showDissolveConfirm = false"
|
||||
/>
|
||||
|
||||
<!-- 用户信息卡片 -->
|
||||
<UserInfoCard
|
||||
:show="showUserInfoCard"
|
||||
:user="selectedMember?.user || null"
|
||||
@close="showUserInfoCard = false; selectedMember = null"
|
||||
@send-message="handleSendMessageToMember"
|
||||
@audio-call="handleAudioCallToMember"
|
||||
@video-call="handleVideoCallToMember"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { GroupInfo, GroupMember, Contact } from '@/types/api'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
|
||||
import GroupCallModal from '@/components/chat/GroupCallModal.vue'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useChatStore } from '@/stores/chat'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import * as groupApi from '@/api/modules/room'
|
||||
import * as contactApi from '@/api/modules/contact'
|
||||
import Avatar from '@/components/common/Avatar.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import UserInfoCard from '@/components/common/UserInfoCard.vue'
|
||||
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
|
||||
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||||
import type { GroupInfo, GroupMember, Contact } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
roomId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'close': []
|
||||
'updated': []
|
||||
'quit': []
|
||||
'dissolve': []
|
||||
}>()
|
||||
const props = defineProps<{ show: boolean; roomId: string }>()
|
||||
const emit = defineEmits(['close', 'updated', 'quit', 'dissolve'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const chatStore = useChatStore()
|
||||
const toastStore = useToastStore()
|
||||
const chatStore = useChatStore()
|
||||
const groupWebRTC = useGroupWebRTC()
|
||||
|
||||
const loading = ref(false)
|
||||
const groupInfo = ref<GroupInfo | null>(null)
|
||||
const members = ref<GroupMember[]>([])
|
||||
const availableContacts = ref<Contact[]>([])
|
||||
|
||||
const showInviteModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const editForm = ref({ name: '', avatar: '' })
|
||||
const showRemoveMemberConfirm = ref(false)
|
||||
const showQuitConfirm = ref(false)
|
||||
const showDissolveConfirm = ref(false)
|
||||
const pendingMember = ref<GroupMember | null>(null)
|
||||
const selectedMember = ref<GroupMember | null>(null)
|
||||
const showUserInfoCard = ref(false)
|
||||
const showCallModal = ref(false)
|
||||
|
||||
// 当前用户角色
|
||||
const currentUserRole = computed(() => {
|
||||
if (!authStore.user) return -1
|
||||
const member = members.value.find(m => m.user_id === authStore.user!.id)
|
||||
return member?.role ?? -1
|
||||
})
|
||||
const editForm = ref({ name: '', avatar: '' })
|
||||
|
||||
// 是否为群主
|
||||
const isOwner = computed(() => currentUserRole.value === 2)
|
||||
|
||||
// 是否为管理员
|
||||
const isAdmin = computed(() => currentUserRole.value === 1)
|
||||
|
||||
// 是否可以邀请成员
|
||||
// Computed
|
||||
const isOwner = computed(() => groupInfo.value && authStore.user && members.value.find(m => m.user_id === authStore.user?.id)?.role === 2)
|
||||
const isAdmin = computed(() => groupInfo.value && authStore.user && members.value.find(m => m.user_id === authStore.user?.id)?.role === 1)
|
||||
const canInvite = computed(() => isOwner.value || isAdmin.value)
|
||||
|
||||
// 是否可以编辑群信息
|
||||
const canEdit = computed(() => isOwner.value)
|
||||
|
||||
// 移除成员确认消息
|
||||
const removeMemberMessage = computed(() => {
|
||||
const name = pendingMember.value?.user?.name || pendingMember.value?.nickname || '未知'
|
||||
return `确定要将 ${name} 移出群聊吗?`
|
||||
// 转换群成员为 Contact 格式,供 GroupCallModal 使用
|
||||
const callCandidates = computed(() => {
|
||||
return members.value
|
||||
.filter(m => m.user_id !== authStore.user?.id)
|
||||
.map(m => ({
|
||||
id: m.user_id,
|
||||
user_id: m.user_id,
|
||||
remark_name: m.nickname || m.user?.name || '未知',
|
||||
user: m.user,
|
||||
is_online: m.user?.is_online
|
||||
} as unknown as Contact))
|
||||
})
|
||||
|
||||
// 加载群信息
|
||||
// Methods
|
||||
async function loadGroupInfo() {
|
||||
if (!props.roomId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const [group, memberList] = await Promise.all([
|
||||
const [info, memberList, contacts] = await Promise.all([
|
||||
groupApi.getGroup(props.roomId),
|
||||
groupApi.getGroupMembers(props.roomId)
|
||||
groupApi.getGroupMembers(props.roomId),
|
||||
contactApi.getContacts()
|
||||
])
|
||||
groupInfo.value = group
|
||||
groupInfo.value = info
|
||||
members.value = memberList
|
||||
|
||||
// 加载可用联系人(用于邀请)
|
||||
const contacts = await contactApi.getContacts()
|
||||
// 过滤掉已经是群成员的联系人
|
||||
|
||||
editForm.value = { name: info.name || '', avatar: info.avatar || '' }
|
||||
|
||||
// 过滤可邀请的好友
|
||||
const memberIds = new Set(memberList.map(m => m.user_id))
|
||||
availableContacts.value = contacts.filter(c => !memberIds.has(c.user_id))
|
||||
|
||||
// 初始化编辑表单
|
||||
editForm.value = {
|
||||
name: group.name || '',
|
||||
avatar: group.avatar || ''
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load group info:', error)
|
||||
} catch(e) {
|
||||
toastStore.error('加载群信息失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否可以管理某个成员
|
||||
function canManageMember(member: GroupMember): boolean {
|
||||
if (!isOwner.value && !isAdmin.value) return false
|
||||
if (member.user_id === authStore.user?.id) return false // 不能管理自己
|
||||
if (member.role === 2) return false // 不能管理群主
|
||||
if (isAdmin.value && member.role === 1) return false // 管理员不能管理其他管理员
|
||||
return true
|
||||
}
|
||||
|
||||
// 处理成员点击
|
||||
function handleMemberClick(member: GroupMember) {
|
||||
selectedMember.value = member
|
||||
showUserInfoCard.value = true
|
||||
}
|
||||
|
||||
// 处理发送消息
|
||||
function handleSendMessageToMember() {
|
||||
if (!selectedMember.value) return
|
||||
const contact: Contact = {
|
||||
id: selectedMember.value.user_id,
|
||||
user_id: selectedMember.value.user_id,
|
||||
contact_user_id: selectedMember.value.user_id,
|
||||
room_id: '',
|
||||
room_type: 'p2p',
|
||||
is_group: false,
|
||||
remark_name: selectedMember.value.user?.name || selectedMember.value.nickname || '未知',
|
||||
is_top: false,
|
||||
is_muted: false,
|
||||
user: selectedMember.value.user,
|
||||
// 通话逻辑
|
||||
function handleStartCall() {
|
||||
if (callCandidates.value.length === 0) {
|
||||
toastStore.info('群里只有你一个人,无法通话')
|
||||
return
|
||||
}
|
||||
chatStore.setCurrentTarget(contact)
|
||||
showUserInfoCard.value = false
|
||||
emit('close')
|
||||
showCallModal.value = true
|
||||
}
|
||||
|
||||
// 处理语音通话
|
||||
function handleAudioCallToMember() {
|
||||
if (!selectedMember.value) return
|
||||
// 这里可以调用 WebRTC 相关功能
|
||||
showUserInfoCard.value = false
|
||||
}
|
||||
|
||||
// 处理视频通话
|
||||
function handleVideoCallToMember() {
|
||||
if (!selectedMember.value) return
|
||||
// 这里可以调用 WebRTC 相关功能
|
||||
showUserInfoCard.value = false
|
||||
}
|
||||
|
||||
// 移除成员
|
||||
function showRemoveMemberConfirmDialog(member: GroupMember) {
|
||||
pendingMember.value = member
|
||||
showRemoveMemberConfirm.value = true
|
||||
}
|
||||
|
||||
async function handleRemoveMember() {
|
||||
if (!pendingMember.value) return
|
||||
|
||||
try {
|
||||
await groupApi.removeGroupMember(props.roomId, pendingMember.value.user_id)
|
||||
toastStore.success('已移除成员')
|
||||
showRemoveMemberConfirm.value = false
|
||||
pendingMember.value = null
|
||||
await loadGroupInfo()
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
toastStore.error('移除成员失败')
|
||||
// 确认发起通话 (接收类型和成员ID)
|
||||
function handleConfirmStartCall(type: 'audio' | 'video', selectedIds: string[]) {
|
||||
if (!selectedIds.length) {
|
||||
toastStore.warning('请选择至少一名成员')
|
||||
return
|
||||
}
|
||||
groupWebRTC.startGroupCall(props.roomId, selectedIds, type)
|
||||
showCallModal.value = false
|
||||
emit('close') // 关闭详情页,显示通话窗口
|
||||
}
|
||||
|
||||
// 邀请单个联系人
|
||||
async function handleInviteContact(contact: Contact) {
|
||||
// 邀请逻辑
|
||||
async function handleInviteMembers(data: { member_ids: string[] }) {
|
||||
try {
|
||||
await groupApi.inviteGroupMembers(props.roomId, { member_ids: [contact.user_id || contact.id] })
|
||||
await groupApi.inviteGroupMembers(props.roomId, { member_ids: data.member_ids })
|
||||
toastStore.success('邀请成功')
|
||||
showInviteModal.value = false
|
||||
await loadGroupInfo()
|
||||
loadGroupInfo()
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
} catch(e) {
|
||||
toastStore.error('邀请失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 更新群信息
|
||||
// 更新逻辑
|
||||
async function handleUpdateGroup() {
|
||||
if (!editForm.value.name.trim()) {
|
||||
toastStore.warning('群名称不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if(!editForm.value.name) return toastStore.warning('群名不能为空')
|
||||
try {
|
||||
await groupApi.updateGroup(props.roomId, {
|
||||
name: editForm.value.name.trim(),
|
||||
avatar: editForm.value.avatar || editForm.value.name.charAt(0).toUpperCase()
|
||||
})
|
||||
await groupApi.updateGroup(props.roomId, editForm.value)
|
||||
toastStore.success('更新成功')
|
||||
showEditModal.value = false
|
||||
await loadGroupInfo()
|
||||
loadGroupInfo()
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
toastStore.error('更新失败')
|
||||
}
|
||||
} catch(e) { toastStore.error('更新失败') }
|
||||
}
|
||||
|
||||
// 退出群聊
|
||||
function showQuitConfirmDialog() {
|
||||
showQuitConfirm.value = true
|
||||
}
|
||||
function showQuitConfirmDialog() { showQuitConfirm.value = true }
|
||||
function showDissolveConfirmDialog() { showDissolveConfirm.value = true }
|
||||
function closeConfirmDialogs() { showQuitConfirm.value = false; showDissolveConfirm.value = false }
|
||||
|
||||
async function handleQuit() {
|
||||
try {
|
||||
await groupApi.quitGroup(props.roomId)
|
||||
toastStore.success('已退出群聊')
|
||||
showQuitConfirm.value = false
|
||||
toastStore.success('已退出')
|
||||
closeConfirmDialogs()
|
||||
emit('quit')
|
||||
emit('close')
|
||||
} catch (error: any) {
|
||||
toastStore.error('退出失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 解散群聊
|
||||
function showDissolveConfirmDialog() {
|
||||
showDissolveConfirm.value = true
|
||||
} catch(e) { toastStore.error('退出失败') }
|
||||
}
|
||||
|
||||
async function handleDissolve() {
|
||||
try {
|
||||
await groupApi.dissolveGroup(props.roomId)
|
||||
toastStore.success('群聊已解散')
|
||||
toastStore.success('已解散')
|
||||
closeConfirmDialogs()
|
||||
emit('dissolve')
|
||||
emit('close')
|
||||
} catch (error: any) {
|
||||
toastStore.error('解散失败')
|
||||
}
|
||||
} catch(e) { toastStore.error('解散失败') }
|
||||
}
|
||||
|
||||
// 监听 show 变化,加载数据
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
loadGroupInfo()
|
||||
}
|
||||
function handleMemberClick(member: GroupMember) { /* 可以添加查看成员资料逻辑 */ }
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) loadGroupInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
|
||||
|
||||
.info-action-btn {
|
||||
@apply flex flex-col items-center justify-start gap-0 transition-transform active:scale-95;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-700 rounded-full;
|
||||
.icon-box {
|
||||
@apply w-12 h-12 rounded-xl flex items-center justify-center text-xl transition-all shadow-lg;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
from { opacity: 0; transform: scale(0.98); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user