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

@@ -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>