修复了一些逻辑漏洞

This commit is contained in:
2025-12-05 17:00:50 +08:00
parent 3f34df2a9f
commit 9173d36763
3 changed files with 539 additions and 23 deletions

View File

@@ -224,6 +224,16 @@
@confirm="handleDissolve"
@cancel="showDissolveConfirm = false"
/>
<!-- 用户信息卡片 -->
<UserInfoCard
:show="showUserInfoCard"
:user="selectedMember?.user || null"
@close="showUserInfoCard = false; selectedMember = null"
@send-message="handleSendMessageToMember"
@audio-call="handleAudioCallToMember"
@video-call="handleVideoCallToMember"
/>
</div>
</template>
@@ -237,6 +247,8 @@ 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'
import UserInfoCard from '@/components/common/UserInfoCard.vue'
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
interface Props {
show: boolean
@@ -267,6 +279,8 @@ const showRemoveMemberConfirm = ref(false)
const showQuitConfirm = ref(false)
const showDissolveConfirm = ref(false)
const pendingMember = ref<GroupMember | null>(null)
const selectedMember = ref<GroupMember | null>(null)
const showUserInfoCard = ref(false)
// 当前用户角色
const currentUserRole = computed(() => {
@@ -336,7 +350,42 @@ function canManageMember(member: GroupMember): boolean {
// 处理成员点击
function handleMemberClick(member: GroupMember) {
// 可以扩展为显示成员详情
selectedMember.value = member
showUserInfoCard.value = true
}
// 处理发送消息
function handleSendMessageToMember() {
if (!selectedMember.value) return
const contact: Contact = {
id: selectedMember.value.user_id,
user_id: selectedMember.value.user_id,
contact_user_id: selectedMember.value.user_id,
room_id: '',
room_type: 'p2p',
is_group: false,
remark_name: selectedMember.value.user?.name || selectedMember.value.nickname || '未知',
is_top: false,
is_muted: false,
user: selectedMember.value.user,
}
chatStore.setCurrentTarget(contact)
showUserInfoCard.value = false
emit('close')
}
// 处理语音通话
function handleAudioCallToMember() {
if (!selectedMember.value) return
// 这里可以调用 WebRTC 相关功能
showUserInfoCard.value = false
}
// 处理视频通话
function handleVideoCallToMember() {
if (!selectedMember.value) return
// 这里可以调用 WebRTC 相关功能
showUserInfoCard.value = false
}
// 移除成员

View File

@@ -1,23 +1,30 @@
<template>
<div
class="flex items-center justify-center text-white font-bold shadow-md transition"
class="flex items-center justify-center text-white font-bold shadow-md transition overflow-hidden"
:class="[sizeClass, roundedClass, { 'cursor-pointer hover:opacity-80': $attrs.onClick }]"
:style="{ background: color || generateColor(name || avatar || '') }"
:style="isImage ? {} : { background: color || generateColor(name || avatar || '') }"
@click="$emit('click')"
>
{{ avatar || name?.charAt(0).toUpperCase() || '?' }}
<img
v-if="isImage"
:src="avatar"
:alt="name || 'Avatar'"
class="w-full h-full object-cover"
@error="handleImageError"
/>
<span v-else>{{ avatar || name?.charAt(0).toUpperCase() || '?' }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { generateColor } from '@/utils/format'
interface Props {
avatar?: string
name?: string
color?: string
size?: 'sm' | 'md' | 'lg' | 'xl' | 'contact'
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'contact'
rounded?: 'full' | 'xl' | 'lg'
}
@@ -26,12 +33,44 @@ const props = withDefaults(defineProps<Props>(), {
rounded: 'full',
})
const imageError = ref(false)
// 判断 avatar 是否是图片URL
const isImage = computed(() => {
if (!props.avatar || imageError.value) return false
// 检查是否是 base64 图片
if (props.avatar.startsWith('data:image/')) {
return true
}
// 检查是否是 http/https URL
if (props.avatar.startsWith('http://') || props.avatar.startsWith('https://')) {
return true
}
// 检查是否是相对路径的图片(以 / 开头)
if (props.avatar.startsWith('/')) {
return true
}
// 检查是否是图片文件扩展名
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']
const lowerAvatar = props.avatar.toLowerCase()
if (imageExtensions.some(ext => lowerAvatar.endsWith(ext))) {
return true
}
return false
})
const sizeClass = computed(() => {
const sizes = {
sm: 'w-9 h-9 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
xl: 'w-16 h-16 text-xl',
'2xl': 'w-24 h-24 text-2xl',
contact: 'w-11 h-11 text-lg',
}
return sizes[props.size]
@@ -46,6 +85,11 @@ const roundedClass = computed(() => {
return rounded[props.rounded]
})
// 图片加载失败时的处理
function handleImageError() {
imageError.value = true
}
defineEmits<{
click: []
}>()

View File

@@ -328,18 +328,202 @@
<!-- 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">
<div class="bg-[#111827] rounded-2xl w-full max-w-2xl shadow-2xl border border-gray-700 overflow-hidden animate-fade-in max-h-[90vh] flex flex-col relative">
<!-- 背景装饰 -->
<div class="absolute top-0 left-0 right-0 h-48 bg-gradient-to-b from-primary/20 to-transparent pointer-events-none"></div>
<!-- 关闭按钮 -->
<button
class="absolute top-5 right-5 z-50 w-9 h-9 rounded-full bg-black/20 hover:bg-white/10 text-gray-300 hover:text-white flex items-center justify-center transition backdrop-blur-md border border-white/5"
@click="showProfileModal = false"
>
<i class="fas fa-times"></i>
</button>
<!-- 内容区域 -->
<div class="flex-1 overflow-y-auto custom-scrollbar relative z-10 flex flex-col">
<!-- 头部头像与核心信息 -->
<div class="pt-14 pb-8 px-8 text-center flex flex-col items-center animate-fade-in">
<div class="relative group cursor-pointer" @click="showAvatarEditModal = true">
<Avatar
:name="authStore.user.name"
:avatar="authStore.user.avatar"
size="2xl"
rounded="full"
class="border-[6px] border-[#111827] shadow-2xl relative z-10 transition-transform duration-300 group-hover:scale-105"
/>
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<i class="fas fa-camera text-white text-xl"></i>
</div>
</div>
<h3 class="text-2xl font-bold text-white mt-5 flex items-center gap-2 justify-center select-text">
{{ authStore.user.name }}
</h3>
<p class="text-gray-400 text-sm mt-2 max-w-md truncate px-4 opacity-80">
{{ authStore.user.desc || '暂无签名' }}
</p>
<div class="flex gap-3 mt-4 text-xs font-medium text-gray-400">
<span class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5 select-text">ID: {{ authStore.user.id }}</span>
<span v-if="authStore.user.region" class="bg-white/5 px-2.5 py-1 rounded-md border border-white/5">{{ authStore.user.region }}</span>
</div>
</div>
<!-- 详细信息列表 -->
<div class="px-8 py-4 space-y-3 max-w-xl mx-auto w-full animate-slide-up">
<div class="bg-white/5 rounded-2xl border border-white/5 overflow-hidden">
<!-- 网名 -->
<div class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 cursor-pointer" @click="showNameEditModal = true">
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
<i class="far fa-user"></i>
</div>
<div class="flex-1 min-w-0 mr-8">
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">网名</div>
<div class="text-sm text-gray-200 font-medium truncate select-text">{{ authStore.user.name || '未知' }}</div>
</div>
<i class="fas fa-edit text-xs text-gray-600 absolute right-4 group-hover:text-gray-400 transition-colors"></i>
</div>
<!-- 手机号 -->
<div class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 cursor-pointer" @click="showPhoneEditModal = true">
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
<i class="fas fa-phone-alt"></i>
</div>
<div class="flex-1 min-w-0 mr-8">
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">手机号</div>
<div class="text-sm text-gray-200 font-medium truncate select-text font-mono">{{ authStore.user.phone || '未设置' }}</div>
</div>
<i class="fas fa-edit text-xs text-gray-600 absolute right-4 group-hover:text-gray-400 transition-colors"></i>
</div>
<!-- 邮箱 -->
<div v-if="authStore.user.email" class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
<i class="far fa-envelope"></i>
</div>
<div class="flex-1 min-w-0 mr-8">
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">邮箱</div>
<div class="text-sm text-gray-200 font-medium truncate select-text font-mono">{{ authStore.user.email }}</div>
</div>
<button class="absolute right-4 w-8 h-8 rounded-lg hover:bg-white/10 text-gray-500 hover:text-primary transition flex items-center justify-center opacity-0 group-hover:opacity-100"
@click.stop="copyText(authStore.user.email)" title="复制">
<i class="far fa-copy"></i>
</button>
</div>
<!-- 地区 -->
<div v-if="authStore.user.region" class="flex items-center p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
<i class="fas fa-map-marker-alt"></i>
</div>
<div class="flex-1 min-w-0 mr-8">
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">地区</div>
<div class="text-sm text-gray-200 font-medium truncate select-text">{{ authStore.user.region }}</div>
</div>
</div>
<!-- 签名 -->
<div class="flex items-start p-4 hover:bg-white/5 transition-colors group relative border-b border-white/5 last:border-0">
<div class="w-9 h-9 rounded-lg bg-white/5 flex items-center justify-center text-gray-500 mr-4 shrink-0">
<i class="far fa-comment"></i>
</div>
<div class="flex-1 min-w-0 mr-8">
<div class="text-[10px] text-gray-500 uppercase tracking-wider mb-0.5 font-bold">个性签名</div>
<div class="text-sm text-gray-200 font-medium break-words">{{ authStore.user.desc || '暂无签名' }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 头像编辑弹窗 -->
<div v-if="showAvatarEditModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showAvatarEditModal = false">
<div class="bg-panel w-96 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
<h3 class="text-lg font-bold text-white mb-4">编辑头像</h3>
<div class="space-y-4">
<div>
<label class="block text-sm text-gray-400 mb-2">当前头像</label>
<div class="flex justify-center mb-4">
<Avatar
:name="authStore.user?.name || ''"
:avatar="editingAvatar || authStore.user?.avatar"
size="xl"
rounded="full"
class="mx-auto border-4 border-panel shadow-xl"
class="border-4 border-gray-700"
/>
<h2 class="text-xl font-bold mt-3 text-white">{{ authStore.user.name }}</h2>
<p class="text-sm text-gray-400 mt-1">{{ authStore.user.desc || '暂无签名' }}</p>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">上传图片</label>
<input
ref="avatarFileInput"
type="file"
accept="image/*"
class="hidden"
@change="handleAvatarFileSelect"
/>
<button
class="w-full py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition mb-2"
@click="avatarFileInput?.click()"
>
<i class="fas fa-upload mr-2"></i>选择文件
</button>
</div>
<div>
<label class="block text-sm text-gray-400 mb-2">或输入图片URL</label>
<input
v-model="editingAvatar"
type="text"
class="w-full bg-black/30 text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none"
placeholder="https://example.com/avatar.jpg"
/>
</div>
</div>
<div class="flex gap-3 mt-6">
<button class="flex-1 py-2.5 rounded-lg text-gray-400 hover:bg-white/5 transition" @click="showAvatarEditModal = false; editingAvatar = ''">取消</button>
<button class="flex-1 py-2.5 rounded-lg bg-primary text-white font-bold hover:bg-primary-hover transition" @click="handleUpdateAvatar">保存</button>
</div>
</div>
</div>
<!-- 昵称编辑弹窗 -->
<div v-if="showNameEditModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showNameEditModal = false">
<div class="bg-panel w-96 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
<h3 class="text-lg font-bold text-white mb-4">编辑昵称</h3>
<input
v-model="editingName"
type="text"
class="w-full bg-black/30 text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none"
placeholder="请输入昵称"
maxlength="20"
@keyup.enter="handleUpdateName"
/>
<div class="flex gap-3 mt-6">
<button class="flex-1 py-2.5 rounded-lg text-gray-400 hover:bg-white/5 transition" @click="showNameEditModal = false; editingName = ''">取消</button>
<button class="flex-1 py-2.5 rounded-lg bg-primary text-white font-bold hover:bg-primary-hover transition" @click="handleUpdateName">保存</button>
</div>
</div>
</div>
<!-- 手机号编辑弹窗 -->
<div v-if="showPhoneEditModal" class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[100] flex items-center justify-center p-4" @click.self="showPhoneEditModal = false">
<div class="bg-panel w-96 rounded-2xl border border-gray-700 p-5 shadow-2xl animate-scale-in">
<h3 class="text-lg font-bold text-white mb-4">编辑手机号</h3>
<input
v-model="editingPhone"
type="tel"
class="w-full bg-black/30 text-white p-3 rounded-xl border border-gray-600 focus:border-primary outline-none font-mono"
placeholder="请输入手机号"
maxlength="20"
@keyup.enter="handleUpdatePhone"
/>
<div class="flex gap-3 mt-6">
<button class="flex-1 py-2.5 rounded-lg text-gray-400 hover:bg-white/5 transition" @click="showPhoneEditModal = false; editingPhone = ''">取消</button>
<button class="flex-1 py-2.5 rounded-lg bg-primary text-white font-bold hover:bg-primary-hover transition" @click="handleUpdatePhone">保存</button>
</div>
</div>
</div>
@@ -403,6 +587,16 @@
</div>
</div>
</div>
<!-- 用户信息卡片群聊消息列表点击头像 -->
<UserInfoCard
:show="showUserInfoCard"
:user="selectedUserForInfo as User | null"
@close="showUserInfoCard = false; selectedUserForInfo = null"
@send-message="handleSendMessageFromInfoCard"
@audio-call="handleAudioCallFromInfoCard"
@video-call="handleVideoCallFromInfoCard"
/>
</div>
</template>
@@ -420,6 +614,7 @@ import * as messageApi from '@/api/modules/message'
import * as contactApi from '@/api/modules/contact'
import * as attachmentApi from '@/api/modules/attachment'
import * as conversationApi from '@/api/modules/conversation'
import * as userApi from '@/api/modules/user'
import { formatTime, generateColor } from '@/utils/format'
import { getMessageSummary } from '@/utils/messageTypes'
import { storage } from '@/utils/storage'
@@ -441,6 +636,7 @@ import GroupNotifyView from '@/views/contact/GroupNotifyView.vue'
import SelectContactsModal from '@/components/chat/SelectContactsModal.vue'
import GroupInfoPanel from '@/components/chat/GroupInfoPanel.vue'
import GroupChatPanel from '@/components/chat/GroupChatPanel.vue'
import UserInfoCard from '@/components/common/UserInfoCard.vue'
import * as groupApi from '@/api/modules/room'
const router = useRouter()
@@ -469,6 +665,16 @@ const pendingDeleteConversation = ref<Conversation | null>(null)
const showGroupInfoPanel = ref(false)
const hoveredBadgeId = ref<number | null>(null)
const refreshingConversations = ref(false)
const showAvatarEditModal = ref(false)
const showNameEditModal = ref(false)
const showPhoneEditModal = ref(false)
const editingAvatar = ref('')
const editingName = ref('')
const editingPhone = ref('')
const avatarFileInput = ref<HTMLInputElement | null>(null)
const selectedAvatarFile = ref<File | null>(null)
const showUserInfoCard = ref(false)
const selectedUserForInfo = ref<User | Contact | null>(null)
const roomMembers = ref<Record<string, Record<string, { name: string; avatar?: string }>>>({})
// Scroll Logic State
@@ -1106,6 +1312,144 @@ function processMessageAfterConversation(message: ChatMessage, roomId: string) {
function backToList() { if (isMobile.value) chatVisible.value = false }
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
function getMsgSummary(msg: ChatMessage): string { return getMessageSummary(msg) }
// 复制文本
function copyText(text?: string) {
if (!text) return
navigator.clipboard.writeText(text).then(() => {
toastStore.success('复制成功')
}).catch(() => {
toastStore.error('复制失败')
})
}
// 处理头像文件选择
function handleAvatarFileSelect(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toastStore.error('请选择图片文件')
return
}
// 保存文件对象用于上传
selectedAvatarFile.value = file
// 预览图片(用于显示)
const reader = new FileReader()
reader.onload = (e) => {
editingAvatar.value = e.target?.result as string
}
reader.readAsDataURL(file)
}
// 更新头像
async function handleUpdateAvatar() {
if (!authStore.user) return
try {
let avatarUrl = editingAvatar.value
// 如果有选择的文件,先上传文件到服务器
if (selectedAvatarFile.value) {
const uploadResult: any = await attachmentApi.uploadAttachment(selectedAvatarFile.value, 'image')
avatarUrl = uploadResult.file_url || uploadResult.url || editingAvatar.value
selectedAvatarFile.value = null
} else if (avatarUrl && !avatarUrl.startsWith('http') && !avatarUrl.startsWith('/') && !avatarUrl.startsWith('data:image/')) {
// 如果输入的是普通文本,不处理
toastStore.warning('请输入有效的图片URL或上传图片文件')
return
}
// 更新用户信息
await userApi.updateUser({
id: authStore.user.id,
updates: { avatar: avatarUrl }
})
// 刷新用户信息
const updatedUser = await userApi.getMyInfo() as any
authStore.updateUserInfo(updatedUser)
toastStore.success('头像已更新')
showAvatarEditModal.value = false
editingAvatar.value = ''
} catch (e: any) {
console.error('Failed to update avatar:', e)
toastStore.error(e?.response?.data?.message || e?.message || '更新失败')
}
}
// 更新昵称
async function handleUpdateName() {
if (!authStore.user || !editingName.value.trim()) {
toastStore.warning('昵称不能为空')
return
}
try {
await userApi.updateUser({
id: authStore.user.id,
updates: { name: editingName.value.trim() }
})
// 刷新用户信息
const updatedUser = await userApi.getMyInfo() as any
authStore.updateUserInfo(updatedUser)
toastStore.success('昵称已更新')
showNameEditModal.value = false
editingName.value = ''
} catch (e: any) {
console.error('Failed to update name:', e)
toastStore.error(e?.response?.data?.message || e?.message || '更新失败')
}
}
// 更新手机号
async function handleUpdatePhone() {
if (!authStore.user) return
try {
await userApi.updateUser({
id: authStore.user.id,
updates: { phone: editingPhone.value.trim() }
})
// 刷新用户信息
const updatedUser = await userApi.getMyInfo() as any
authStore.updateUserInfo(updatedUser)
toastStore.success('手机号已更新')
showPhoneEditModal.value = false
editingPhone.value = ''
} catch (e: any) {
console.error('Failed to update phone:', e)
toastStore.error(e?.response?.data?.message || e?.message || '更新失败')
}
}
// 监听编辑弹窗打开,初始化编辑值
watch(showNameEditModal, (show) => {
if (show && authStore.user) {
editingName.value = authStore.user.name || ''
}
})
watch(showPhoneEditModal, (show) => {
if (show && authStore.user) {
editingPhone.value = authStore.user.phone || ''
}
})
watch(showAvatarEditModal, (show) => {
if (show && authStore.user) {
editingAvatar.value = authStore.user.avatar || ''
selectedAvatarFile.value = null
}
})
async function startCall(type: 'audio' | 'video') {
if (!chatStore.currentTarget) return
const receiverUserId = chatStore.currentTarget.user_id || chatStore.currentTarget.id
@@ -1386,8 +1730,83 @@ function handleAvatarClick(userOrContact: User | Contact) {
return
}
// 显示对方信息
selectedUser.value = userOrContact as Contact
// 显示对方信息 - 使用新的 UserInfoCard 组件
// 如果是 Contact提取 User 信息
if ('user' in userOrContact && userOrContact.user) {
selectedUserForInfo.value = userOrContact.user
} else if ('user_id' in userOrContact) {
// 如果是 Contact 但没有 user 信息,需要查找或构造
const contact = userOrContact as Contact
// 尝试从联系人列表中找到完整的用户信息
const fullContact = chatStore.contacts.find(c => c.user_id === contact.user_id || c.id === contact.user_id)
if (fullContact?.user) {
selectedUserForInfo.value = fullContact.user
} else {
// 如果没有找到,构造一个基本的 User 对象
selectedUserForInfo.value = {
id: contact.user_id || contact.id,
name: contact.remark_name || contact.user?.name || '未知',
avatar: contact.user?.avatar || '',
email: contact.user?.email || '',
phone: contact.user?.phone || '',
desc: contact.user?.desc || '',
region: contact.user?.region || '',
created_at: contact.user?.created_at || '',
updated_at: contact.user?.updated_at || '',
} as User
}
} else {
selectedUserForInfo.value = userOrContact as User
}
showUserInfoCard.value = true
}
// 从用户信息卡片发送消息
function handleSendMessageFromInfoCard() {
if (!selectedUserForInfo.value) return
// 确保是 User 类型
const user = selectedUserForInfo.value as User
// 查找或创建对应的联系人
const userId = user.id
let contact = chatStore.contacts.find(c => c.user_id === userId || c.id === userId)
if (!contact) {
// 如果不在联系人列表中,创建一个临时的 Contact 对象
contact = {
id: userId,
user_id: userId,
contact_user_id: userId,
room_id: '',
room_type: 'p2p',
is_group: false,
remark_name: user.name,
is_top: false,
is_muted: false,
user: user,
} as Contact
}
chatStore.setCurrentTarget(contact)
showUserInfoCard.value = false
selectedUserForInfo.value = null
}
// 从用户信息卡片发起语音通话
function handleAudioCallFromInfoCard() {
if (!selectedUserForInfo.value) return
// 这里可以调用 WebRTC 相关功能
showUserInfoCard.value = false
selectedUserForInfo.value = null
}
// 从用户信息卡片发起视频通话
function handleVideoCallFromInfoCard() {
if (!selectedUserForInfo.value) return
// 这里可以调用 WebRTC 相关功能
showUserInfoCard.value = false
selectedUserForInfo.value = null
}
// 判断是否为好友
@@ -1564,6 +1983,10 @@ onUnmounted(() => {
.fade-slide-enter-active, .fade-slide-leave-active { transition: all 0.3s ease; }
.fade-slide-enter-from, .fade-slide-leave-to { opacity: 0; transform: translateY(20px); }
.animate-fade-in { animation: fadeIn 0.3s ease-out; }
.animate-scale-in { animation: scaleIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); }
.animate-slide-up { animation: slideUp 0.4s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
@keyframes scaleIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.animate-pulse-fast { animation: pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
</style>