Files
nl-um-vue-ts/src/components/chat/GroupInfoPanel.vue
2026-07-08 08:17:58 +08:00

999 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>