This commit is contained in:
2025-12-05 15:23:14 +08:00
parent c259092b5a
commit 66e8db2e46
12 changed files with 1076 additions and 103 deletions

View File

@@ -58,8 +58,10 @@ export function getGroup(roomId: string) {
}
// 获取群成员列表
export function getGroupMembers(roomId: string) {
return request.get<GroupMember[]>(`/groups/${roomId}/members`)
export function getGroupMembers(roomId: string, keyword?: string) {
return request.get<GroupMember[]>(`/groups/${roomId}/members`, {
params: keyword ? { keyword } : undefined
})
}
// 邀请成员入群

View File

@@ -1,5 +1,52 @@
<template>
<div class="w-80 bg-[#1e293b] border-l border-gray-800 flex flex-col h-full">
<!-- 群信息头部显示群头像和名称 -->
<div class="border-b border-gray-800 p-4 bg-gradient-to-br from-primary/10 to-purple-500/10">
<div class="flex items-center gap-3 mb-3">
<Avatar
:name="groupName"
:avatar="groupAvatar"
size="lg"
rounded="xl"
class="border-2 border-white/20"
/>
<div class="flex-1 min-w-0">
<h2 class="text-lg font-bold text-white truncate">{{ groupName || '未知群聊' }}</h2>
<p class="text-xs text-gray-400">{{ members.length }} 名成员</p>
</div>
</div>
<!-- 管理员/群主操作按钮 -->
<div v-if="canEditGroupName || canEditAnnouncement" class="flex gap-2 mt-3">
<button
v-if="canEditGroupName || canEditAnnouncement"
class="flex-1 py-2 bg-primary/20 hover:bg-primary/30 text-primary text-xs rounded-lg transition flex items-center justify-center gap-1"
@click="showInviteModal = true"
>
<i class="fas fa-user-plus"></i>
<span>邀请好友</span>
</button>
</div>
<!-- 群主专属操作 -->
<div v-if="isOwner" class="flex gap-2 mt-2">
<button
class="flex-1 py-2 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 text-xs rounded-lg transition flex items-center justify-center gap-1"
@click="showTransferOwnerModal = true"
>
<i class="fas fa-crown"></i>
<span>转让群主</span>
</button>
<button
class="flex-1 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 text-xs rounded-lg transition flex items-center justify-center gap-1"
@click="showDissolveConfirmDialog"
>
<i class="fas fa-trash-alt"></i>
<span>解散群聊</span>
</button>
</div>
</div>
<!-- 群名称区域 -->
<div class="border-b border-gray-800 p-4">
<div class="flex items-center justify-between mb-2">
@@ -99,13 +146,34 @@
<h3 class="text-sm font-semibold text-white">群成员 ({{ members.length }})</h3>
</div>
<!-- 搜索框 -->
<div class="mb-3">
<div class="relative">
<i class="fas fa-search absolute left-3 top-2.5 text-gray-500 text-xs"></i>
<input
v-model="memberSearchQuery"
type="text"
class="w-full bg-input rounded-lg py-2 pl-9 pr-3 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="搜索群成员..."
@input="handleMemberSearch"
/>
<button
v-if="memberSearchQuery"
class="absolute right-2 top-2 text-gray-500 hover:text-white transition"
@click="clearMemberSearch"
>
<i class="fas fa-times text-xs"></i>
</button>
</div>
</div>
<div v-if="loadingMembers" class="text-center py-8 text-gray-500 text-xs">
<i class="fas fa-spinner fa-spin mr-2"></i>
加载中...
</div>
<div v-else-if="members.length === 0" class="text-center py-8 text-gray-500 text-xs">
暂无成员
{{ memberSearchQuery ? '未找到匹配的成员' : '暂无成员' }}
</div>
<div v-else class="space-y-2">
@@ -114,6 +182,7 @@
:key="member.user_id"
class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 transition cursor-pointer group"
@click="handleMemberClick(member)"
@contextmenu.stop="showMemberContextMenu($event, member)"
>
<div class="relative shrink-0">
<Avatar
@@ -159,16 +228,123 @@
</div>
</div>
</div>
<!-- 邀请好友模态框 -->
<SelectContactsModal
v-if="showInviteModal"
:show="showInviteModal"
:contacts="availableContacts"
mode="invite"
@close="showInviteModal = false"
@invite="handleInviteMembers"
/>
<ContextMenu />
<!-- 转让群主模态框 -->
<div
v-if="showTransferOwnerModal"
class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm"
@click.self="showTransferOwnerModal = false"
>
<div class="bg-panel rounded-2xl w-96 shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
<div class="p-6">
<h3 class="text-lg font-bold text-white mb-4">转让群主</h3>
<p class="text-sm text-gray-400 mb-4">请选择要转让给的新群主</p>
<div class="max-h-64 overflow-y-auto custom-scrollbar space-y-2 mb-4">
<div
v-for="member in members.filter(m => m.role !== 2 && m.user_id !== authStore.user?.id)"
:key="member.user_id"
class="flex items-center gap-3 p-2 rounded-lg hover:bg-white/5 transition cursor-pointer"
@click="showTransferOwnerConfirmDialog(member.user_id)"
>
<Avatar
:name="member.user?.name || member.nickname || '未知'"
:avatar="member.user?.avatar"
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 === 1" class="text-blue-400">管理员</span>
<span v-else>成员</span>
</p>
</div>
</div>
</div>
<button
class="w-full py-2 bg-gray-700 text-gray-300 text-sm rounded-lg hover:bg-gray-600 transition"
@click="showTransferOwnerModal = false"
>
取消
</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="showDissolveConfirm"
title="解散群聊"
message="确定要解散群聊吗?解散后所有成员将被移除,且无法恢复。"
type="danger"
confirm-text="解散"
cancel-text="取消"
@confirm="handleDissolveGroup"
@cancel="showDissolveConfirm = false"
/>
<ConfirmModal
:show="showTransferOwnerConfirm"
title="转让群主"
message="确定要将群主转让给该成员吗?转让后您将失去群主权限。"
type="warning"
confirm-text="转让"
cancel-text="取消"
@confirm="handleTransferOwner"
@cancel="showTransferOwnerConfirm = false; pendingNewOwnerId = ''"
/>
<ConfirmModal
:show="showSetAdminConfirm"
title="设置管理员"
:message="setAdminMessage"
type="info"
confirm-text="确定"
cancel-text="取消"
@confirm="handleSetAdmin"
@cancel="showSetAdminConfirm = false; pendingMember = null"
/>
</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 ContextMenu from '@/components/common/ContextMenu.vue'
import ConfirmModal from '@/components/common/ConfirmModal.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 type { GroupMember } from '@/types/api'
import { useContextMenu } from '@/composables/useContextMenu'
import type { GroupMember, Contact } from '@/types/api'
interface Props {
roomId: string
@@ -180,21 +356,45 @@ const emit = defineEmits<{
'member-removed': []
'member-clicked': [member: GroupMember]
'group-updated': []
'invite-members': []
'dissolve': []
'transfer-owner': [newOwnerId: string]
}>()
const authStore = useAuthStore()
const chatStore = useChatStore()
const toastStore = useToastStore()
const { showContextMenu } = useContextMenu()
// 可邀请的联系人(排除已在群中的成员)
const availableContacts = computed(() => {
const memberIds = new Set(members.value.map(m => m.user_id))
return chatStore.contacts.filter(c => {
const userId = c.user_id || c.id
return userId !== authStore.user?.id && !memberIds.has(userId)
})
})
const loadingMembers = ref(false)
const members = ref<GroupMember[]>([])
const memberSearchQuery = ref('')
const announcement = ref('')
const isEditingAnnouncement = ref(false)
const editAnnouncementText = ref('')
const loadingAnnouncement = ref(false)
const groupName = ref('')
const groupAvatar = ref('')
const isEditingGroupName = ref(false)
const editGroupNameText = ref('')
const loadingGroupName = ref(false)
const showInviteModal = ref(false)
const showTransferOwnerModal = ref(false)
const showRemoveMemberConfirm = ref(false)
const showDissolveConfirm = ref(false)
const showTransferOwnerConfirm = ref(false)
const showSetAdminConfirm = ref(false)
const pendingMember = ref<GroupMember | null>(null)
const pendingNewOwnerId = ref<string>('')
// 当前用户角色
const currentUserRole = computed(() => {
@@ -215,6 +415,18 @@ const canEditAnnouncement = computed(() => isOwner.value || isAdmin.value)
// 是否可以编辑群名
const canEditGroupName = computed(() => isOwner.value || isAdmin.value)
// 移除成员确认消息
const removeMemberMessage = computed(() => {
const name = pendingMember.value?.user?.name || pendingMember.value?.nickname || '未知'
return `确定要移除成员 "${name}" 吗?`
})
// 设置管理员确认消息
const setAdminMessage = computed(() => {
const name = pendingMember.value?.user?.name || pendingMember.value?.nickname || '未知'
return `确定要将 "${name}" 设为管理员吗?`
})
// 判断是否可以管理某个成员
function canManageMember(member: GroupMember): boolean {
// 群主可以管理所有成员(除了自己)
@@ -229,12 +441,12 @@ function canManageMember(member: GroupMember): boolean {
}
// 加载群成员
async function loadMembers() {
async function loadMembers(keyword?: string) {
if (loadingMembers.value) return
loadingMembers.value = true
try {
const memberList = await groupApi.getGroupMembers(props.roomId)
const memberList = await groupApi.getGroupMembers(props.roomId, keyword)
members.value = memberList as unknown as GroupMember[]
} catch (error) {
console.error('Failed to load group members:', error)
@@ -245,6 +457,22 @@ async function loadMembers() {
}
}
// 处理成员搜索
function handleMemberSearch() {
const keyword = memberSearchQuery.value.trim()
if (keyword) {
loadMembers(keyword)
} else {
loadMembers()
}
}
// 清除搜索
function clearMemberSearch() {
memberSearchQuery.value = ''
loadMembers()
}
// 加载群信息(包括群名和公告)
async function loadGroupInfo() {
if (loadingGroupName.value) return
@@ -256,6 +484,7 @@ async function loadGroupInfo() {
groupApi.getGroupAnnouncement(props.roomId).catch(() => ({ announcement: '' }))
])
groupName.value = groupInfo.name || groupInfo.room_name || ''
groupAvatar.value = groupInfo.avatar || groupInfo.room_avatar || ''
announcement.value = annRes.announcement || ''
} catch (error) {
console.error('Failed to load group info:', error)
@@ -346,23 +575,157 @@ function handleMemberClick(member: GroupMember) {
}
// 移除成员
async function handleRemoveMember(member: GroupMember) {
if (!confirm(`确定要移除成员 "${member.user?.name || member.nickname || '未知'}" 吗?`)) {
return
}
function showRemoveMemberConfirmDialog(member: GroupMember) {
pendingMember.value = member
showRemoveMemberConfirm.value = true
}
async function handleRemoveMember() {
if (!pendingMember.value) return
try {
await groupApi.removeGroupMember(props.roomId, member.user_id)
await groupApi.removeGroupMember(props.roomId, pendingMember.value.user_id)
toastStore.success('成员已移除')
// 重新加载成员列表
await loadMembers()
emit('member-removed')
showRemoveMemberConfirm.value = false
pendingMember.value = null
} catch (error: any) {
console.error('Failed to remove member:', error)
toastStore.error(error.message || '移除成员失败')
}
}
// 邀请成员
async function handleInviteMembers(data: { member_ids: string[] }) {
if (!data.member_ids || data.member_ids.length === 0) {
toastStore.warning('请选择要邀请的好友')
return
}
try {
await groupApi.inviteGroupMembers(props.roomId, {
member_ids: data.member_ids
})
toastStore.success('邀请成功')
showInviteModal.value = false
// 重新加载成员列表
await loadMembers()
emit('invite-members')
} catch (error: any) {
console.error('Failed to invite members:', error)
toastStore.error(error.message || '邀请失败')
}
}
// 解散群聊
function showDissolveConfirmDialog() {
showDissolveConfirm.value = true
}
async function handleDissolveGroup() {
try {
await groupApi.dissolveGroup(props.roomId)
toastStore.success('群聊已解散')
showDissolveConfirm.value = false
emit('dissolve')
} catch (error: any) {
console.error('Failed to dissolve group:', error)
toastStore.error(error.message || '解散群聊失败')
}
}
// 转让群主
function showTransferOwnerConfirmDialog(newOwnerId: string) {
if (!newOwnerId) {
toastStore.warning('请选择新群主')
return
}
pendingNewOwnerId.value = newOwnerId
showTransferOwnerConfirm.value = true
}
async function handleTransferOwner() {
if (!pendingNewOwnerId.value) return
try {
await groupApi.updateMemberRole(props.roomId, pendingNewOwnerId.value, { role: 2 })
// 将自己的角色改为成员
if (authStore.user?.id) {
await groupApi.updateMemberRole(props.roomId, authStore.user.id, { role: 0 })
}
toastStore.success('群主转让成功')
showTransferOwnerModal.value = false
showTransferOwnerConfirm.value = false
pendingNewOwnerId.value = ''
// 重新加载成员列表
await loadMembers()
emit('transfer-owner', pendingNewOwnerId.value)
} catch (error: any) {
console.error('Failed to transfer owner:', error)
toastStore.error(error.message || '转让群主失败')
}
}
// 显示成员右键菜单
function showMemberContextMenu(event: MouseEvent, member: GroupMember) {
const menuItems: any[] = []
// 为所有成员添加"查看资料"选项
menuItems.push({
label: '查看资料',
icon: 'fas fa-user',
action: () => handleMemberClick(member)
})
// 群主可以设置管理员(仅对普通成员)
if (isOwner.value && member.role === 0 && member.user_id !== authStore.user?.id) {
menuItems.push({
label: '设为管理员',
icon: 'fas fa-user-shield',
action: () => showSetAdminConfirmDialog(member)
})
}
// 管理员和群主可以移除成员
if (canManageMember(member)) {
menuItems.push({
label: '移除群聊',
icon: 'fas fa-user-minus',
danger: true,
action: () => showRemoveMemberConfirmDialog(member)
})
}
if (menuItems.length > 0) {
showContextMenu(event, menuItems)
}
}
// 设置管理员
function showSetAdminConfirmDialog(member: GroupMember) {
pendingMember.value = member
showSetAdminConfirm.value = true
}
async function handleSetAdmin() {
if (!pendingMember.value) return
try {
await groupApi.updateMemberRole(props.roomId, pendingMember.value.user_id, { role: 1 })
toastStore.success('已设为管理员')
// 重新加载成员列表
await loadMembers()
showSetAdminConfirm.value = false
pendingMember.value = null
emit('group-updated')
} catch (error: any) {
console.error('Failed to set admin:', error)
toastStore.error(error.message || '设置管理员失败')
}
}
// 监听 roomId 变化
watch(() => props.roomId, () => {
if (props.roomId) {
@@ -371,11 +734,18 @@ watch(() => props.roomId, () => {
}
}, { immediate: true })
onMounted(() => {
onMounted(async () => {
if (props.roomId) {
loadMembers()
loadGroupInfo()
}
// 加载联系人列表(用于邀请)
try {
const contacts = await contactApi.getContacts()
chatStore.setContacts(contacts)
} catch (error) {
console.error('Failed to load contacts:', error)
}
})
</script>

View File

@@ -62,7 +62,7 @@
<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="handleDissolve"
@click="showDissolveConfirmDialog"
>
<i class="fas fa-trash"></i>
<span>解散群聊</span>
@@ -70,7 +70,7 @@
<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="handleQuit"
@click="showQuitConfirmDialog"
>
<i class="fas fa-sign-out-alt"></i>
<span>退出群聊</span>
@@ -111,7 +111,7 @@
<button
v-if="member.role !== 2"
class="text-gray-400 hover:text-red-400 transition text-sm"
@click.stop="handleRemoveMember(member)"
@click.stop="showRemoveMemberConfirmDialog(member)"
>
<i class="fas fa-user-minus"></i>
</button>
@@ -190,6 +190,40 @@
</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>
@@ -202,6 +236,7 @@ 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
@@ -228,6 +263,10 @@ 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(() => {
@@ -248,6 +287,12 @@ 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
@@ -295,14 +340,19 @@ function handleMemberClick(member: GroupMember) {
}
// 移除成员
async function handleRemoveMember(member: GroupMember) {
if (!confirm(`确定要将 ${member.user?.name || member.nickname} 移出群聊吗?`)) {
return
}
function showRemoveMemberConfirmDialog(member: GroupMember) {
pendingMember.value = member
showRemoveMemberConfirm.value = true
}
async function handleRemoveMember() {
if (!pendingMember.value) return
try {
await groupApi.removeGroupMember(props.roomId, member.user_id)
await groupApi.removeGroupMember(props.roomId, pendingMember.value.user_id)
toastStore.success('已移除成员')
showRemoveMemberConfirm.value = false
pendingMember.value = null
await loadGroupInfo()
emit('updated')
} catch (error: any) {
@@ -345,14 +395,15 @@ async function handleUpdateGroup() {
}
// 退出群聊
function showQuitConfirmDialog() {
showQuitConfirm.value = true
}
async function handleQuit() {
if (!confirm('确定要退出该群聊吗?')) {
return
}
try {
await groupApi.quitGroup(props.roomId)
toastStore.success('已退出群聊')
showQuitConfirm.value = false
emit('quit')
emit('close')
} catch (error: any) {
@@ -361,11 +412,11 @@ async function handleQuit() {
}
// 解散群聊
function showDissolveConfirmDialog() {
showDissolveConfirm.value = true
}
async function handleDissolve() {
if (!confirm('确定要解散该群聊吗?此操作不可恢复!')) {
return
}
try {
await groupApi.dissolveGroup(props.roomId)
toastStore.success('群聊已解散')

View File

@@ -1,7 +1,7 @@
<template>
<!-- 系统/通知消息居中灰色提示条样式 -->
<div v-if="isSystemOrNotify" class="flex justify-center my-2">
<div class="bg-gray-700/50 text-gray-400 text-xs px-4 py-2 rounded-full border border-gray-600/30 max-w-[80%] text-center">
<div v-if="isSystemOrNotify" class="flex justify-center items-center w-full my-2">
<div class="bg-gray-700/50 text-gray-400 text-xs px-4 py-2 rounded-full border border-gray-600/30 text-center mx-auto">
<i :class="systemIcon" class="mr-1.5"></i>
<span>{{ systemContent }}</span>
</div>

View File

@@ -1,38 +1,58 @@
<template>
<div class="p-4 bg-panel border-t border-gray-800 shrink-0 z-20">
<div class="p-4 bg-panel border-t border-gray-800 shrink-0 z-20 relative">
<!-- 遮罩层限制被移除/退出用户发言 -->
<div
v-if="isBlocked"
class="absolute inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center cursor-not-allowed"
@click.stop
@contextmenu.prevent
@mousedown.prevent
@selectstart.prevent
>
<div class="text-center px-6">
<i class="fas fa-ban text-4xl text-red-500 mb-3"></i>
<p class="text-white font-semibold text-sm mb-1">您已被移出群聊</p>
<p class="text-gray-400 text-xs">无法在此群聊中发送消息</p>
</div>
</div>
<div class="flex gap-6 text-gray-400 text-lg mb-3 px-2 items-center">
<i
class="far fa-smile hover:text-primary cursor-pointer transition-all transform hover:scale-125 hover:rotate-12"
class="far fa-smile hover:text-primary transition-all transform hover:scale-125 hover:rotate-12"
:class="isBlocked ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 cursor-pointer'"
title="表情"
@click="insertEmoji('😊')"
@click="!isBlocked && insertEmoji('😊')"
></i>
<i
class="far fa-image hover:text-green-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(1)"
class="far fa-image hover:text-green-400 transition-all transform hover:scale-125"
:class="isBlocked ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 cursor-pointer'"
@click="!isBlocked && triggerFile(1)"
title="发送图片"
></i>
<!-- 新增视频上传按钮 -->
<i
class="fas fa-video hover:text-purple-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(3)"
class="fas fa-video hover:text-purple-400 transition-all transform hover:scale-125"
:class="isBlocked ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 cursor-pointer'"
@click="!isBlocked && triggerFile(3)"
title="发送视频"
></i>
<i
class="fas fa-folder-plus hover:text-yellow-400 cursor-pointer transition-all transform hover:scale-125"
@click="triggerFile(8)"
class="fas fa-folder-plus hover:text-yellow-400 transition-all transform hover:scale-125"
:class="isBlocked ? 'text-gray-600 cursor-not-allowed' : 'text-gray-400 cursor-pointer'"
@click="!isBlocked && triggerFile(8)"
title="发送文件"
></i>
<!-- 长按录音 -->
<div
class="relative flex items-center group cursor-pointer select-none ml-auto mr-2"
@mousedown="handleRecordStart"
@touchstart.passive="handleRecordStart"
@mouseup="handleRecordStop"
@touchend.prevent="handleRecordStop"
@mouseleave="handleRecordCancel"
class="relative flex items-center group select-none ml-auto mr-2"
:class="isBlocked ? 'cursor-not-allowed' : 'cursor-pointer'"
@mousedown="!isBlocked && handleRecordStart()"
@touchstart.passive="!isBlocked && handleRecordStart()"
@mouseup="!isBlocked && handleRecordStop()"
@touchend.prevent="!isBlocked && handleRecordStop()"
@mouseleave="!isBlocked && handleRecordCancel()"
>
<div
class="w-8 h-8 rounded-full flex items-center justify-center transition-all shadow-md"
@@ -53,16 +73,19 @@
<div class="bg-input rounded-3xl p-2 pl-4 flex items-end ring-1 ring-white/5 focus-within:ring-primary/50 transition-all shadow-inner hover:shadow-lg hover:shadow-black/20">
<textarea
:value="modelValue"
class="w-full bg-transparent border-none text-white resize-none h-10 max-h-32 py-2 px-1 text-sm focus:outline-none scrollbar-thin placeholder-gray-500 leading-6"
:disabled="isBlocked"
class="w-full bg-transparent border-none text-white resize-none h-10 max-h-32 py-2 px-1 text-sm focus:outline-none scrollbar-thin placeholder-gray-500 leading-6 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="输入消息 (Enter 发送)..."
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@keydown.enter.exact.prevent="$emit('send')"
@keydown.enter.exact.prevent="!isBlocked && $emit('send')"
@paste="handlePaste"
></textarea>
<div
class="w-10 h-10 rounded-2xl flex items-center justify-center ml-2 transition-all duration-300 cursor-pointer shadow-lg"
:class="modelValue.trim() ? 'bg-primary hover:bg-indigo-500 text-white scale-100 rotate-0' : 'bg-gray-700 text-gray-500 scale-90 rotate-90 opacity-50 cursor-not-allowed'"
@click="$emit('send')"
class="w-10 h-10 rounded-2xl flex items-center justify-center ml-2 transition-all duration-300 shadow-lg"
:class="isBlocked
? 'bg-gray-700 text-gray-500 scale-90 rotate-90 opacity-50 cursor-not-allowed'
: (modelValue.trim() ? 'bg-primary hover:bg-indigo-500 text-white scale-100 rotate-0 cursor-pointer' : 'bg-gray-700 text-gray-500 scale-90 rotate-90 opacity-50 cursor-not-allowed')"
@click="!isBlocked && $emit('send')"
>
<i class="fas fa-paper-plane text-sm transform -ml-0.5 mt-0.5"></i>
</div>
@@ -78,7 +101,7 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useToastStore } from '@/stores/toast'
const toastStore = useToastStore()
@@ -86,6 +109,7 @@ const toastStore = useToastStore()
interface Props {
modelValue: string
isRecording: boolean
isBlocked?: boolean // 是否被禁言(被移除或退出群聊)
}
const props = defineProps<Props>()
@@ -119,6 +143,14 @@ function triggerFile(type: number) {
}
function handleFileChange(e: Event) {
if (props.isBlocked) {
e.preventDefault()
if (fileInput.value) {
fileInput.value.value = ''
}
return
}
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (file) {
@@ -131,6 +163,11 @@ function handleFileChange(e: Event) {
}
function handlePaste(e: ClipboardEvent) {
if (props.isBlocked) {
e.preventDefault()
return
}
const items = e.clipboardData?.items
if (!items) return
@@ -198,4 +235,80 @@ function handleRecordCancel() {
emit('record-cancel')
}
}
// 防止遮罩层被删除(通过 MutationObserver 监控)
let observer: MutationObserver | null = null
const containerRef = ref<HTMLElement | null>(null)
onMounted(() => {
if (typeof window !== 'undefined' && props.isBlocked) {
// 延迟设置 observer确保 DOM 已渲染
setTimeout(() => {
const container = document.querySelector('.p-4.bg-panel.border-t.border-gray-800.relative')
if (container) {
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLElement &&
node.classList.contains('absolute') &&
node.classList.contains('inset-0') &&
node.classList.contains('bg-black\\/80')) {
// 如果遮罩层被删除,刷新页面
if (props.isBlocked) {
console.warn('检测到遮罩层被删除,刷新页面以恢复')
setTimeout(() => location.reload(), 100)
}
}
})
}
})
})
observer.observe(container, { childList: true, subtree: true })
}
}, 100)
}
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
// 监听 isBlocked 变化,重新设置 observer
watch(() => props.isBlocked, (newVal) => {
if (observer) {
observer.disconnect()
observer = null
}
if (newVal && typeof window !== 'undefined') {
setTimeout(() => {
const container = document.querySelector('.p-4.bg-panel.border-t.border-gray-800.relative')
if (container) {
observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.removedNodes.forEach((node) => {
if (node instanceof HTMLElement &&
node.classList.contains('absolute') &&
node.classList.contains('inset-0') &&
node.classList.contains('bg-black\\/80')) {
if (props.isBlocked) {
console.warn('检测到遮罩层被删除,刷新页面以恢复')
setTimeout(() => location.reload(), 100)
}
}
})
}
})
})
observer.observe(container, { childList: true, subtree: true })
}
}, 100)
}
})
</script>

View File

@@ -38,11 +38,15 @@
v-for="(msg, index) in messages"
:key="index"
class="flex gap-3 msg-row group"
:class="{ 'flex-row-reverse': msg.isSelf }"
:class="{
'flex-row-reverse': msg.isSelf && !isSystemMessage(msg),
'justify-center': isSystemMessage(msg)
}"
@contextmenu.stop="$emit('contextmenu', $event, msg)"
>
<!-- 头像 -->
<!-- 系统消息不显示头像 -->
<Avatar
v-if="!isSystemMessage(msg)"
:name="getSenderName(msg)"
:avatar="getSenderAvatar(msg)"
:color="msg.isSelf ? generateColor(currentUser.id) : (getMemberColor(msg.sender_user_id) || target.color)"
@@ -53,21 +57,29 @@
/>
<!-- 消息内容区 -->
<div class="flex flex-col max-w-[75%] md:max-w-[60%]">
<!-- 群聊中显示发送者昵称非自己发送的消息 -->
<div
class="flex flex-col"
:class="isSystemMessage(msg) ? 'w-full items-center' : 'max-w-[75%] md:max-w-[60%]'"
>
<!-- 系统消息不显示发送者名称 -->
<div
v-if="isGroupChat && !msg.isSelf"
class="text-[10px] text-gray-500 mb-1 px-1"
v-if="!isSystemMessage(msg)"
>
{{ getSenderDisplayName(msg) }}
</div>
<!-- 单聊或自己发送的消息 -->
<div
v-else-if="!isGroupChat || msg.isSelf"
class="text-[10px] text-gray-500 mb-1 px-1"
:class="{ 'text-right': msg.isSelf }"
>
{{ msg.isSelf ? 'Me' : (target.remark_name || target.user?.name) }}
<!-- 群聊中显示发送者昵称非自己发送的消息 -->
<div
v-if="isGroupChat && !msg.isSelf"
class="text-[10px] text-gray-500 mb-1 px-1"
>
{{ getSenderDisplayName(msg) }}
</div>
<!-- 单聊或自己发送的消息 -->
<div
v-else-if="!isGroupChat || msg.isSelf"
class="text-[10px] text-gray-500 mb-1 px-1"
:class="{ 'text-right': msg.isSelf }"
>
{{ msg.isSelf ? 'Me' : (target.remark_name || target.user?.name) }}
</div>
</div>
<!-- 消息气泡 -->
@@ -82,6 +94,7 @@ import { ref, computed } from 'vue'
import { generateColor } from '@/utils/format'
import Avatar from '@/components/common/Avatar.vue'
import MessageBubble from '@/components/chat/MessageBubble.vue'
import { isSystemOrNotifyMessage } from '@/utils/messageTypes'
import type { ChatMessage, User, Contact } from '@/types/api'
interface Props {
@@ -107,6 +120,11 @@ const containerRef = ref<HTMLElement | null>(null)
// 判断是否为群聊
const isGroupChat = computed(() => props.target.is_group || props.target.room_type === 'group')
// 判断是否为系统消息
function isSystemMessage(msg: ChatMessage): boolean {
return isSystemOrNotifyMessage(msg.message_type)
}
// 获取发送者名称
function getSenderName(msg: ChatMessage): string {
if (msg.isSelf) {

View File

@@ -7,7 +7,7 @@
<div class="bg-panel rounded-2xl w-full max-w-md shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[80vh] 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>
<h2 class="text-xl font-bold text-white">{{ mode === 'invite' ? '邀请好友加入群聊' : '选择联系人创建群聊' }}</h2>
<button
class="text-gray-400 hover:text-white transition text-xl"
@click="$emit('close')"
@@ -85,6 +85,17 @@
</div>
</div>
<!-- 群名称输入仅在创建模式下显示 -->
<div v-if="mode === 'create'" class="px-6 py-3 border-t border-gray-800">
<input
v-model="groupName"
type="text"
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="请输入群名称(必填)"
maxlength="20"
/>
</div>
<!-- 底部操作区 -->
<div class="px-6 py-4 border-t border-gray-800 flex justify-between items-center">
<div class="text-sm text-gray-400">
@@ -99,24 +110,13 @@
</button>
<button
class="px-4 py-2 rounded-xl bg-primary hover:bg-indigo-600 text-white transition disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="selectedContacts.length < 1 || !groupName.trim()"
:disabled="selectedContacts.length < 1 || (mode === 'create' && !groupName.trim())"
@click="handleCreate"
>
创建群聊
{{ mode === 'invite' ? '邀请' : '创建群聊' }}
</button>
</div>
</div>
<!-- 群名称输入在底部操作区上方 -->
<div class="px-6 py-3 border-t border-gray-800">
<input
v-model="groupName"
type="text"
class="w-full bg-input rounded-xl py-2 px-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500"
placeholder="请输入群名称(必填)"
maxlength="20"
/>
</div>
</div>
</div>
</template>
@@ -129,13 +129,17 @@ import Avatar from '@/components/common/Avatar.vue'
interface Props {
show: boolean
contacts: Contact[]
mode?: 'create' | 'invite' // 创建群聊或邀请好友
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
mode: 'create'
})
const emit = defineEmits<{
'close': []
'create': [data: { member_ids: string[]; name: string; avatar?: string }]
'create': [data: { member_ids: string[]; name?: string; avatar?: string }]
'invite': [data: { member_ids: string[] }]
}>()
const searchQuery = ref('')
@@ -174,20 +178,29 @@ function removeContact(contactId: string) {
}
}
// 创建群聊
// 创建群聊或邀请好友
function handleCreate() {
if (selectedContacts.value.length < 1 || !groupName.value.trim()) {
if (selectedContacts.value.length < 1) {
return
}
const memberIds = selectedContacts.value.map(c => c.user_id || c.id)
const avatar = groupName.value.charAt(0).toUpperCase() // 使用群名称首字母作为头像
emit('create', {
member_ids: memberIds,
name: groupName.value.trim(),
avatar
})
if (props.mode === 'invite') {
emit('invite', {
member_ids: memberIds
})
} else {
if (!groupName.value.trim()) {
return
}
const avatar = groupName.value.charAt(0).toUpperCase() // 使用群名称首字母作为头像
emit('create', {
member_ids: memberIds,
name: groupName.value.trim(),
avatar
})
}
// 重置状态
selectedContacts.value = []

View File

@@ -41,6 +41,13 @@
<div v-if="toast.description" class="text-xs text-gray-400 mt-1">
{{ toast.description }}
</div>
<button
v-if="toast.action"
class="mt-2 text-xs text-primary hover:text-primary/80 underline transition"
@click="handleAction(toast)"
>
{{ toast.action.label }}
</button>
</div>
<!-- 关闭按钮 -->
@@ -65,6 +72,13 @@ const toasts = computed(() => toastStore.toasts)
function removeToast(id: string) {
toastStore.removeToast(id)
}
function handleAction(toast: Toast) {
if (toast.action) {
toast.action.handler()
removeToast(toast.id)
}
}
</script>
<style scoped>

View File

@@ -149,7 +149,7 @@ export function useWebRTC(
sender_client_id: clientId || '',
receiver_user_id: currentReceiverUserId,
room_id: roomId,
message_type: 0,
message_type: 4, // 系统消息类型
content: content,
duration: 0,
extra: JSON.stringify({ isSystem: true }),

View File

@@ -4,6 +4,7 @@ import type { Conversation } from '@/types/conversation'
import type { ChatMessage } from '@/types/api'
import * as conversationApi from '@/api/modules/conversation'
import { getMessageSummary } from '@/utils/messageTypes'
import { useChatStore } from './chat'
export const useConversationStore = defineStore('conversation', () => {
const conversations = ref<Conversation[]>([])
@@ -84,7 +85,23 @@ export const useConversationStore = defineStore('conversation', () => {
}
}
const summary = getMessageSummary(message)
// 判断是否为群聊
const isGroupChat = conv?.type === 2 || conv?.is_group
// 生成摘要:群聊时需要包含发送者信息
let summary = getMessageSummary(message)
if (isGroupChat && !isSelf && message.sender_user_id) {
// 从 chatStore 中查找发送者信息
const chatStore = useChatStore()
const senderContact = chatStore.contacts.find(
c => c.user_id === message.sender_user_id || c.id === message.sender_user_id
)
if (senderContact) {
const senderName = senderContact.remark_name || senderContact.user?.name || '未知用户'
summary = `${senderName}${summary}`
}
}
const now = new Date(message.created_at || Date.now()).getTime()
if (conv) {

View File

@@ -9,6 +9,10 @@ export interface Toast {
description?: string
type: ToastType
duration?: number
action?: {
label: string
handler: () => void
}
}
export const useToastStore = defineStore('toast', () => {
@@ -21,7 +25,8 @@ export const useToastStore = defineStore('toast', () => {
message: string,
type: ToastType = 'info',
duration: number = 3000,
description?: string
description?: string,
action?: { label: string; handler: () => void }
) {
const id = `toast-${Date.now()}-${Math.random()}`
const toast: Toast = {
@@ -30,6 +35,7 @@ export const useToastStore = defineStore('toast', () => {
description,
type,
duration,
action,
}
toasts.value.push(toast)

View File

@@ -49,6 +49,13 @@
<i class="fas fa-users"></i>
<span>发起群聊</span>
</button>
<button
class="px-4 py-2 rounded-xl bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center gap-2 text-sm"
@click="refreshConversations"
title="刷新会话列表"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshingConversations }"></i>
</button>
</div>
<div class="relative group">
<i class="fas fa-search absolute left-4 top-3 text-gray-500 group-focus-within:text-primary transition"></i>
@@ -198,9 +205,10 @@
:target="chatStore.currentTarget"
:loading-history="loadingHistory"
:has-more-history="chatStore.currentTarget ? (hasMoreHistory[getRoomId(chatStore.currentTarget)] !== false) : true"
:room-members="roomMembers"
:room-members="currentRoomMembers"
@scroll="handleScroll"
@load-more="loadMoreMessages"
@avatar-click="handleAvatarClick"
/>
<!-- 新消息提示/回到底部按钮 -->
@@ -221,6 +229,7 @@
<MessageInput
v-model="inputText"
:is-recording="isRecording"
:is-blocked="isBlockedFromGroup"
@send="handleSendText"
@file="handleFileSelect"
@record-start="handleRecordStart"
@@ -306,8 +315,8 @@
@dissolve="handleGroupDissolve"
/>
<!-- Profile Modal -->
<div v-if="showProfileModal && authStore.user" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showProfileModal = false">
<!-- Profile Modal (自己的) -->
<div v-if="showProfileModal && authStore.user && !selectedUser" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="showProfileModal = false">
<div class="bg-panel rounded-2xl w-80 shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
<div class="h-24 bg-gradient-to-r from-indigo-600 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-12">
@@ -323,6 +332,66 @@
</div>
</div>
</div>
<!-- User Info Modal (点击对方头像) -->
<div v-if="selectedUser" class="fixed inset-0 bg-black/80 z-[60] flex items-center justify-center p-4 backdrop-blur-sm" @click.self="selectedUser = null">
<div class="bg-panel rounded-2xl w-96 shadow-2xl border border-gray-700 overflow-hidden animate-fade-in">
<div class="h-32 bg-gradient-to-r from-indigo-600 to-purple-600"></div>
<div class="px-6 pb-6 text-center -mt-16">
<Avatar
:name="getUserDisplayName(selectedUser)"
:avatar="getUserAvatar(selectedUser)"
size="xl"
rounded="full"
class="mx-auto border-4 border-panel shadow-xl"
/>
<h2 class="text-xl font-bold mt-3 text-white">{{ getUserDisplayName(selectedUser) }}</h2>
<p class="text-sm text-gray-400 mt-1">{{ getUserDesc(selectedUser) || '暂无签名' }}</p>
<!-- 用户信息 -->
<div class="mt-4 space-y-2 text-left">
<div v-if="getUserId(selectedUser)" class="flex items-center justify-between text-sm">
<span class="text-gray-400">账号ID:</span>
<span class="text-white">{{ getUserId(selectedUser) }}</span>
</div>
<div v-if="getUserPhone(selectedUser)" class="flex items-center justify-between text-sm">
<span class="text-gray-400">手机号:</span>
<span class="text-white">{{ getUserPhone(selectedUser) }}</span>
</div>
<div v-if="getUserEmail(selectedUser)" class="flex items-center justify-between text-sm">
<span class="text-gray-400">邮箱:</span>
<span class="text-white">{{ getUserEmail(selectedUser) }}</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="mt-6 flex gap-3">
<button
v-if="!isFriend(selectedUser as Contact)"
class="flex-1 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition flex items-center justify-center gap-2"
@click="handleAddFriend"
>
<i class="fas fa-user-plus"></i>
<span>添加好友</span>
</button>
<button
v-else
class="flex-1 py-2 bg-primary hover:bg-indigo-500 text-white rounded-lg transition flex items-center justify-center gap-2"
@click="handleSendMessageToUser"
>
<i class="fas fa-comment"></i>
<span>发消息</span>
</button>
<button
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-gray-300 rounded-lg transition"
@click="selectedUser = null"
>
关闭
</button>
</div>
</div>
</div>
</div>
</div>
</template>
@@ -342,7 +411,7 @@ import * as attachmentApi from '@/api/modules/attachment'
import { formatTime, generateColor } from '@/utils/format'
import { getMessageSummary } from '@/utils/messageTypes'
import { storage } from '@/utils/storage'
import type { Contact, ChatMessage } from '@/types/api'
import type { Contact, ChatMessage, User } from '@/types/api'
import type { Conversation } from '@/types/conversation'
import { useWebRTCStore } from '@/stores/webrtc'
// Components...
@@ -378,11 +447,13 @@ const inputText = ref('')
const chatVisible = ref(false)
const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
const selectedUser = ref<Contact | User | null>(null)
const isRecording = ref(false)
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
const showCreateGroupModal = ref(false)
const showGroupInfoPanel = ref(false)
const roomMembers = ref<Record<string, { name: string; avatar?: string }>>({})
const refreshingConversations = ref(false)
const roomMembers = ref<Record<string, Record<string, { name: string; avatar?: string }>>>({})
// Scroll Logic State
const showScrollBottomBtn = ref(false)
@@ -446,6 +517,24 @@ function getRoomId(contact: Contact): string {
// 判断是否为群聊
const isGroupChat = computed(() => chatStore.currentTarget?.is_group || chatStore.currentTarget?.room_type === 'group')
// 获取当前房间的成员映射(用于 MessageList
const currentRoomMembers = computed(() => {
if (!chatStore.currentTarget?.room_id) {
return {}
}
return roomMembers.value[chatStore.currentTarget.room_id] || {}
})
// 判断当前用户是否被禁言(被移除或退出群聊)
const isBlockedFromGroup = computed(() => {
if (!isGroupChat.value || !chatStore.currentTarget?.room_id || !authStore.user) {
return false
}
const currentMember = roomMembers.value[chatStore.currentTarget.room_id]?.[authStore.user.id]
// 如果当前用户不在群成员列表中,则认为被禁言
return !currentMember
})
// 获取当前目标名称
function getCurrentTargetName(): string {
if (!chatStore.currentTarget) return ''
@@ -539,9 +628,10 @@ async function selectChat(contact: Contact) {
// 加载群成员信息
async function loadGroupMembers(roomId: string) {
try {
const members = await groupApi.getGroupMembers(roomId)
const response = await groupApi.getGroupMembers(roomId)
const members = Array.isArray(response) ? response : (response.data || [])
const membersMap: Record<string, { name: string; avatar?: string }> = {}
members.forEach(m => {
members.forEach((m: any) => {
membersMap[m.user_id] = {
name: m.user?.name || m.nickname || '未知',
avatar: m.user?.avatar
@@ -550,6 +640,8 @@ async function loadGroupMembers(roomId: string) {
roomMembers.value[roomId] = membersMap
} catch (error) {
console.error('Failed to load group members:', error)
// 如果加载失败,清空成员列表(表示用户可能已被移除)
roomMembers.value[roomId] = {} as Record<string, { name: string; avatar?: string }>
}
}
@@ -647,6 +739,13 @@ async function selectChatByConversation(conv: Conversation) {
async function sendMessage(type: number, content: string, extra: any = {}, duration = 0) {
if (!chatStore.currentTarget) return
// 检查是否被禁言(群聊中被移除或退出)
if (isGroupChat.value && isBlockedFromGroup.value) {
toastStore.error('您已被移出群聊,无法发送消息')
return
}
const roomId = getRoomId(chatStore.currentTarget)
const isGroup = chatStore.currentTarget.is_group || chatStore.currentTarget.room_type === 'group'
@@ -671,7 +770,17 @@ async function sendMessage(type: number, content: string, extra: any = {}, durat
try {
await messageApi.sendMessage(payload)
} catch(e) { toastStore.error('发送失败'); }
} catch(e: any) {
// 移除已添加的消息(因为发送失败)
const messages = chatStore.messages[roomId] || []
const index = messages.findIndex(m => m.id === message.id)
if (index > -1) {
messages.splice(index, 1)
}
const errorMsg = e?.response?.data?.message || e?.message || '发送失败'
toastStore.error(errorMsg)
}
}
function handleWebSocketMessage(message: ChatMessage) {
@@ -679,10 +788,88 @@ function handleWebSocketMessage(message: ChatMessage) {
const roomId = message.room_id
message.isSelf = message.sender_user_id === authStore.user!.id
message.extra = typeof message.extra === 'string' ? JSON.parse(message.extra || '{}') : message.extra
// 处理好友申请通知
if (message.message_type === 5) {
const extra = message.extra as any
toastStore.showToast(
'收到好友申请',
'info',
5000,
extra.title || message.content || '有人申请添加你为好友',
{
label: '去处理',
handler: () => {
currentTab.value = 'contact'
contactStore.setLeftPanelMode('friend-notify')
}
}
)
return
}
// 处理群聊通知
if (message.message_type === 7) {
const extra = message.extra as any
toastStore.showToast(
'群聊通知',
'info',
5000,
extra.title || message.content || '收到群聊通知',
{
label: '去查看',
handler: () => {
currentTab.value = 'contact'
contactStore.setLeftPanelMode('group-notify')
}
}
)
return
}
chatStore.addMessage(roomId, message)
const contact = chatStore.contacts.find(c => c.user_id === message.sender_user_id || c.id === message.sender_user_id)
// 检查是否为群聊消息,如果是且当前正在查看该群聊,检查群成员信息
const isGroup = chatStore.currentTarget?.is_group || chatStore.currentTarget?.room_type === 'group'
const currentRoomId = chatStore.currentTarget ? getRoomId(chatStore.currentTarget) : null
const isCurrentGroupChat = isGroup && currentRoomId === roomId
// 如果是群聊消息且当前正在查看该群聊,检查发送者是否在 roomMembers 中
if (isCurrentGroupChat && !message.isSelf && message.sender_user_id) {
const roomMembersForRoom = roomMembers.value[roomId] || {}
if (!roomMembersForRoom[message.sender_user_id]) {
// 发送者不在成员列表中,立即加载群成员
loadGroupMembers(roomId)
}
}
// 检查会话是否存在
// 对于群聊消息,使用 room_id 查找;对于私聊消息,使用 target_id 查找
const isGroupMessage = message.room_id && (message.room_id.startsWith('group_') || message.receiver_user_id === message.room_id)
let existingConv = null
if (isGroupMessage) {
// 群聊消息:使用 room_id 查找会话
existingConv = conversationStore.conversations.find(c => c.room_id === roomId)
} else {
// 私聊消息:使用 target_id 查找会话
const targetId = message.isSelf ? message.receiver_user_id : message.sender_user_id
existingConv = conversationStore.conversations.find(c => c.target_id === targetId || c.room_id === roomId)
}
// 如果会话不存在,刷新会话列表(添加防抖,避免频繁刷新)
if (!existingConv && !message.isSelf) {
const now = Date.now()
const lastRefresh = (window as any).__lastConversationRefresh || 0
// 防抖1秒内最多刷新一次
if (now - lastRefresh > 1000) {
(window as any).__lastConversationRefresh = now
conversationStore.loadConversations()
}
}
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
@@ -709,6 +896,29 @@ function handleWebSocketMessage(message: ChatMessage) {
})
}
}
} else {
// 不在会话列表时收到消息,显示提示
if (!message.isSelf && currentTab.value !== 'chat') {
const senderName = message.sender_user_id || '未知用户'
toastStore.showToast(
'收到新消息',
'info',
5000,
`来自 ${senderName} 的新消息`,
{
label: '去查看',
handler: async () => {
currentTab.value = 'chat'
// 尝试查找或创建会话
await conversationStore.loadConversations()
const conv = conversationStore.conversations.find(c => c.room_id === roomId || c.target_id === message.sender_user_id)
if (conv) {
await selectChatByConversation(conv)
}
}
}
)
}
}
}
@@ -912,10 +1122,169 @@ async function handleGroupMemberRemoved() {
await conversationStore.loadConversations()
}
// 刷新会话列表
async function refreshConversations() {
if (refreshingConversations.value) return
refreshingConversations.value = true
try {
await conversationStore.loadConversations()
toastStore.success('会话列表已刷新')
} catch (error) {
console.error('Failed to refresh conversations:', error)
toastStore.error('刷新失败')
} finally {
refreshingConversations.value = false
}
}
// 群成员点击处理
function handleGroupMemberClicked(member: any) {
// 可以在这里实现点击成员后的操作,比如查看成员信息、@成员等
console.log('Member clicked:', member)
// 显示成员的用户详细信息
if (member.user) {
// 如果有完整的用户信息,直接使用
selectedUser.value = member.user as User
} else if (member.user_id) {
// 如果没有完整信息,尝试从联系人列表中查找
const contact = chatStore.contacts.find(c => c.user_id === member.user_id || c.id === member.user_id)
if (contact) {
selectedUser.value = contact
} else {
// 如果找不到,创建一个临时的 Contact 对象
selectedUser.value = {
id: member.user_id,
user_id: member.user_id,
contact_user_id: member.user_id,
is_top: false,
is_muted: false,
user: member.user || undefined
} as Contact
}
}
}
// 处理头像点击
function handleAvatarClick(userOrContact: User | Contact) {
// 如果是自己,显示自己的资料
if (('id' in userOrContact && userOrContact.id === authStore.user?.id) ||
('user_id' in userOrContact && userOrContact.user_id === authStore.user?.id)) {
showProfileModal.value = true
return
}
// 显示对方信息
selectedUser.value = userOrContact as Contact
}
// 判断是否为好友
function isFriend(user: Contact | User): boolean {
if (!user) return false
const userId = 'user_id' in user ? user.user_id : ('id' in user ? user.id : null)
if (!userId) return false
// 检查是否在联系人列表中
return chatStore.contacts.some(c => c.user_id === userId || c.id === userId)
}
// 添加好友
async function handleAddFriend() {
if (!selectedUser.value) return
const userId = 'user_id' in selectedUser.value
? selectedUser.value.user_id
: ('id' in selectedUser.value ? selectedUser.value.id : null)
if (!userId) {
toastStore.error('无法获取用户ID')
return
}
try {
await contactApi.addFriend({ to_user_id: userId })
toastStore.success('好友申请已发送')
selectedUser.value = null
} catch (error: any) {
console.error('Failed to add friend:', error)
toastStore.error(error.message || '添加好友失败')
}
}
// 给用户发消息
function handleSendMessageToUser() {
if (!selectedUser.value) return
// 查找或创建联系人
const userId = 'user_id' in selectedUser.value
? selectedUser.value.user_id
: ('id' in selectedUser.value ? selectedUser.value.id : null)
if (!userId) return
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,
remark_name: 'name' in selectedUser.value ? selectedUser.value.name : selectedUser.value.user?.name,
is_top: false,
is_muted: false,
user: 'user' in selectedUser.value ? selectedUser.value.user : selectedUser.value as any,
} as Contact
}
selectedUser.value = null
selectChat(contact)
}
// 辅助函数:获取用户显示名称
function getUserDisplayName(user: Contact | User | null): string {
if (!user) return '未知用户'
if ('remark_name' in user && user.remark_name) return user.remark_name
if ('name' in user && user.name) return user.name
if ('user' in user && user.user?.name) return user.user.name
return '未知用户'
}
// 辅助函数:获取用户头像
function getUserAvatar(user: Contact | User | null): string {
if (!user) return ''
if ('avatar' in user && user.avatar) return user.avatar
if ('user' in user && user.user?.avatar) return user.user.avatar
return ''
}
// 辅助函数:获取用户描述
function getUserDesc(user: Contact | User | null): string {
if (!user) return ''
if ('desc' in user && user.desc) return user.desc
if ('user' in user && user.user?.desc) return user.user.desc
return ''
}
// 辅助函数获取用户ID
function getUserId(user: Contact | User | null): string | null {
if (!user) return null
if ('user_id' in user) return user.user_id
if ('id' in user) return user.id
if ('user' in user && user.user?.id) return user.user.id
return null
}
// 辅助函数:获取用户手机号
function getUserPhone(user: Contact | User | null): string | null {
if (!user) return null
if ('phone' in user && user.phone) return user.phone
if ('user' in user && user.user?.phone) return user.user.phone
return null
}
// 辅助函数:获取用户邮箱
function getUserEmail(user: Contact | User | null): string | null {
if (!user) return null
if ('email' in user && user.email) return user.email
if ('user' in user && user.user?.email) return user.user.email
return null
}
watch(currentTab, (newTab) => {