群视频通话

This commit is contained in:
2025-12-06 23:02:49 +08:00
parent 926c2a5621
commit 655a2bdd59
11 changed files with 1756 additions and 1326 deletions

View File

@@ -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))
}

View 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>

View 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>

View 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>

View 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

View File

@@ -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>