diff --git a/src/components/chat/GroupChatPanel.vue b/src/components/chat/GroupChatPanel.vue index 2fd2b99..2ecfa59 100644 --- a/src/components/chat/GroupChatPanel.vue +++ b/src/components/chat/GroupChatPanel.vue @@ -204,6 +204,31 @@ + + +
+
+
+ +
+

移除成员

+

+ 确定要将 {{ removingMember.nickname || removingMember.user?.name || '该成员' }} 移出群聊吗? +

+
+ + +
+
+
@@ -221,9 +246,10 @@ 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', 'group-updated', 'show-info', 'show-notice']) +const emit = defineEmits(['member-clicked', 'member-removed', 'group-updated', 'show-info', 'show-notice']) const authStore = useAuthStore() const chatStore = useChatStore() @@ -243,13 +269,32 @@ const showCallModal = ref(false) const showAnnouncementModal = ref(false) const isEditingAnnouncement = ref(false) const editAnnouncementText = ref('') +const removingMember = ref(null) +const removing = ref(false) // Computed -const isOwner = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.role === 2) -const isAdmin = computed(() => members.value.find(m => m.user_id === authStore.user?.id)?.role === 1) +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 @@ -308,12 +353,108 @@ function handleMemberClick(member: GroupMember) { } function showMemberContextMenu(event: MouseEvent, member: GroupMember) { - const items = [ - { label: '查看资料', icon: 'fas fa-user', action: () => handleMemberClick(member) } - ] + 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) { diff --git a/src/views/chat/ChatView.vue b/src/views/chat/ChatView.vue index 1897b51..2f8ef68 100644 --- a/src/views/chat/ChatView.vue +++ b/src/views/chat/ChatView.vue @@ -1490,47 +1490,97 @@ async function startCall(type: 'audio' | 'video') { // 会话右键菜单 function showConversationMenu(event: MouseEvent, conv: any) { - showContextMenu(event, [ + const isGroup = conv.is_group || conv.room_type === 'group' + const contact = chatStore.contacts.find(c => c.user_id === conv.target_id || c.id === conv.target_id) + + const menuItems: Array<{ label: string; icon: string; action: () => void; danger?: boolean }> = [ + // 1. 置顶会话 { label: conv.is_top ? '取消置顶' : '置顶会话', icon: 'fas fa-thumbtack', action: async () => { - // 查找对应联系人以更新状态 - const contact = chatStore.contacts.find(c => c.user_id === conv.target_id || c.id === conv.target_id) if (contact) { try { await contactApi.updateContact(contact.id, { is_top: !contact.is_top }) - // 手动更新store中的状态,触发 filteredConversations 重新计算 contact.is_top = !contact.is_top - // 强制刷新会话列表 (触发 computed 更新) await conversationStore.loadConversations() toastStore.success(contact.is_top ? '已置顶' : '已取消置顶') } catch(e) { toastStore.error('操作失败') } } else { - toastStore.warning('无法找到对应联系人') + // 群聊可能没有 contact,使用 conversationApi + try { + await conversationApi.updateConversation({ target_id: conv.target_id, is_top: !conv.is_top }) + await conversationStore.loadConversations() + toastStore.success(!conv.is_top ? '已置顶' : '已取消置顶') + } catch(e) { + toastStore.error('操作失败') + } } } }, + // 2. 免打扰 { - label: '标记已读', - icon: 'fas fa-check-double', + label: conv.is_muted ? '开启提醒' : '消息免打扰', + icon: conv.is_muted ? 'fas fa-bell' : 'fas fa-bell-slash', action: async () => { - await conversationStore.clearUnread(conv.target_id) - toastStore.success('已标记已读') - } - }, - { - label: '删除会话', - icon: 'fas fa-trash-alt', - danger: true, - action: () => { - pendingDeleteConversation.value = conv - showDeleteConversationConfirm.value = true + try { + if (contact) { + await contactApi.updateContact(contact.id, { is_muted: !conv.is_muted }) + contact.is_muted = !conv.is_muted + } else { + await conversationApi.updateConversation({ target_id: conv.target_id, is_muted: !conv.is_muted }) + } + await conversationStore.loadConversations() + toastStore.success(!conv.is_muted ? '已开启免打扰' : '已开启提醒') + } catch(e) { + toastStore.error('操作失败') + } } } - ]) + ] + + // 3. 特别关心 - 仅私聊显示 + if (!isGroup && contact) { + menuItems.push({ + label: conv.is_special_care ? '取消特别关心' : '特别关心', + icon: conv.is_special_care ? 'fas fa-heart' : 'far fa-heart', + action: async () => { + try { + await contactApi.updateContact(contact.id, { is_special_care: !conv.is_special_care }) + contact.is_special_care = !conv.is_special_care + await conversationStore.loadConversations() + toastStore.success(!conv.is_special_care ? '已设为特别关心' : '已取消特别关心') + } catch(e) { + toastStore.error('操作失败') + } + } + }) + } + + // 4. 标记已读 + menuItems.push({ + label: '标记已读', + icon: 'fas fa-check-double', + action: async () => { + await conversationStore.clearUnread(conv.target_id) + toastStore.success('已标记已读') + } + }) + + // 5. 删除会话 + menuItems.push({ + label: '删除会话', + icon: 'fas fa-trash-alt', + danger: true, + action: () => { + pendingDeleteConversation.value = conv + showDeleteConversationConfirm.value = true + } + }) + + showContextMenu(event, menuItems) } async function handleMarkRead(conv: Conversation) {