Files
nl-um-vue-ts/src/components/chat/GroupInfoPanel.vue
2025-12-05 15:23:14 +08:00

460 lines
14 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
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>
<!-- 加载状态 -->
<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>
</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">
<Avatar
:name="groupInfo.name"
:avatar="groupInfo.avatar || groupInfo.name?.charAt(0)"
size="xl"
rounded="full"
class="mb-4 border-4 border-gray-700"
/>
<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>
<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)"
>
<i class="fas fa-user-minus"></i>
</button>
</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>
<!-- 邀请成员弹窗 -->
<SelectContactsModal
v-if="showInviteModal"
:show="showInviteModal"
:contacts="availableContacts"
@close="showInviteModal = false"
@create="handleInviteMembers"
/>
<!-- 编辑群信息弹窗 -->
<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"
/>
</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>
</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>
</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"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import type { GroupInfo, GroupMember, Contact } from '@/types/api'
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'
interface Props {
show: boolean
roomId: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'close': []
'updated': []
'quit': []
'dissolve': []
}>()
const authStore = useAuthStore()
const chatStore = useChatStore()
const toastStore = useToastStore()
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 currentUserRole = computed(() => {
if (!authStore.user) return -1
const member = members.value.find(m => m.user_id === authStore.user!.id)
return member?.role ?? -1
})
// 是否为群主
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 removeMemberMessage = computed(() => {
const name = pendingMember.value?.user?.name || pendingMember.value?.nickname || '未知'
return `确定要将 ${name} 移出群聊吗?`
})
// 加载群信息
async function loadGroupInfo() {
if (!props.roomId) return
loading.value = true
try {
const [group, memberList] = await Promise.all([
groupApi.getGroup(props.roomId),
groupApi.getGroupMembers(props.roomId)
])
groupInfo.value = group
members.value = memberList
// 加载可用联系人(用于邀请)
const contacts = await contactApi.getContacts()
// 过滤掉已经是群成员的联系人
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)
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) {
// 可以扩展为显示成员详情
}
// 移除成员
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('移除成员失败')
}
}
// 邀请单个联系人
async function handleInviteContact(contact: Contact) {
try {
await groupApi.inviteGroupMembers(props.roomId, { member_ids: [contact.user_id || contact.id] })
toastStore.success('邀请成功')
showInviteModal.value = false
await loadGroupInfo()
emit('updated')
} catch (error: any) {
toastStore.error('邀请失败')
}
}
// 更新群信息
async function handleUpdateGroup() {
if (!editForm.value.name.trim()) {
toastStore.warning('群名称不能为空')
return
}
try {
await groupApi.updateGroup(props.roomId, {
name: editForm.value.name.trim(),
avatar: editForm.value.avatar || editForm.value.name.charAt(0).toUpperCase()
})
toastStore.success('更新成功')
showEditModal.value = false
await loadGroupInfo()
emit('updated')
} catch (error: any) {
toastStore.error('更新失败')
}
}
// 退出群聊
function showQuitConfirmDialog() {
showQuitConfirm.value = true
}
async function handleQuit() {
try {
await groupApi.quitGroup(props.roomId)
toastStore.success('已退出群聊')
showQuitConfirm.value = false
emit('quit')
emit('close')
} catch (error: any) {
toastStore.error('退出失败')
}
}
// 解散群聊
function showDissolveConfirmDialog() {
showDissolveConfirm.value = true
}
async function handleDissolve() {
try {
await groupApi.dissolveGroup(props.roomId)
toastStore.success('群聊已解散')
emit('dissolve')
emit('close')
} catch (error: any) {
toastStore.error('解散失败')
}
}
// 监听 show 变化,加载数据
watch(() => props.show, (newVal) => {
if (newVal) {
loadGroupInfo()
}
})
</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.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
</style>