Files
nl-um-vue-ts/src/views/chat/ChatView.vue

556 lines
25 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-screen w-screen flex text-sm sm:text-base bg-dark" @contextmenu.prevent>
<!-- 左侧功能导航 (UI微调增加阴影和圆角) -->
<div class="w-16 bg-dark border-r border-gray-800 flex flex-col items-center py-6 gap-6 z-20 hidden md:flex shrink-0 shadow-xl">
<Avatar
:name="authStore.user?.name"
:avatar="authStore.user?.avatar"
size="md"
class="cursor-pointer hover:ring-2 ring-primary transition-all duration-300 transform hover:scale-110 shadow-lg"
@click="showProfileModal = true"
/>
<!-- ...导航图标保持不变增加 hover scale... -->
<div
class="text-gray-400 hover:text-primary cursor-pointer transition transform hover:scale-110 relative w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
:class="{ 'text-primary bg-white/5': currentTab === 'chat' }"
@click="currentTab = 'chat'"
>
<i class="fas fa-comment-dots text-xl"></i>
<div v-if="chatStore.totalUnread > 0" class="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
</div>
<div
class="text-gray-400 hover:text-primary cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
:class="{ 'text-primary bg-white/5': currentTab === 'contact' }"
@click="currentTab = 'contact'"
>
<i class="fas fa-address-book text-xl"></i>
</div>
<div
class="mt-auto mb-2 text-gray-500 hover:text-red-500 cursor-pointer transition transform hover:scale-110 w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/5"
@click="handleLogout"
title="退出登录"
>
<i class="fas fa-sign-out-alt text-xl"></i>
</div>
</div>
<!-- 中间侧边栏 (UI微调) -->
<div
v-show="currentTab === 'chat' && (!isMobile || !chatVisible)"
class="w-full md:w-80 bg-panel border-r border-gray-800 flex flex-col z-10 h-full shrink-0"
>
<div class="p-5 border-b border-gray-800">
<div class="relative group">
<i class="fas fa-search absolute left-4 top-3 text-gray-500 group-focus-within:text-primary transition"></i>
<input
v-model="searchQuery"
type="text"
class="w-full bg-input rounded-2xl py-2.5 pl-10 pr-4 text-sm text-white focus:ring-2 ring-primary outline-none transition placeholder-gray-500 shadow-inner"
placeholder="搜索会话或联系人..."
/>
</div>
</div>
<div class="flex-1 overflow-y-auto px-2 space-y-1 py-2 custom-scrollbar">
<div
v-for="conv in filteredConversations"
:key="conv.id"
class="flex items-center p-3 cursor-pointer rounded-2xl transition-all duration-200 relative group border border-transparent"
:class="{
'bg-gradient-to-r from-primary/20 to-transparent border-primary/30': chatStore.currentTarget && (chatStore.currentTarget.user_id === conv.target_id || chatStore.currentTarget.id === conv.target_id),
'hover:bg-white/5 hover:border-white/5': !chatStore.currentTarget || (chatStore.currentTarget.user_id !== conv.target_id && chatStore.currentTarget.id !== conv.target_id),
'bg-black/20': conv.is_top,
}"
@click="selectChatByConversation(conv)"
@contextmenu.stop="showConversationMenu($event, conv)"
>
<div class="relative shrink-0">
<Avatar
:name="conv.name"
:avatar="conv.avatar || conv.name?.charAt(0)"
:color="undefined"
size="contact"
rounded="xl"
class="transition transform group-hover:scale-105 shadow-md"
/>
<div
v-if="(conv.unread_count || 0) > 0"
class="absolute -top-1.5 -right-1.5 bg-red-500 text-white text-[10px] min-w-[18px] h-[18px] flex items-center justify-center rounded-full border-2 border-panel font-bold shadow-sm animate-bounce"
>
{{ conv.unread_count }}
</div>
</div>
<div class="flex-1 min-w-0 ml-3">
<div class="flex justify-between items-center mb-0.5">
<span class="font-semibold truncate text-gray-200 text-sm group-hover:text-white transition">
{{ conv.name || '未知' }}
</span>
<span class="text-xs text-gray-500 group-hover:text-gray-400">{{ formatTime(conv.last_time || Date.now()) }}</span>
</div>
<div class="text-xs text-gray-400 truncate flex items-center h-4 group-hover:text-gray-300">
<i v-if="conv.is_top" class="fas fa-thumbtack text-yellow-500 mr-1 text-[10px]"></i>
<span v-if="conv.is_muted" class="fas fa-bell-slash text-gray-600 mr-1 text-[10px]"></span>
<span>{{ conv.last_message || '' }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧主聊天区 -->
<div
v-if="chatStore.currentTarget && (currentTab === 'chat' && (!isMobile || chatVisible))"
class="flex-1 flex flex-col bg-dark relative w-full h-full overflow-hidden"
>
<!-- 聊天头部 -->
<div class="h-16 border-b border-gray-800 flex justify-between items-center px-6 bg-panel/90 backdrop-blur shrink-0 z-20 shadow-sm">
<div class="flex items-center gap-3 cursor-pointer group" @click="backToList">
<i class="fas fa-chevron-left md:hidden text-gray-400 text-lg p-2 -ml-2"></i>
<Avatar
:name="chatStore.currentTarget.user?.name || chatStore.currentTarget.remark_name"
:avatar="chatStore.currentTarget.user?.avatar || chatStore.currentTarget.remark_name?.charAt(0)"
:color="chatStore.currentTarget.color"
size="sm"
rounded="full"
class="group-hover:ring-2 ring-primary transition"
/>
<div>
<h3 class="font-bold text-sm md:text-base text-gray-100 flex items-center gap-2">
{{ chatStore.currentTarget.remark_name || chatStore.currentTarget.user?.name }}
<span class="w-2 h-2 bg-success rounded-full animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>
</h3>
</div>
</div>
<div class="flex gap-2 text-gray-400">
<button class="action-btn" title="语音通话" @click="startCall('audio')"><i class="fas fa-phone"></i></button>
<button class="action-btn" title="视频通话" @click="startCall('video')"><i class="fas fa-video"></i></button>
<button class="action-btn" @click.stop="showChatOptionsMenu($event, chatStore.currentTarget)"><i class="fas fa-ellipsis-v"></i></button>
</div>
</div>
<!-- 消息列表 (包含滚动逻辑) -->
<!-- 修复: 添加 flex flex-col 确保 MessageList flex-1 生效 -->
<div class="flex-1 overflow-hidden relative flex flex-col min-h-0">
<MessageList
ref="messageListRef"
:messages="currentMessages"
:current-user="authStore.user!"
:target="chatStore.currentTarget"
@scroll="handleScroll"
/>
<!-- 新消息提示/回到底部按钮 -->
<Transition name="fade-slide">
<button
v-if="showScrollBottomBtn"
class="absolute bottom-6 right-6 bg-primary text-white px-4 py-2 rounded-full shadow-xl hover:bg-indigo-500 transition-all transform hover:scale-105 active:scale-95 flex items-center gap-2 z-30"
@click="scrollToBottom(true)"
>
<i class="fas fa-arrow-down"></i>
<span v-if="unreadCount > 0" class="text-xs font-bold">{{ unreadCount }} 条新消息</span>
<span v-else class="text-xs">回到底部</span>
</button>
</Transition>
</div>
<!-- 输入区 -->
<MessageInput
v-model="inputText"
:is-recording="isRecording"
@send="handleSendText"
@file="handleFileSelect"
@record-start="handleRecordStart"
@record-stop="handleRecordStop"
@record-cancel="handleRecordCancel"
/>
</div>
<!-- 空状态 -->
<div
v-else-if="currentTab === 'chat'"
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 hidden md:flex"
>
<div class="w-32 h-32 bg-panel rounded-full flex items-center justify-center mb-6 shadow-2xl border border-gray-800 animate-float">
<i class="fas fa-comments text-5xl text-primary opacity-80"></i>
</div>
<p class="text-2xl font-medium text-gray-300 tracking-widest">NL-IM</p>
<p class="text-sm mt-3 opacity-60 font-light">Design for Developer</p>
</div>
<!-- 联系人页面左侧联系人列表 + 右侧联系人操作区QQ 风格 -->
<div
v-if="currentTab === 'contact'"
class="flex flex-1 bg-panel h-full"
>
<!-- 左侧联系人列表分组 & QQ 风格 -->
<div class="w-80 border-r border-gray-800 shrink-0">
<ContactCircle />
</div>
<!-- 右侧根据 leftPanelMode 和选中好友显示不同内容 -->
<div class="flex-1 min-w-0">
<!-- 默认空页面 -->
<div
v-if="contactStore.leftPanelMode === 'default' && !contactStore.selectedContact"
class="flex-1 flex flex-col items-center justify-center bg-dark text-gray-500 h-full"
>
<div class="w-32 h-32 bg-panel rounded-full flex items-center justify-center mb-6 shadow-2xl border border-gray-800 animate-float">
<i class="fas fa-address-book text-5xl text-primary opacity-80"></i>
</div>
<p class="text-2xl font-medium text-gray-300 tracking-widest">NL-IM</p>
<p class="text-sm mt-3 opacity-60 font-light">Design for Developer</p>
</div>
<!-- 好友资料卡视图 -->
<ContactDetailCard
v-else-if="contactStore.selectedContact"
@switch-to-chat="currentTab = 'chat'"
/>
<!-- 好友管理器视图 -->
<ContactView v-else-if="contactStore.leftPanelMode === 'friend-manager'" />
<!-- 好友通知视图 -->
<FriendNotifyView v-else-if="contactStore.leftPanelMode === 'friend-notify'" />
<!-- 群通知视图预留 -->
<div v-else-if="contactStore.leftPanelMode === 'group-notify'" class="flex-1 flex items-center justify-center text-gray-400">
<div class="text-center">
<i class="fas fa-users text-6xl mb-4 opacity-50"></i>
<p class="text-sm">群通知功能开发中</p>
</div>
</div>
</div>
</div>
<ContextMenu />
<CallWindow ref="callWindowRef" :call="webrtc.call" :target="chatStore.currentTarget" :is-mobile="isMobile" :local-stream="webrtc.localStream.value" :remote-stream="webrtc.remoteStream.value" @end-call="webrtc.endCall" @accept-call="webrtc.acceptCall" @toggle-mute="webrtc.toggleMute" @toggle-camera="webrtc.toggleCamera" @toggle-minimize="webrtc.call.minimized = !webrtc.call.minimized" />
<FileConfirmModal :show="fileModal.show" :type="fileModal.type" :preview="fileModal.preview" :name="fileModal.name" :size="fileModal.size" @close="fileModal.show = false" @confirm="confirmSendFile" />
<!-- Profile Modal (kept simple) -->
<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">
<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">
<Avatar
:name="authStore.user.name"
:avatar="authStore.user.avatar"
size="xl"
rounded="full"
class="mx-auto border-4 border-panel shadow-xl"
/>
<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>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useConversationStore } from '@/stores/conversation'
import { useContactStore } from '@/stores/contact'
import { useToastStore } from '@/stores/toast'
import { useContextMenu } from '@/composables/useContextMenu'
import { wsManager } from '@/api/websocket'
import * as messageApi from '@/api/modules/message'
import * as contactApi from '@/api/modules/contact'
import * as attachmentApi from '@/api/modules/attachment'
import { formatTime } from '@/utils/format'
import { storage } from '@/utils/storage'
import type { Contact, ChatMessage } from '@/types/api'
import type { Conversation } from '@/types/conversation'
import { useWebRTC } from '@/composables/useWebRTC'
// Components...
import Avatar from '@/components/common/Avatar.vue'
import ContextMenu from '@/components/common/ContextMenu.vue'
import FileConfirmModal from '@/components/common/FileConfirmModal.vue'
import CallWindow from '@/components/call/CallWindow.vue'
import MessageList from '@/components/chat/MessageList.vue'
import MessageInput from '@/components/chat/MessageInput.vue'
import ContactView from '@/views/contact/ContactView.vue'
import ContactCircle from '@/views/contact/ContactCircle.vue'
import ContactDetailCard from '@/views/contact/ContactDetailCard.vue'
import FriendNotifyView from '@/views/contact/FriendNotifyView.vue'
const router = useRouter()
const authStore = useAuthStore()
const chatStore = useChatStore()
const conversationStore = useConversationStore()
const toastStore = useToastStore()
const contactStore = useContactStore()
const { showContextMenu } = useContextMenu()
// WebRTC logic...
function handleIncomingCall(senderUserId: string) {
const contact = chatStore.contacts.find((c) => c.user_id === senderUserId || c.id === senderUserId)
if (contact && (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id)) {
selectChat(contact)
}
}
const webrtc = useWebRTC(authStore.user?.id || '', handleIncomingCall)
// State - 从本地存储恢复
const currentTab = ref<'chat' | 'contact'>(storage.getCurrentTab())
const searchQuery = ref('')
const inputText = ref('')
const chatVisible = ref(false)
const isMobile = ref(window.innerWidth < 768)
const showProfileModal = ref(false)
const isRecording = ref(false)
const callWindowRef = ref<InstanceType<typeof CallWindow> | null>(null)
const fileModal = ref({ show: false, type: 0, preview: '', name: '', size: 0, file: null as File | null })
// Scroll Logic State
const showScrollBottomBtn = ref(false)
const unreadCount = ref(0)
const isNearBottom = ref(true)
const filteredConversations = computed(() => {
const query = searchQuery.value.toLowerCase()
return conversationStore.conversations.filter(conv => {
const name = (conv.name || conv.target_user?.name || '').toLowerCase()
return name.includes(query)
})
})
const currentMessages = computed(() => {
if (!chatStore.currentTarget) return []
const roomId = getRoomId(chatStore.currentTarget)
return chatStore.getRoomMessages(roomId)
})
function getRoomId(contact: Contact): string {
// 优先使用 contact.room_id雪花ID如果没有则使用旧的生成方式作为后备
if (contact.room_id) {
return contact.room_id
}
// 后备方案:使用旧的格式(兼容旧数据)
const userIds = [authStore.user!.id, contact.user_id || contact.id || contact.contact_user_id].sort()
return userIds.join('_')
}
// 滚动处理
function handleScroll(e: Event) {
const target = e.target as HTMLElement
// 阈值设为 100px
const threshold = 100
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
isNearBottom.value = distanceToBottom < threshold
// 如果接近底部,隐藏按钮并清空未读
if (isNearBottom.value) {
showScrollBottomBtn.value = false
unreadCount.value = 0
} else {
showScrollBottomBtn.value = true
}
}
function scrollToBottom(smooth = true) {
nextTick(() => {
const container = document.getElementById('msgListContainer')
if (container) {
container.scrollTo({
top: container.scrollHeight,
behavior: smooth ? 'smooth' : 'auto'
})
showScrollBottomBtn.value = false
unreadCount.value = 0
}
})
}
async function selectChat(contact: Contact) {
// 保存选中的会话
storage.setSelectedConversation(contact.user_id || contact.id)
chatStore.setCurrentTarget(contact)
chatVisible.value = true
unreadCount.value = 0 // 切换聊天时重置
const roomId = getRoomId(contact)
if (chatStore.getRoomMessages(roomId).length === 0) {
try {
const response = await messageApi.getMessages(roomId, 1, 50)
const msgs = response.data.map((msg: ChatMessage) => ({
...msg,
isSelf: msg.sender_user_id === authStore.user!.id,
extra: typeof msg.extra === 'string' ? JSON.parse(msg.extra || '{}') : msg.extra,
}))
chatStore.setRoomMessages(roomId, msgs.reverse())
} catch (error) { console.error('Failed to load messages:', error) }
}
scrollToBottom(false) // 初始进入直接跳到底部
}
// 通过会话点击打开聊天
async function selectChatByConversation(conv: Conversation) {
// 在联系人列表中找到对应联系人(用于沿用现有 chatStore 结构)
const contact = chatStore.contacts.find(
c => c.user_id === conv.target_id || c.id === conv.target_id,
)
if (!contact) {
// 如果本地没有联系人数据,可以在这里补一次联系人列表加载;当前先简单返回
return
}
await selectChat(contact)
// 清空该会话未读
await conversationStore.clearUnread(conv.target_id)
}
// ... Send Logic (Text, File, Audio) ...
// (保持大部分原有逻辑,但在发送成功后调用 scrollToBottom)
async function sendMessage(type: number, content: string, extra: any = {}, duration = 0) {
if (!chatStore.currentTarget) return
const roomId = getRoomId(chatStore.currentTarget)
const payload = {
sender_client_id: wsManager.getClientId() || '',
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
room_id: roomId, message_type: type, content, duration, extra: JSON.stringify(extra)
}
// Optimistic UI update
const message: ChatMessage = {
id: Date.now(), room_id: roomId, sender_user_id: authStore.user!.id,
receiver_user_id: chatStore.currentTarget.user_id || chatStore.currentTarget.id,
message_type: type, content, duration, extra, created_at: new Date().toISOString(), isSelf: true
}
chatStore.addMessage(roomId, message)
chatStore.updateContactLastMsg(chatStore.currentTarget.id, getMsgSummary(message), Date.now())
scrollToBottom(true) // 发送消息后自动滚动
try {
await messageApi.sendMessage(payload)
} catch(e) { toastStore.error('发送失败'); } // Should handle remove optimistic msg on fail
}
// WebSocket 消息处理核心优化
function handleWebSocketMessage(message: ChatMessage) {
if (message.message_type === 6) return
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
chatStore.addMessage(roomId, message)
const contact = chatStore.contacts.find(c => c.user_id === message.sender_user_id || c.id === message.sender_user_id)
if (contact) {
chatStore.updateContactLastMsg(contact.id, getMsgSummary(message), Date.now())
conversationStore.handleMessageUpdate(message, message.isSelf === true)
if (!message.isSelf) {
// 如果不是当前聊天窗口,或者是当前窗口但用户不在底部
if (!chatStore.currentTarget || chatStore.currentTarget.id !== contact.id) {
chatStore.incrementUnread(contact.id)
} else {
// 当前窗口
if (isNearBottom.value) {
scrollToBottom(true)
} else {
unreadCount.value++
showScrollBottomBtn.value = true
}
}
// 系统通知
if ('Notification' in window && Notification.permission === 'granted' && document.hidden) {
new Notification(contact.remark_name || contact.user?.name || '新消息', {
body: getMsgSummary(message),
icon: '/favicon.ico'
})
}
}
}
}
// Helper functions (backToList, handleLogout, getMsgSummary, etc.) keep same...
function backToList() { if (isMobile.value) chatVisible.value = false }
function handleLogout() { authStore.logout(); wsManager.disconnect(); router.push('/login') }
function getMsgSummary(msg: ChatMessage): string { const types: Record<number, string> = { 1: '[图片]', 2: '[语音]', 3: '[视频]', 8: '[文件]' }; return types[msg.message_type] || msg.content }
async function startCall(type: 'audio' | 'video') { if (!chatStore.currentTarget) return; await webrtc.startCall(type, chatStore.currentTarget.user_id || chatStore.currentTarget.id) }
function showContactMenu(event: MouseEvent, contact: Contact) { showContextMenu(event, [{ label: '删除会话', icon: 'fas fa-trash', danger: true, action: () => {} }]) }
function showChatOptionsMenu(event: MouseEvent, contact: Contact) { showContextMenu(event, [{ label: '清空记录', icon: 'fas fa-eraser', danger: true, action: () => chatStore.clearRoomMessages(getRoomId(contact)) }]) }
// File handlers...
function handleSendText() { if (!inputText.value.trim() || !chatStore.currentTarget) return; sendMessage(0, inputText.value); inputText.value = '' }
function handleFileSelect(type: number, file: File) {
fileModal.value = { show: true, type, preview: '', name: file.name, size: file.size, file }
if (type === 1) { const reader = new FileReader(); reader.onload = (e) => { fileModal.value.preview = e.target?.result as string }; reader.readAsDataURL(file) }
}
async function confirmSendFile() {
if (!fileModal.value.file || !chatStore.currentTarget) return
try {
const uploadType = fileModal.value.type === 1 ? 'image' : (fileModal.value.type === 3 ? 'video' : 'file')
toastStore.info('正在上传...')
const attachment = await attachmentApi.uploadAttachment(fileModal.value.file, uploadType)
const extra = { name: fileModal.value.name, size: fileModal.value.size, url: attachment.file_url, attachment_id: attachment.id }
await sendMessage(fileModal.value.type, attachment.file_url, extra, 0)
fileModal.value.show = false; toastStore.success('发送成功')
} catch (e: any) { toastStore.error('发送失败') }
}
// Recording handlers same...
function handleRecordStart() { isRecording.value = true }
async function handleRecordStop(blob: Blob, duration: number) { isRecording.value = false; if (!chatStore.currentTarget) return; try { const audioFile = new File([blob], `audio.webm`, { type: 'audio/webm' }); toastStore.info('正在上传语音...'); const attachment = await attachmentApi.uploadAttachment(audioFile, 'file'); await sendMessage(2, attachment.file_url, { duration: `00:${duration}`, url: attachment.file_url }, duration) } catch (e) { toastStore.error('发送失败') } }
function handleRecordCancel() { isRecording.value = false }
async function loadContacts() { try { const contacts = await contactApi.getContacts(); chatStore.setContacts(contacts) } catch (e) { console.error(e) } }
// 监听 currentTab 变化并保存
watch(currentTab, (newTab) => {
storage.setCurrentTab(newTab)
})
onMounted(async () => {
if (!authStore.isAuthenticated) {
const isValid = await authStore.checkAuth()
if (!isValid) { router.push('/login'); return }
}
// Remove permission request here!
if (authStore.user) {
await wsManager.connect(authStore.user.id)
wsManager.onMessage(handleWebSocketMessage)
wsManager.onSignal(webrtc.handleSignaling)
}
await loadContacts()
await conversationStore.loadConversations()
// 恢复选中的会话
const savedTargetId = storage.getSelectedConversation()
if (savedTargetId) {
const contact = chatStore.contacts.find(c => c.user_id === savedTargetId || c.id === savedTargetId)
if (contact) {
await selectChat(contact)
}
}
window.addEventListener('resize', () => isMobile.value = window.innerWidth < 768)
})
onUnmounted(() => {
wsManager.offMessage(handleWebSocketMessage)
wsManager.offSignal(webrtc.handleSignaling)
})
</script>
<style scoped>
.action-btn {
@apply w-10 h-10 rounded-xl hover:bg-white/10 hover:text-white transition flex items-center justify-center transform hover:scale-110 active:scale-95;
}
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
.custom-scrollbar::-webkit-scrollbar-thumb { @apply bg-gray-700 rounded-full; }
.animate-float { animation: float 6s ease-in-out infinite; }
@keyframes float { 0% { transform: translateY(0px); } 50% { transform: translateY(-20px); } 100% { transform: translateY(0px); } }
.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; }
@keyframes fadeIn { from { opacity: 0; transform: scale(0.95); } to { opacity: 1; transform: scale(1); } }
</style>