999 lines
42 KiB
Vue
999 lines
42 KiB
Vue
<template>
|
||
<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 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 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"
|
||
size="xl"
|
||
rounded="2xl"
|
||
class="mb-4 shadow-2xl ring-4 ring-[#1e1e24] z-10 -mt-2"
|
||
/>
|
||
|
||
<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>
|
||
|
||
<!-- 群公告区域 -->
|
||
<div class="w-full bg-black/20 rounded-2xl p-4 border border-white/5 mb-6">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<div class="flex items-center gap-2">
|
||
<i class="fas fa-bullhorn text-indigo-400 text-sm"></i>
|
||
<h4 class="text-sm font-bold text-gray-300">群公告</h4>
|
||
</div>
|
||
<button
|
||
v-if="canEditAnnouncement"
|
||
class="text-xs text-indigo-400 hover:text-indigo-300 transition"
|
||
@click="openAnnouncementModal"
|
||
>
|
||
{{ announcement ? '编辑' : '添加' }}
|
||
</button>
|
||
</div>
|
||
<div
|
||
class="text-sm text-gray-400 leading-relaxed cursor-pointer hover:text-gray-300 transition"
|
||
@click="openAnnouncementModal"
|
||
>
|
||
<p v-if="announcement" class="line-clamp-3 whitespace-pre-wrap">{{ announcement }}</p>
|
||
<p v-else class="text-gray-600 italic">暂无公告</p>
|
||
</div>
|
||
</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>
|
||
|
||
<!-- 2.5 群二维码 -->
|
||
<button class="info-action-btn group" @click="showQrModal = true">
|
||
<div class="icon-box bg-amber-500/10 text-amber-400 group-hover:bg-amber-500 group-hover:text-white border border-amber-500/20">
|
||
<i class="fas fa-qrcode"></i>
|
||
</div>
|
||
<span class="text-xs text-gray-400 group-hover:text-white mt-2">二维码</span>
|
||
</button>
|
||
|
||
<!-- 2.6 群文件 -->
|
||
<button class="info-action-btn group" @click="openGroupFiles">
|
||
<div class="icon-box bg-cyan-500/10 text-cyan-400 group-hover:bg-cyan-500 group-hover:text-white border border-cyan-500/20">
|
||
<i class="fas fa-folder-open"></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>
|
||
|
||
<!-- 3.5 群设置按钮 -->
|
||
<button v-if="canEdit" class="info-action-btn group" @click="openSettingsModal">
|
||
<div class="icon-box bg-teal-500/10 text-teal-400 group-hover:bg-teal-500 group-hover:text-white border border-teal-500/20">
|
||
<i class="fas fa-cog"></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="openAdminModal">
|
||
<div class="icon-box bg-purple-500/10 text-purple-400 group-hover:bg-purple-500 group-hover:text-white border border-purple-500/20">
|
||
<i class="fas fa-user-shield"></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="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>
|
||
|
||
<!-- 6. 解散按钮 -->
|
||
<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"
|
||
>
|
||
<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 v-else-if="member.role === 1" class="absolute -top-1 -right-1 bg-blue-500 text-white text-[8px] p-0.5 rounded-full w-3 h-3 flex items-center justify-center shadow-sm">
|
||
<i class="fas fa-star"></i>
|
||
</div>
|
||
<!-- 移除按钮 -->
|
||
<button
|
||
v-if="canRemoveMember(member)"
|
||
class="absolute -top-1 -left-1 w-4 h-4 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-10"
|
||
@click.stop="showRemoveConfirm(member)"
|
||
title="移除成员"
|
||
>
|
||
<i class="fas fa-minus text-[8px]"></i>
|
||
</button>
|
||
<!-- 转让群主按钮(仅群主可见,且不能转让给自己) -->
|
||
<button
|
||
v-if="isOwner && member.user_id !== authStore.user?.id"
|
||
class="absolute -bottom-1 -left-1 w-4 h-4 bg-yellow-500 hover:bg-yellow-600 text-black rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-10"
|
||
@click.stop="showTransferConfirm(member)"
|
||
title="转让群主"
|
||
>
|
||
<i class="fas fa-crown text-[8px]"></i>
|
||
</button>
|
||
</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>
|
||
|
||
<!-- 弹窗 1: 邀请好友 -->
|
||
<SelectContactsModal v-if="showInviteModal" :show="showInviteModal" :contacts="availableContacts" mode="invite" title="邀请好友加入" @close="showInviteModal = false" @invite="handleInviteMembers" />
|
||
|
||
<!-- 弹窗 2: 发起通话 -->
|
||
<GroupCallModal
|
||
v-if="showCallModal"
|
||
:show="showCallModal"
|
||
:members="callCandidates"
|
||
@close="showCallModal = false"
|
||
@start-call="handleConfirmStartCall"
|
||
/>
|
||
|
||
<!-- 弹窗 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 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>
|
||
|
||
<!-- 弹窗 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>
|
||
|
||
<!-- 弹窗 5: 移除成员确认 -->
|
||
<div v-if="removingMember" 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">
|
||
<div class="flex justify-center mb-4">
|
||
<Avatar
|
||
:name="removingMember.user?.name || removingMember.nickname"
|
||
:avatar="removingMember.user?.avatar"
|
||
size="lg"
|
||
rounded="full"
|
||
/>
|
||
</div>
|
||
<h3 class="text-white font-bold mb-2">移除成员</h3>
|
||
<p class="text-gray-400 text-sm mb-6">
|
||
确定要将 <span class="text-white font-medium">{{ removingMember.nickname || removingMember.user?.name || '该成员' }}</span> 移出群聊吗?
|
||
</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="removingMember = null">取消</button>
|
||
<button class="flex-1 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm" :disabled="removing" @click="handleRemoveMember">
|
||
<i v-if="removing" class="fas fa-circle-notch fa-spin mr-1"></i>
|
||
确定移除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 弹窗 6: 转让群主确认 -->
|
||
<div v-if="transferringTo" 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">
|
||
<div class="flex justify-center mb-4">
|
||
<div class="relative">
|
||
<Avatar
|
||
:name="transferringTo.user?.name || transferringTo.nickname"
|
||
:avatar="transferringTo.user?.avatar"
|
||
size="lg"
|
||
rounded="full"
|
||
/>
|
||
<div class="absolute -top-1 -right-1 bg-yellow-500 text-black text-[10px] p-1 rounded-full w-5 h-5 flex items-center justify-center shadow-sm">
|
||
<i class="fas fa-crown"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<h3 class="text-white font-bold mb-2">转让群主</h3>
|
||
<p class="text-gray-400 text-sm mb-6">
|
||
确定要将群主转让给 <span class="text-yellow-400 font-medium">{{ transferringTo.nickname || transferringTo.user?.name || '该成员' }}</span> 吗?<br/>
|
||
<span class="text-xs text-gray-500">转让后您将成为普通成员</span>
|
||
</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="transferringTo = null">取消</button>
|
||
<button class="flex-1 py-2 bg-yellow-500 text-black rounded-lg hover:bg-yellow-400 text-sm font-bold" :disabled="transferring" @click="handleTransferOwner">
|
||
<i v-if="transferring" class="fas fa-circle-notch fa-spin mr-1"></i>
|
||
确定转让
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 弹窗 7: 管理员设置 -->
|
||
<div v-if="showAdminModal" 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-md border border-gray-700 shadow-2xl overflow-hidden max-h-[80vh] flex flex-col">
|
||
<div class="px-4 py-3 border-b border-gray-700 flex justify-between items-center bg-gray-900/50">
|
||
<div>
|
||
<h3 class="text-white font-bold text-sm">管理员设置</h3>
|
||
<p class="text-xs text-gray-400 mt-0.5">已选择 {{ selectedAdminIds.size }} 人</p>
|
||
</div>
|
||
<button class="text-gray-400 hover:text-white" @click="showAdminModal = false"><i class="fas fa-times"></i></button>
|
||
</div>
|
||
|
||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||
<div class="space-y-2">
|
||
<div
|
||
v-for="member in adminCandidates"
|
||
:key="member.user_id"
|
||
class="flex items-center gap-3 p-3 rounded-xl hover:bg-white/5 cursor-pointer transition"
|
||
:class="{ 'bg-purple-500/10': selectedAdminIds.has(member.user_id) }"
|
||
@click="toggleAdminSelection(member.user_id)"
|
||
>
|
||
<div class="relative">
|
||
<Avatar
|
||
:name="member.user?.name || member.nickname"
|
||
:avatar="member.user?.avatar"
|
||
size="sm"
|
||
rounded="full"
|
||
/>
|
||
<div v-if="member.role === 1" class="absolute -top-1 -right-1 bg-blue-500 text-white text-[8px] p-0.5 rounded-full w-3 h-3 flex items-center justify-center">
|
||
<i class="fas fa-star"></i>
|
||
</div>
|
||
</div>
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-sm text-white truncate">{{ member.nickname || member.user?.name || '未知' }}</p>
|
||
<p class="text-xs text-gray-500">{{ member.role === 1 ? '当前管理员' : '普通成员' }}</p>
|
||
</div>
|
||
<div
|
||
class="w-5 h-5 rounded border-2 transition flex items-center justify-center"
|
||
:class="selectedAdminIds.has(member.user_id) ? 'bg-purple-500 border-purple-500' : 'border-gray-600'"
|
||
>
|
||
<i v-if="selectedAdminIds.has(member.user_id)" class="fas fa-check text-white text-xs"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="px-4 py-3 border-t border-gray-700 bg-gray-900/50 flex justify-end gap-2">
|
||
<button class="px-4 py-2 text-gray-300 hover:text-white text-sm" @click="showAdminModal = false">取消</button>
|
||
<button
|
||
class="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-500 disabled:opacity-50"
|
||
:disabled="savingAdmins"
|
||
@click="handleSaveAdmins"
|
||
>
|
||
<i v-if="savingAdmins" class="fas fa-circle-notch fa-spin mr-1"></i>
|
||
保存设置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 用户信息卡片 -->
|
||
<UserInfoCard
|
||
:show="showUserInfoCard"
|
||
:user="selectedMemberUser"
|
||
@close="showUserInfoCard = false; selectedMemberUser = null"
|
||
@send-message="handleSendMessageFromCard"
|
||
@audio-call="handleAudioCallFromCard"
|
||
@video-call="handleVideoCallFromCard"
|
||
@view-moments="handleViewMomentsFromCard"
|
||
/>
|
||
|
||
<!-- 用户朋友圈模态框 -->
|
||
<UserMomentsModal
|
||
:show="showUserMomentsModal"
|
||
:user="selectedMemberUser"
|
||
@close="showUserMomentsModal = false"
|
||
/>
|
||
|
||
<!-- 弹窗 7.5: 群二维码 -->
|
||
<div v-if="showQrModal" class="fixed inset-0 bg-black/80 z-[1000] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showQrModal = false">
|
||
<div class="bg-gray-800 rounded-xl w-full max-w-sm border border-gray-700 shadow-2xl p-6 text-center">
|
||
<h3 class="text-white font-bold mb-2">群二维码</h3>
|
||
<p class="text-gray-400 text-xs mb-4">分享群 ID 邀请好友加入</p>
|
||
<div class="bg-white p-4 rounded-lg inline-block mb-4">
|
||
<img
|
||
v-if="groupInfo?.room_id"
|
||
:src="`https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(groupInfo.room_id)}`"
|
||
alt="群二维码"
|
||
class="w-44 h-44"
|
||
/>
|
||
</div>
|
||
<p class="text-gray-300 text-sm font-mono break-all mb-4 select-all">{{ groupInfo?.room_id }}</p>
|
||
<button class="w-full py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm" @click="copyGroupId">
|
||
复制群 ID
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 弹窗 8: 群设置 -->
|
||
<div v-if="showSettingsModal" 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-md border border-gray-700 shadow-2xl overflow-hidden">
|
||
<div class="px-4 py-3 border-b border-gray-700 flex justify-between items-center bg-gray-900/50">
|
||
<h3 class="text-white font-bold text-sm">群设置</h3>
|
||
<button class="text-gray-400 hover:text-white" @click="showSettingsModal = false"><i class="fas fa-times"></i></button>
|
||
</div>
|
||
<div v-if="loadingSettings" class="p-8 flex justify-center">
|
||
<i class="fas fa-circle-notch fa-spin text-xl text-indigo-500"></i>
|
||
</div>
|
||
<div v-else class="p-4 space-y-4">
|
||
<!-- 群简介 -->
|
||
<div>
|
||
<label class="block text-gray-400 text-xs mb-2">群简介</label>
|
||
<textarea
|
||
v-model="groupSettings.description"
|
||
class="w-full bg-black/20 border border-gray-600 rounded-lg p-3 text-sm text-white focus:border-indigo-500 focus:outline-none h-24 resize-none"
|
||
placeholder="请输入群简介..."
|
||
></textarea>
|
||
</div>
|
||
<!-- 邀请权限 -->
|
||
<div>
|
||
<label class="block text-gray-400 text-xs mb-2">邀请权限</label>
|
||
<div class="grid grid-cols-3 gap-2">
|
||
<button
|
||
v-for="perm in [0, 1, 2]"
|
||
:key="perm"
|
||
class="py-2 px-3 rounded-lg text-sm transition border"
|
||
:class="groupSettings.invite_permission === perm
|
||
? 'bg-indigo-600 text-white border-indigo-500'
|
||
: 'bg-gray-700/50 text-gray-300 border-gray-600 hover:bg-gray-600'"
|
||
@click="groupSettings.invite_permission = perm"
|
||
>
|
||
{{ getInvitePermissionText(perm) }}
|
||
</button>
|
||
</div>
|
||
<p class="text-xs text-gray-500 mt-2">
|
||
<i class="fas fa-info-circle mr-1"></i>
|
||
设置谁可以邀请新成员加入群聊
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="px-4 py-3 bg-gray-900/50 border-t border-gray-700 flex justify-end gap-2">
|
||
<button class="px-4 py-2 text-gray-300 hover:text-white text-sm" @click="showSettingsModal = false">取消</button>
|
||
<button
|
||
class="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-500 disabled:opacity-50"
|
||
:disabled="savingSettings"
|
||
@click="saveGroupSettings"
|
||
>
|
||
<i v-if="savingSettings" class="fas fa-circle-notch fa-spin mr-1"></i>
|
||
保存设置
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 弹窗 9: 群公告详情/编辑 -->
|
||
<div v-if="showAnnouncementModal" 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-md border border-gray-700 shadow-2xl overflow-hidden">
|
||
<div class="px-4 py-3 border-b border-gray-700 flex justify-between items-center bg-gray-900/50">
|
||
<h3 class="text-white font-bold text-sm">群公告</h3>
|
||
<button class="text-gray-400 hover:text-white" @click="showAnnouncementModal = false"><i class="fas fa-times"></i></button>
|
||
</div>
|
||
<div class="p-4">
|
||
<textarea
|
||
v-if="isEditingAnnouncement"
|
||
v-model="editAnnouncementText"
|
||
class="w-full bg-black/20 border border-gray-600 rounded-lg p-3 text-sm text-white focus:border-indigo-500 focus:outline-none h-40 resize-none"
|
||
placeholder="请输入群公告..."
|
||
></textarea>
|
||
<div v-else class="min-h-[100px] text-sm text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||
{{ announcement || '暂无公告' }}
|
||
</div>
|
||
</div>
|
||
<div class="px-4 py-3 bg-gray-900/50 border-t border-gray-700 flex justify-end gap-2" v-if="canEditAnnouncement">
|
||
<template v-if="isEditingAnnouncement">
|
||
<button class="px-3 py-1.5 text-xs text-gray-300 hover:text-white" @click="isEditingAnnouncement = false">取消</button>
|
||
<button class="px-3 py-1.5 text-xs bg-indigo-600 text-white rounded hover:bg-indigo-500" @click="saveAnnouncement">保存</button>
|
||
</template>
|
||
<button v-else class="px-3 py-1.5 text-xs bg-gray-700 text-white rounded hover:bg-gray-600" @click="startEditAnnouncement">编辑公告</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, watch } from 'vue'
|
||
import Avatar from '@/components/common/Avatar.vue'
|
||
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
|
||
import GroupCallModal from '@/components/chat/GroupCallModal.vue'
|
||
import UserInfoCard from '@/components/common/UserInfoCard.vue'
|
||
import UserMomentsModal from '@/components/moment/UserMomentsModal.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 { useGroupWebRTC } from '@/composables/useGroupWebRTC'
|
||
import { useWebRTCStore } from '@/stores/webrtc'
|
||
import type { GroupInfo, GroupMember, Contact, User } from '@/types/api'
|
||
|
||
const props = defineProps<{ show: boolean; roomId: string }>()
|
||
const emit = defineEmits(['close', 'updated', 'quit', 'dissolve'])
|
||
|
||
const authStore = useAuthStore()
|
||
const toastStore = useToastStore()
|
||
const chatStore = useChatStore()
|
||
const groupWebRTC = useGroupWebRTC()
|
||
const webrtcStore = useWebRTCStore()
|
||
const webrtc = webrtcStore.webrtc
|
||
|
||
const loading = ref(false)
|
||
// 用户信息卡片状态
|
||
const showUserInfoCard = ref(false)
|
||
const selectedMemberUser = ref<User | null>(null)
|
||
const showUserMomentsModal = ref(false)
|
||
const groupInfo = ref<GroupInfo | null>(null)
|
||
const members = ref<GroupMember[]>([])
|
||
const availableContacts = ref<Contact[]>([])
|
||
|
||
const showInviteModal = ref(false)
|
||
const showQrModal = ref(false)
|
||
const showEditModal = ref(false)
|
||
const showQuitConfirm = ref(false)
|
||
const showDissolveConfirm = ref(false)
|
||
const showCallModal = ref(false)
|
||
const removingMember = ref<GroupMember | null>(null)
|
||
const removing = ref(false)
|
||
const transferringTo = ref<GroupMember | null>(null)
|
||
const transferring = ref(false)
|
||
const showAdminModal = ref(false)
|
||
const selectedAdminIds = ref<Set<string>>(new Set())
|
||
const savingAdmins = ref(false)
|
||
|
||
const editForm = ref({ name: '', avatar: '' })
|
||
|
||
// 群设置相关
|
||
const showSettingsModal = ref(false)
|
||
const groupSettings = ref({
|
||
description: '',
|
||
invite_permission: 0 // 0-所有人 1-仅管理员 2-仅群主
|
||
})
|
||
const loadingSettings = ref(false)
|
||
const savingSettings = ref(false)
|
||
|
||
// 群公告相关
|
||
const announcement = ref('')
|
||
const showAnnouncementModal = ref(false)
|
||
const isEditingAnnouncement = ref(false)
|
||
const editAnnouncementText = ref('')
|
||
|
||
// Computed
|
||
const currentUserRole = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.role ?? 0)
|
||
const isOwner = computed(() => currentUserRole.value === 2)
|
||
const isAdmin = computed(() => currentUserRole.value === 1)
|
||
const canInvite = computed(() => isOwner.value || isAdmin.value)
|
||
const canEdit = computed(() => isOwner.value)
|
||
const canRemove = computed(() => isOwner.value || isAdmin.value)
|
||
const canEditAnnouncement = computed(() => isOwner.value || isAdmin.value)
|
||
|
||
// 判断是否可以移除某个成员
|
||
function canRemoveMember(member: GroupMember): boolean {
|
||
// 不能移除自己
|
||
if (member.user_id === authStore.user?.id) return false
|
||
// 群主可以移除任何人
|
||
if (isOwner.value) return true
|
||
// 管理员只能移除普通成员(role=0)
|
||
if (isAdmin.value && member.role === 0) return true
|
||
return false
|
||
}
|
||
|
||
// 转换群成员为 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))
|
||
})
|
||
|
||
// 管理员候选人(排除群主)
|
||
const adminCandidates = computed(() => {
|
||
return members.value.filter(m => m.user_id !== authStore.user?.id && m.role !== 2)
|
||
})
|
||
|
||
// Methods
|
||
async function loadGroupInfo() {
|
||
if (!props.roomId) return
|
||
loading.value = true
|
||
try {
|
||
const [info, memberList, contacts, ann] = await Promise.all([
|
||
groupApi.getGroup(props.roomId),
|
||
groupApi.getGroupMembers(props.roomId),
|
||
contactApi.getContacts(),
|
||
groupApi.getGroupAnnouncement(props.roomId).catch(() => ({ announcement: '' }))
|
||
])
|
||
// 后端返回结构:{ room: { room_name, room_avatar, ... }, member_count, owner_id, ... }
|
||
// 从嵌套的 room 对象中提取群信息
|
||
const roomInfo = info.room
|
||
const normalizedInfo = {
|
||
room_id: roomInfo?.room_id || props.roomId,
|
||
room_type: 'group' as const,
|
||
name: roomInfo?.room_name || '未命名群聊',
|
||
avatar: roomInfo?.room_avatar || '',
|
||
owner_id: info.owner_id || roomInfo?.owner_id || '',
|
||
member_count: info.member_count || 0,
|
||
created_at: roomInfo?.created_at || ''
|
||
}
|
||
groupInfo.value = normalizedInfo as any
|
||
members.value = memberList
|
||
announcement.value = ann.announcement || ''
|
||
|
||
editForm.value = { name: normalizedInfo.name, avatar: normalizedInfo.avatar }
|
||
|
||
// 过滤可邀请的好友
|
||
const memberIds = new Set(memberList.map(m => m.user_id))
|
||
availableContacts.value = contacts.filter(c => !memberIds.has(c.user_id))
|
||
} catch(e) {
|
||
toastStore.error('加载群信息失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 通话逻辑
|
||
function handleStartCall() {
|
||
if (callCandidates.value.length === 0) {
|
||
toastStore.info('群里只有你一个人,无法通话')
|
||
return
|
||
}
|
||
showCallModal.value = true
|
||
}
|
||
|
||
// 确认发起通话 (接收类型和成员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 handleInviteMembers(data: { member_ids: string[] }) {
|
||
try {
|
||
await groupApi.inviteGroupMembers(props.roomId, { member_ids: data.member_ids })
|
||
toastStore.success('邀请成功')
|
||
showInviteModal.value = false
|
||
loadGroupInfo()
|
||
emit('updated')
|
||
} catch(e) {
|
||
toastStore.error('邀请失败')
|
||
}
|
||
}
|
||
|
||
// 更新逻辑
|
||
async function handleUpdateGroup() {
|
||
if(!editForm.value.name) return toastStore.warning('群名不能为空')
|
||
try {
|
||
await groupApi.updateGroup(props.roomId, editForm.value)
|
||
toastStore.success('更新成功')
|
||
showEditModal.value = false
|
||
loadGroupInfo()
|
||
emit('updated')
|
||
} catch(e) { toastStore.error('更新失败') }
|
||
}
|
||
|
||
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('已退出')
|
||
closeConfirmDialogs()
|
||
emit('quit')
|
||
} catch(e) { toastStore.error('退出失败') }
|
||
}
|
||
|
||
async function handleDissolve() {
|
||
try {
|
||
await groupApi.dissolveGroup(props.roomId)
|
||
toastStore.success('已解散')
|
||
closeConfirmDialogs()
|
||
emit('dissolve')
|
||
} catch(e) { toastStore.error('解散失败') }
|
||
}
|
||
|
||
function handleMemberClick(member: GroupMember) {
|
||
// 打开用户信息卡片
|
||
if (member.user) {
|
||
selectedMemberUser.value = member.user as User
|
||
} else {
|
||
// 如果没有完整用户信息,构造一个基本的 User 对象
|
||
selectedMemberUser.value = {
|
||
id: member.user_id,
|
||
name: member.nickname || '未知',
|
||
avatar: '',
|
||
email: '',
|
||
phone: '',
|
||
desc: '',
|
||
region: '',
|
||
created_at: '',
|
||
updated_at: '',
|
||
} as User
|
||
}
|
||
showUserInfoCard.value = true
|
||
}
|
||
|
||
// 从用户信息卡片发送消息
|
||
function handleSendMessageFromCard() {
|
||
if (!selectedMemberUser.value) return
|
||
|
||
const userId = selectedMemberUser.value.id
|
||
let contact = chatStore.contacts.find(c => c.user_id === userId || c.id === userId)
|
||
|
||
if (!contact) {
|
||
contact = {
|
||
id: userId,
|
||
user_id: userId,
|
||
contact_user_id: userId,
|
||
room_id: '',
|
||
room_type: 'p2p',
|
||
is_group: false,
|
||
remark_name: selectedMemberUser.value.name,
|
||
is_top: false,
|
||
is_muted: false,
|
||
user: selectedMemberUser.value,
|
||
} as Contact
|
||
}
|
||
|
||
chatStore.setCurrentTarget(contact)
|
||
showUserInfoCard.value = false
|
||
selectedMemberUser.value = null
|
||
emit('close')
|
||
}
|
||
|
||
// 从用户信息卡片发起语音通话
|
||
async function handleAudioCallFromCard() {
|
||
if (!selectedMemberUser.value) return
|
||
const userId = selectedMemberUser.value.id
|
||
const contact = chatStore.contacts.find(c => c.user_id === userId || c.id === userId)
|
||
if (contact) {
|
||
await webrtc.startCall('audio', userId, contact.room_id, contact)
|
||
}
|
||
showUserInfoCard.value = false
|
||
selectedMemberUser.value = null
|
||
emit('close')
|
||
}
|
||
|
||
// 从用户信息卡片发起视频通话
|
||
async function handleVideoCallFromCard() {
|
||
if (!selectedMemberUser.value) return
|
||
const userId = selectedMemberUser.value.id
|
||
const contact = chatStore.contacts.find(c => c.user_id === userId || c.id === userId)
|
||
if (contact) {
|
||
await webrtc.startCall('video', userId, contact.room_id, contact)
|
||
}
|
||
showUserInfoCard.value = false
|
||
selectedMemberUser.value = null
|
||
emit('close')
|
||
}
|
||
|
||
// 从用户信息卡片查看朋友圈
|
||
function handleViewMomentsFromCard() {
|
||
if (!selectedMemberUser.value) return
|
||
showUserInfoCard.value = false
|
||
showUserMomentsModal.value = true
|
||
}
|
||
|
||
// 移除成员相关
|
||
function showRemoveConfirm(member: GroupMember) {
|
||
removingMember.value = member
|
||
}
|
||
|
||
async function handleRemoveMember() {
|
||
if (!removingMember.value) return
|
||
removing.value = true
|
||
try {
|
||
await groupApi.removeGroupMember(props.roomId, removingMember.value.user_id)
|
||
toastStore.success('已移除该成员')
|
||
removingMember.value = null
|
||
loadGroupInfo()
|
||
emit('updated')
|
||
} catch (e) {
|
||
toastStore.error('移除失败')
|
||
} finally {
|
||
removing.value = false
|
||
}
|
||
}
|
||
|
||
// 转让群主相关
|
||
function showTransferConfirm(member: GroupMember) {
|
||
transferringTo.value = member
|
||
}
|
||
|
||
async function handleTransferOwner() {
|
||
if (!transferringTo.value) return
|
||
transferring.value = true
|
||
try {
|
||
// 调用 updateMemberRole 将目标成员设置为群主 (role=2)
|
||
await groupApi.updateMemberRole(props.roomId, transferringTo.value.user_id, { role: 2 })
|
||
toastStore.success('群主已转让')
|
||
transferringTo.value = null
|
||
loadGroupInfo()
|
||
emit('updated')
|
||
} catch (e: any) {
|
||
toastStore.error(e?.response?.data?.message || e?.message || '转让失败')
|
||
} finally {
|
||
transferring.value = false
|
||
}
|
||
}
|
||
|
||
// 管理员设置相关
|
||
function openAdminModal() {
|
||
// 初始化选中状态,当前管理员默认选中
|
||
selectedAdminIds.value = new Set(
|
||
members.value.filter(m => m.role === 1).map(m => m.user_id)
|
||
)
|
||
showAdminModal.value = true
|
||
}
|
||
|
||
function toggleAdminSelection(userId: string) {
|
||
if (selectedAdminIds.value.has(userId)) {
|
||
selectedAdminIds.value.delete(userId)
|
||
} else {
|
||
selectedAdminIds.value.add(userId)
|
||
}
|
||
// 触发响应式更新
|
||
selectedAdminIds.value = new Set(selectedAdminIds.value)
|
||
}
|
||
|
||
async function handleSaveAdmins() {
|
||
savingAdmins.value = true
|
||
try {
|
||
// 获取当前管理员列表
|
||
const currentAdminIds = new Set(members.value.filter(m => m.role === 1).map(m => m.user_id))
|
||
|
||
// 需要设置为管理员的(新增)
|
||
const toAdd = [...selectedAdminIds.value].filter(id => !currentAdminIds.has(id))
|
||
// 需要取消管理员的(移除)
|
||
const toRemove = [...currentAdminIds].filter(id => !selectedAdminIds.value.has(id))
|
||
|
||
// 批量设置
|
||
const promises: Promise<void>[] = []
|
||
for (const userId of toAdd) {
|
||
promises.push(groupApi.updateMemberRole(props.roomId, userId, { role: 1 }))
|
||
}
|
||
for (const userId of toRemove) {
|
||
promises.push(groupApi.updateMemberRole(props.roomId, userId, { role: 0 }))
|
||
}
|
||
|
||
await Promise.all(promises)
|
||
|
||
toastStore.success('管理员设置已保存')
|
||
showAdminModal.value = false
|
||
loadGroupInfo()
|
||
emit('updated')
|
||
} catch (e: any) {
|
||
toastStore.error(e?.response?.data?.message || e?.message || '保存失败')
|
||
} finally {
|
||
savingAdmins.value = false
|
||
}
|
||
}
|
||
|
||
// 群设置相关方法
|
||
async function openSettingsModal() {
|
||
showSettingsModal.value = true
|
||
loadingSettings.value = true
|
||
try {
|
||
const settings = await groupApi.getGroupSettings(props.roomId)
|
||
groupSettings.value = {
|
||
description: settings.description || '',
|
||
invite_permission: settings.invite_permission ?? 0
|
||
}
|
||
} catch (e) {
|
||
// 接口可能不存在,使用默认值
|
||
groupSettings.value = { description: '', invite_permission: 0 }
|
||
} finally {
|
||
loadingSettings.value = false
|
||
}
|
||
}
|
||
|
||
async function saveGroupSettings() {
|
||
savingSettings.value = true
|
||
try {
|
||
await groupApi.updateGroupSettings(props.roomId, {
|
||
description: groupSettings.value.description,
|
||
invite_permission: groupSettings.value.invite_permission
|
||
})
|
||
toastStore.success('群设置已保存')
|
||
showSettingsModal.value = false
|
||
emit('updated')
|
||
} catch (e) {
|
||
toastStore.error('保存失败')
|
||
} finally {
|
||
savingSettings.value = false
|
||
}
|
||
}
|
||
|
||
function getInvitePermissionText(permission: number): string {
|
||
switch (permission) {
|
||
case 0: return '所有人'
|
||
case 1: return '仅管理员'
|
||
case 2: return '仅群主'
|
||
default: return '所有人'
|
||
}
|
||
}
|
||
|
||
/** 复制群 ID 到剪贴板 */
|
||
function copyGroupId() {
|
||
if (!groupInfo.value?.room_id) return
|
||
navigator.clipboard.writeText(groupInfo.value.room_id).then(() => {
|
||
toastStore.success('群 ID 已复制')
|
||
}).catch(() => toastStore.error('复制失败'))
|
||
}
|
||
|
||
/** 群文件:跳转附件列表(按群 room_id 筛选) */
|
||
function openGroupFiles() {
|
||
toastStore.info(`群文件:${props.roomId}(可在聊天中发送的文件消息查看)`)
|
||
}
|
||
|
||
// 群公告相关方法
|
||
function openAnnouncementModal() {
|
||
isEditingAnnouncement.value = false
|
||
showAnnouncementModal.value = true
|
||
}
|
||
|
||
function startEditAnnouncement() {
|
||
editAnnouncementText.value = announcement.value
|
||
isEditingAnnouncement.value = true
|
||
}
|
||
|
||
async function saveAnnouncement() {
|
||
try {
|
||
await groupApi.updateGroupAnnouncement(props.roomId, editAnnouncementText.value)
|
||
announcement.value = editAnnouncementText.value
|
||
isEditingAnnouncement.value = false
|
||
showAnnouncementModal.value = false
|
||
toastStore.success('公告已更新')
|
||
} catch(e) {
|
||
toastStore.error('更新失败')
|
||
}
|
||
}
|
||
|
||
watch(() => props.show, (val) => {
|
||
if (val) loadGroupInfo()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.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;
|
||
}
|
||
.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.2s ease-out;
|
||
}
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: scale(0.98); }
|
||
to { opacity: 1; transform: scale(1); }
|
||
}
|
||
</style>
|