Files
nl-um-vue-ts/src/components/chat/GroupChatPanel.vue
2025-12-08 13:57:37 +08:00

543 lines
20 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>
<div class="w-80 bg-[#171a21] border-l border-gray-800 flex flex-col h-full shadow-2xl relative z-10 font-sans select-none">
<!-- 1. 顶部群信息卡片 -->
<div class="relative overflow-hidden shrink-0 pb-4">
<!-- 动态背景 -->
<div class="absolute inset-0 bg-gradient-to-br from-indigo-900/30 via-slate-900/50 to-[#171a21] z-0 pointer-events-none"></div>
<div class="relative z-10 px-5 pt-6">
<div class="flex items-start gap-4 mb-5">
<!-- 群头像 -->
<div class="relative group cursor-pointer transition-transform active:scale-95" @click="emit('show-info')">
<Avatar
:name="groupName"
:avatar="groupAvatar"
size="lg"
rounded="2xl"
class="shadow-xl ring-2 ring-white/10 group-hover:ring-indigo-500/50 transition-all"
/>
<div class="absolute -bottom-1 -right-1 bg-gray-800 rounded-full p-1 border border-gray-700 shadow-sm">
<i class="fas fa-crown text-[10px] text-yellow-500" v-if="isOwner"></i>
<i class="fas fa-shield-alt text-[10px] text-blue-400" v-else-if="isAdmin"></i>
</div>
</div>
<div class="flex-1 min-w-0 pt-1">
<h2 class="text-lg font-bold text-white tracking-tight truncate leading-snug mb-1 cursor-pointer hover:text-indigo-300 transition" @click="emit('show-info')">
{{ groupName || '加载中...' }}
</h2>
<div class="flex items-center gap-2 text-xs text-gray-400">
<span class="bg-white/5 px-1.5 py-0.5 rounded text-[10px] font-mono border border-white/5">ID: {{ roomId.slice(-4) }}</span>
<span>{{ members.length }} 成员</span>
</div>
</div>
</div>
<!-- 2. 快捷操作栏核心功能入口 -->
<div class="grid grid-cols-4 gap-2">
<!-- A. 发起群通话 -->
<button
class="action-btn-col bg-green-600/10 text-green-400 hover:bg-green-600/20 border border-green-500/20"
@click="handleStartCallClick"
title="语音/视频通话"
>
<i class="fas fa-video mb-1.5 text-sm"></i>
<span>通话</span>
</button>
<!-- B. 邀请成员 -->
<button
class="action-btn-col bg-blue-600/10 text-blue-400 hover:bg-blue-600/20 border border-blue-500/20"
@click="showInviteModal = true"
v-if="canInvite"
>
<i class="fas fa-user-plus mb-1.5 text-sm"></i>
<span>邀请</span>
</button>
<!-- C. 群公告 -->
<button
class="action-btn-col bg-purple-600/10 text-purple-400 hover:bg-purple-600/20 border border-purple-500/20"
@click="openAnnouncementModal"
>
<i class="fas fa-bullhorn mb-1.5 text-sm"></i>
<span>公告</span>
</button>
<!-- D. 管理/设置 -->
<button
class="action-btn-col bg-gray-600/10 text-gray-400 hover:bg-gray-600/20 border border-gray-500/20"
@click="emit('show-info')"
>
<i class="fas fa-cog mb-1.5 text-sm"></i>
<span>管理</span>
</button>
</div>
</div>
</div>
<!-- 3. 公告预览 (折叠式) -->
<div
class="px-5 py-3 border-y border-white/5 bg-white/[0.02] cursor-pointer hover:bg-white/[0.04] transition"
v-if="announcement"
@click="openAnnouncementModal"
>
<div class="flex items-center gap-2 mb-1.5">
<i class="fas fa-quote-left text-[10px] text-indigo-400"></i>
<span class="text-xs font-bold text-gray-400">群公告</span>
</div>
<p class="text-xs text-gray-300 line-clamp-2 leading-relaxed opacity-80">
{{ announcement }}
</p>
</div>
<!-- 4. 成员列表 -->
<div class="flex-1 overflow-hidden flex flex-col bg-[#171a21]">
<div class="px-5 py-3 flex items-center justify-between">
<h3 class="text-xs font-bold text-gray-500 uppercase tracking-wider">成员列表</h3>
<button class="text-gray-500 hover:text-white transition" @click="toggleSearch">
<i class="fas fa-search text-xs"></i>
</button>
</div>
<!-- 搜索栏 -->
<div v-if="showSearch" class="px-4 mb-2 animate-fade-in-down">
<div class="relative">
<input
v-model="memberSearchQuery"
type="text"
class="w-full bg-black/20 border border-gray-700 rounded-lg py-1.5 pl-8 pr-3 text-xs text-white focus:border-indigo-500 focus:outline-none placeholder-gray-600"
placeholder="搜索成员..."
@input="handleMemberSearch"
/>
<i class="fas fa-search absolute left-2.5 top-2 text-gray-600 text-[10px]"></i>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-3 space-y-1 pb-4">
<div
v-for="member in members"
:key="member.user_id"
class="flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group relative"
@click="handleMemberClick(member)"
@contextmenu.stop="showMemberContextMenu($event, member)"
>
<!-- 头像状态圈 -->
<div class="relative shrink-0">
<Avatar
:name="member.user?.name || member.nickname"
:avatar="member.user?.avatar"
size="sm"
rounded="full"
/>
<!-- 在线点 -->
<div
v-if="member.user?.is_online"
class="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-[#171a21]"
></div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-200 group-hover:text-white truncate">
{{ member.nickname || member.user?.name || '未知用户' }}
</span>
<!-- 身份标识 -->
<span v-if="member.role === 2" class="px-1.5 py-0.5 rounded text-[10px] bg-yellow-500/20 text-yellow-400 font-bold border border-yellow-500/20">群主</span>
<span v-else-if="member.role === 1" class="px-1.5 py-0.5 rounded text-[10px] bg-blue-500/20 text-blue-400 font-bold border border-blue-500/20">管理</span>
</div>
<p class="text-xs text-gray-500 truncate mt-0.5 group-hover:text-gray-400">
{{ member.user?.email || '无签名' }}
</p>
</div>
</div>
</div>
</div>
<!-- 弹窗 1邀请好友 (Mode=invite) -->
<SelectContactsModal
v-if="showInviteModal"
:show="showInviteModal"
:contacts="availableContacts"
mode="invite"
title="邀请好友加入群聊"
@close="showInviteModal = false"
@invite="handleInviteMembers"
/>
<!-- 弹窗 2发起通话 (替换为全新的 GroupCallModal) -->
<GroupCallModal
v-if="showCallModal"
:show="showCallModal"
:members="callCandidates"
@close="showCallModal = false"
@start-call="confirmStartCall"
/>
<!-- 弹窗 3公告详情/编辑 -->
<div v-if="showAnnouncementModal" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm animate-fade-in">
<div class="bg-gray-800 rounded-xl w-full max-w-sm 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>
<ContextMenu />
<!-- 弹窗 4移除成员确认 -->
<div v-if="removingMember" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm animate-fade-in">
<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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, 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 ContextMenu from '@/components/common/ContextMenu.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 { useContextMenu } from '@/composables/useContextMenu'
import { useGroupWebRTC } from '@/composables/useGroupWebRTC'
import type { GroupMember, Contact } from '@/types/api'
import type { MenuItem } from '@/stores/contextMenu'
const props = defineProps<{ roomId: string }>()
const emit = defineEmits(['member-clicked', 'member-removed', 'group-updated', 'show-info', 'show-notice'])
const authStore = useAuthStore()
const chatStore = useChatStore()
const toastStore = useToastStore()
const { showContextMenu } = useContextMenu()
const groupWebRTC = useGroupWebRTC()
// State
const members = ref<GroupMember[]>([])
const groupName = ref('')
const groupAvatar = ref('')
const announcement = ref('')
const memberSearchQuery = ref('')
const showSearch = ref(false)
const showInviteModal = ref(false)
const showCallModal = ref(false)
const showAnnouncementModal = ref(false)
const isEditingAnnouncement = ref(false)
const editAnnouncementText = ref('')
const removingMember = ref<GroupMember | null>(null)
const removing = ref(false)
// 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 canEditAnnouncement = computed(() => isOwner.value || isAdmin.value)
// 判断是否为好友
function isFriendCheck(userId: string): boolean {
return chatStore.contacts.some(c => c.user_id === userId || c.id === userId)
}
// 判断是否可以移除某个成员
function canRemoveMember(member: GroupMember): boolean {
// 不能移除自己
if (member.user_id === authStore.user?.id) return false
// 群主可以移除任何人
if (isOwner.value) return true
// 管理员只能移除普通成员
if (isAdmin.value && member.role === 0) return true
return false
}
// 过滤出可通话的候选人(排除自己)
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 availableContacts = computed(() => {
const memberIds = new Set(members.value.map(m => m.user_id))
return chatStore.contacts.filter(c => !memberIds.has(c.user_id || c.id))
})
// Methods
function toggleSearch() {
showSearch.value = !showSearch.value
if (!showSearch.value) memberSearchQuery.value = ''
}
async function loadData() {
if (!props.roomId) return
try {
const [info, memberList, ann] = await Promise.all([
groupApi.getGroup(props.roomId),
groupApi.getGroupMembers(props.roomId),
groupApi.getGroupAnnouncement(props.roomId).catch(() => ({ announcement: '' }))
])
groupName.value = info.name || '未命名群聊'
groupAvatar.value = info.avatar || ''
members.value = memberList
announcement.value = ann.announcement || ''
} catch (e) {
console.error(e)
}
}
async function handleMemberSearch() {
if (!memberSearchQuery.value) {
loadData()
return
}
try {
const res = await groupApi.getGroupMembers(props.roomId, memberSearchQuery.value)
members.value = res
} catch(e) {}
}
function handleMemberClick(member: GroupMember) {
emit('member-clicked', member)
}
function showMemberContextMenu(event: MouseEvent, member: GroupMember) {
const items: MenuItem[] = []
// 1. 查看资料 - 始终显示
items.push({
label: '查看资料',
icon: 'fas fa-user',
action: () => handleMemberClick(member)
})
// 不是自己时才显示以下选项
if (member.user_id !== authStore.user?.id) {
// 2. 添加好友 - 非好友时显示
if (!isFriendCheck(member.user_id)) {
items.push({
label: '添加好友',
icon: 'fas fa-user-plus',
action: () => handleAddFriendFromMenu(member)
})
}
// 3. 设置/取消管理员 - 仅群主可操作
if (isOwner.value) {
if (member.role === 0) {
// 普通成员 -> 设置为管理员
items.push({
label: '设置为管理员',
icon: 'fas fa-user-shield',
action: () => handleSetAdmin(member)
})
} else if (member.role === 1) {
// 管理员 -> 取消管理员
items.push({
label: '取消管理员',
icon: 'fas fa-user-minus',
action: () => handleRemoveAdmin(member)
})
}
}
// 4. 移除成员 - 群主可移除任何人,管理员只能移除普通成员
if (canRemoveMember(member)) {
items.push({
label: '移除成员',
icon: 'fas fa-user-times',
danger: true,
action: () => { removingMember.value = member }
})
}
}
showContextMenu(event, items)
}
// --- 成员管理逻辑 ---
async function handleAddFriendFromMenu(member: GroupMember) {
try {
await contactApi.addFriend({ to_user_id: member.user_id })
toastStore.success('好友申请已发送')
} catch (e: any) {
toastStore.error(e?.response?.data?.message || e?.message || '添加好友失败')
}
}
async function handleSetAdmin(member: GroupMember) {
try {
await groupApi.updateMemberRole(props.roomId, member.user_id, { role: 1 })
toastStore.success('已设置为管理员')
loadData()
emit('group-updated')
} catch (e: any) {
toastStore.error(e?.response?.data?.message || e?.message || '设置失败')
}
}
async function handleRemoveAdmin(member: GroupMember) {
try {
await groupApi.updateMemberRole(props.roomId, member.user_id, { role: 0 })
toastStore.success('已取消管理员')
loadData()
emit('group-updated')
} catch (e: any) {
toastStore.error(e?.response?.data?.message || e?.message || '取消失败')
}
}
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
loadData()
emit('member-removed')
emit('group-updated')
} catch (e: any) {
toastStore.error(e?.response?.data?.message || e?.message || '移除失败')
} finally {
removing.value = false
}
}
// --- 通话逻辑 ---
function handleStartCallClick() {
if (callCandidates.value.length === 0) {
toastStore.info('群里只有你一个人,无法通话')
return
}
showCallModal.value = true
}
// 确认发起通话
function confirmStartCall(type: 'audio' | 'video', selectedIds: string[]) {
if (!selectedIds.length) {
toastStore.warning('请选择至少一名成员')
return
}
groupWebRTC.startGroupCall(props.roomId, selectedIds, type)
showCallModal.value = false
}
// --- 邀请逻辑 ---
async function handleInviteMembers(data: { member_ids: string[] }) {
if (!data.member_ids?.length) return
try {
await groupApi.inviteGroupMembers(props.roomId, { member_ids: data.member_ids })
toastStore.success('邀请成功')
showInviteModal.value = false
loadData()
emit('group-updated')
} catch(e: any) {
toastStore.error(e.message || '邀请失败')
}
}
// --- 公告逻辑 ---
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
toastStore.success('公告已更新')
} catch(e) {
toastStore.error('更新失败')
}
}
watch(() => props.roomId, loadData, { immediate: true })
onMounted(() => {
if (chatStore.contacts.length === 0) {
contactApi.getContacts().then(res => chatStore.setContacts(res))
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.action-btn-col {
@apply flex flex-col items-center justify-center py-2.5 rounded-lg transition-all active:scale-95 text-xs font-medium cursor-pointer backdrop-blur-sm;
}
.animate-fade-in-down {
animation: fadeInDown 0.2s ease-out;
}
@keyframes fadeInDown {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>