音视频通话功能完成(P2P)
已知问题: 文件无法发送
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
v-show="call.active"
|
||||
class="fixed z-[999] transition-all duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700"
|
||||
:class="[
|
||||
v-show="call.active"
|
||||
class="fixed z-[999] transition-all duration-300 overflow-hidden bg-gray-900 flex flex-col shadow-2xl border border-gray-700 font-sans"
|
||||
:class="[
|
||||
call.minimized
|
||||
? 'w-48 h-64 bottom-5 right-5 rounded-xl border-gray-600'
|
||||
: isMobile
|
||||
@@ -10,120 +10,134 @@
|
||||
: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[900px] h-[600px] rounded-2xl',
|
||||
]"
|
||||
>
|
||||
<!-- 顶部功能栏 -->
|
||||
<!-- 顶部功能栏 (渐变遮罩) -->
|
||||
<div
|
||||
class="absolute top-0 w-full p-4 z-20 flex justify-between items-start bg-gradient-to-b from-black/60 to-transparent transition-opacity hover:opacity-100"
|
||||
:class="{ 'opacity-0': !call.minimized }"
|
||||
class="absolute top-0 w-full p-4 z-30 flex justify-between items-start bg-gradient-to-b from-black/80 to-transparent transition-opacity hover:opacity-100"
|
||||
:class="{ 'opacity-0': !call.minimized && !isMobile && call.status === 'connected' }"
|
||||
>
|
||||
<div class="text-white text-sm font-bold drop-shadow-md px-2" v-if="!call.minimized">
|
||||
<div class="text-white text-sm font-bold drop-shadow-md px-2 flex items-center gap-2" v-if="!call.minimized">
|
||||
<i class="fas" :class="call.type === 'video' ? 'fa-video' : 'fa-phone'"></i>
|
||||
{{ call.type === 'video' ? '视频通话' : '语音通话' }}
|
||||
<span>{{ call.type === 'video' ? '视频通话' : '语音通话' }}</span>
|
||||
<span v-if="call.status === 'connected'" class="text-xs font-normal opacity-80 pl-2 border-l border-white/30">
|
||||
{{ formatDuration(call.duration) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-black/40 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition"
|
||||
@click="call.minimized = !call.minimized"
|
||||
|
||||
<!-- 最小化按钮 -->
|
||||
<button
|
||||
class="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 text-white flex items-center justify-center backdrop-blur transition ml-auto"
|
||||
@click="emit('toggle-minimize')"
|
||||
title="最小化/还原"
|
||||
>
|
||||
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
|
||||
</button>
|
||||
</div>
|
||||
>
|
||||
<i class="fas" :class="call.minimized ? 'fa-expand' : 'fa-compress'"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<!-- 主内容区域 -->
|
||||
<div class="flex-1 relative bg-black overflow-hidden flex items-center justify-center">
|
||||
<!-- 纯语音 UI 或 连接中状态:显示大头像 -->
|
||||
|
||||
<!-- 1. 语音/等待状态 UI -->
|
||||
<div
|
||||
v-if="call.type === 'audio' || call.status !== 'connected'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
|
||||
v-if="call.type === 'audio' || call.status !== 'connected'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 z-10"
|
||||
>
|
||||
<!-- 呼叫中/语音通话时的波纹动画 -->
|
||||
<div
|
||||
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-xl transition-all duration-1000 relative"
|
||||
:class="{
|
||||
'animate-[ripple_2s_infinite]':
|
||||
call.status === 'connected' || call.status === 'outgoing',
|
||||
}"
|
||||
:style="{ background: target?.color || '#6366f1' }"
|
||||
>
|
||||
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
|
||||
<!-- 动态波纹头像 -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-32 h-32 rounded-full flex items-center justify-center text-5xl font-bold mb-6 shadow-2xl z-20 relative border-4 border-gray-800"
|
||||
:style="{ background: target?.color || '#6366f1' }"
|
||||
>
|
||||
{{ target?.user?.avatar || target?.remark_name?.charAt(0) || '?' }}
|
||||
</div>
|
||||
<!-- 波纹动画 -->
|
||||
<div v-if="call.status === 'outgoing' || call.status === 'connected'"
|
||||
class="absolute inset-0 bg-white/20 rounded-full animate-ping z-10"></div>
|
||||
</div>
|
||||
<div class="text-2xl font-bold text-gray-100 tracking-wide">
|
||||
|
||||
<div class="text-2xl font-bold text-gray-100 tracking-wide mt-2">
|
||||
{{ target?.remark_name || target?.user?.name || '未知用户' }}
|
||||
</div>
|
||||
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2">
|
||||
<span
|
||||
v-if="call.status === 'connected'"
|
||||
class="w-2 h-2 bg-green-500 rounded-full animate-pulse"
|
||||
></span>
|
||||
<div class="text-gray-400 mt-2 font-mono flex items-center gap-2 text-sm">
|
||||
<span v-if="call.status === 'connected'" class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
{{ call.statusText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 视频通话 UI -->
|
||||
<!-- 远程视频流 (仅 Video 模式且连接成功显示) -->
|
||||
<!-- 2. 远程视频流 (全屏) -->
|
||||
<video
|
||||
ref="remoteVideo"
|
||||
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0"
|
||||
autoplay
|
||||
playsinline
|
||||
:class="{
|
||||
'opacity-0': call.type === 'audio' || call.status !== 'connected',
|
||||
}"
|
||||
ref="remoteVideoRef"
|
||||
class="w-full h-full object-cover transition-opacity duration-500 absolute inset-0 z-0"
|
||||
autoplay
|
||||
playsinline
|
||||
:class="{ 'opacity-0': call.type === 'audio' || call.status !== 'connected' }"
|
||||
></video>
|
||||
|
||||
<!-- 本地视频 (小窗) - 仅 Video 模式显示 -->
|
||||
<video
|
||||
v-show="call.type === 'video' && !call.minimized"
|
||||
ref="localVideo"
|
||||
class="absolute top-5 right-5 w-48 h-36 bg-gray-800 rounded-lg border border-gray-600 object-cover z-20 shadow-2xl transition hover:scale-105"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<!-- 3. 本地视频流 (画中画) -->
|
||||
<!-- 优化:移动端使用竖屏比例 (w-24 h-32 -> 3:4),PC端使用横屏比例 (w-48 h-36 -> 4:3) -->
|
||||
<div
|
||||
v-show="call.type === 'video' && !call.minimized && call.status === 'connected'"
|
||||
class="absolute z-20 overflow-hidden shadow-2xl transition hover:scale-105 border border-white/20 bg-gray-800"
|
||||
:class="[
|
||||
isMobile
|
||||
? 'top-16 right-4 w-28 h-40 rounded-xl' /* 移动端:右上角,竖长方形 */
|
||||
: 'bottom-28 right-8 w-56 h-40 rounded-lg' /* PC端:右下角,横长方形 */
|
||||
]"
|
||||
>
|
||||
<video
|
||||
ref="localVideoRef"
|
||||
class="w-full h-full object-cover"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
<!-- 摄像头关闭提示 -->
|
||||
<div v-if="call.camOff" class="absolute inset-0 flex items-center justify-center bg-gray-800 text-white/50">
|
||||
<i class="fas fa-video-slash"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部控制栏 -->
|
||||
<div
|
||||
v-if="!call.minimized"
|
||||
class="h-24 bg-gray-900/90 backdrop-blur border-t border-gray-800 flex items-center justify-center gap-8 shrink-0 pb-4 z-30"
|
||||
v-if="!call.minimized"
|
||||
class="h-24 bg-gray-900/80 backdrop-blur-md border-t border-white/10 flex items-center justify-center gap-6 md:gap-10 shrink-0 pb-4 z-30"
|
||||
>
|
||||
<!-- 挂断 -->
|
||||
<button
|
||||
class="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center text-xl transition transform hover:scale-110 shadow-lg shadow-red-500/30"
|
||||
@click="endCall"
|
||||
title="挂断"
|
||||
class="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 text-white flex items-center justify-center text-xl transition transform active:scale-95 shadow-lg shadow-red-500/30"
|
||||
@click="emit('end-call')"
|
||||
title="挂断"
|
||||
>
|
||||
<i class="fas fa-phone-slash"></i>
|
||||
</button>
|
||||
|
||||
<!-- 接听 -->
|
||||
<!-- 接听 (仅来电时显示) -->
|
||||
<button
|
||||
v-if="call.status === 'incoming'"
|
||||
class="w-14 h-14 rounded-full bg-green-500 hover:bg-green-600 text-white flex items-center justify-center text-xl transition transform hover:scale-110 shadow-lg shadow-green-500/30"
|
||||
@click="acceptCall"
|
||||
title="接听"
|
||||
v-if="call.status === 'incoming'"
|
||||
class="w-14 h-14 rounded-full bg-green-500 hover:bg-green-600 text-white flex items-center justify-center text-xl transition transform active:scale-95 shadow-lg shadow-green-500/30 animate-pulse"
|
||||
@click="emit('accept-call')"
|
||||
title="接听"
|
||||
>
|
||||
<i class="fas fa-phone"></i>
|
||||
</button>
|
||||
|
||||
<!-- 通话中功能 -->
|
||||
<!-- 通话中功能按钮 -->
|
||||
<template v-if="call.status === 'connected'">
|
||||
<button
|
||||
class="w-12 h-12 rounded-full bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center"
|
||||
@click="toggleMute"
|
||||
:class="{ 'bg-white text-black': call.muted }"
|
||||
title="静音"
|
||||
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
|
||||
@click="emit('toggle-mute')"
|
||||
:class="{ 'bg-white !text-black': call.muted }"
|
||||
title="静音"
|
||||
>
|
||||
<i class="fas" :class="call.muted ? 'fa-microphone-slash' : 'fa-microphone'"></i>
|
||||
</button>
|
||||
<!-- 仅视频模式显示关闭摄像头 -->
|
||||
|
||||
<button
|
||||
v-if="call.type === 'video'"
|
||||
class="w-12 h-12 rounded-full bg-gray-700 hover:bg-gray-600 text-white transition flex items-center justify-center"
|
||||
@click="toggleCamera"
|
||||
:class="{ 'bg-white text-black': call.camOff }"
|
||||
title="关闭摄像头"
|
||||
v-if="call.type === 'video'"
|
||||
class="w-12 h-12 rounded-full bg-gray-700/80 hover:bg-gray-600 text-white transition flex items-center justify-center backdrop-blur"
|
||||
@click="emit('toggle-camera')"
|
||||
:class="{ 'bg-white !text-black': call.camOff }"
|
||||
title="开关摄像头"
|
||||
>
|
||||
<i class="fas" :class="call.camOff ? 'fa-video-slash' : 'fa-video'"></i>
|
||||
</button>
|
||||
@@ -133,7 +147,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import type { Contact } from '@/types/api'
|
||||
|
||||
interface Props {
|
||||
@@ -141,16 +155,20 @@ interface Props {
|
||||
active: boolean
|
||||
minimized: boolean
|
||||
type: 'audio' | 'video'
|
||||
status: 'idle' | 'outgoing' | 'incoming' | 'connected'
|
||||
status: string
|
||||
statusText: string
|
||||
muted: boolean
|
||||
camOff: boolean
|
||||
duration: number
|
||||
}
|
||||
target: Contact | null
|
||||
isMobile: boolean
|
||||
// 接收 MediaStream 对象,而不是 DOM 元素
|
||||
localStream: MediaStream | null
|
||||
remoteStream: MediaStream | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'end-call': []
|
||||
@@ -160,28 +178,36 @@ const emit = defineEmits<{
|
||||
'toggle-minimize': []
|
||||
}>()
|
||||
|
||||
const remoteVideo = ref<HTMLVideoElement>()
|
||||
const localVideo = ref<HTMLVideoElement>()
|
||||
const localVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
const remoteVideoRef = ref<HTMLVideoElement | null>(null)
|
||||
|
||||
function endCall() {
|
||||
emit('end-call')
|
||||
// 格式化时间辅助函数
|
||||
const formatDuration = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, '0')
|
||||
const s = (seconds % 60).toString().padStart(2, '0')
|
||||
return `${m}:${s}`
|
||||
}
|
||||
|
||||
function acceptCall() {
|
||||
emit('accept-call')
|
||||
}
|
||||
// 自动绑定流到 Video 元素 (解决本地视频异常的核心)
|
||||
watch(() => props.localStream, (newStream) => {
|
||||
if (localVideoRef.value && newStream) {
|
||||
localVideoRef.value.srcObject = newStream
|
||||
}
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
function toggleMute() {
|
||||
emit('toggle-mute')
|
||||
}
|
||||
watch(() => props.remoteStream, (newStream) => {
|
||||
if (remoteVideoRef.value && newStream) {
|
||||
remoteVideoRef.value.srcObject = newStream
|
||||
}
|
||||
}, { immediate: true, flush: 'post' })
|
||||
|
||||
function toggleCamera() {
|
||||
emit('toggle-camera')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
remoteVideo,
|
||||
localVideo,
|
||||
// 确保在组件挂载或 Video 元素出现时也能绑定
|
||||
watch([localVideoRef, remoteVideoRef], () => {
|
||||
if (localVideoRef.value && props.localStream) {
|
||||
localVideoRef.value.srcObject = props.localStream
|
||||
}
|
||||
if (remoteVideoRef.value && props.remoteStream) {
|
||||
remoteVideoRef.value.srcObject = props.remoteStream
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<div
|
||||
class="relative flex items-center group cursor-pointer select-none"
|
||||
@mousedown="handleRecordStart"
|
||||
@touchstart.prevent="handleRecordStart"
|
||||
@touchstart.passive="handleRecordStart"
|
||||
@mouseup="handleRecordStop"
|
||||
@touchend.prevent="handleRecordStop"
|
||||
@mouseleave="handleRecordCancel"
|
||||
@@ -69,6 +69,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
@@ -144,7 +147,7 @@ async function handleRecordStart() {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
|
||||
if (duration < 1000) {
|
||||
alert('说话时间太短')
|
||||
toastStore.warning('说话时间太短')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -156,9 +159,9 @@ async function handleRecordStart() {
|
||||
mediaRecorder.start()
|
||||
recordStartTime = Date.now()
|
||||
emit('record-start')
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error('Failed to start recording:', error)
|
||||
alert('无法访问麦克风')
|
||||
toastStore.error(error.message || '无法访问麦克风')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:src="`http://127.0.0.1:12080${imageUrl}`"
|
||||
class="rounded-lg max-w-full cursor-zoom-in hover:brightness-90 transition border border-white/10"
|
||||
@click="previewImage"
|
||||
/>
|
||||
@@ -22,7 +22,7 @@ const imageUrl = computed(() => {
|
||||
function previewImage() {
|
||||
const w = window.open('')
|
||||
if (w) {
|
||||
w.document.write(`<img src="${imageUrl.value}" style="max-width:100%">`)
|
||||
w.document.write(`<img src="http://127.0.0.1:12080${imageUrl.value}" style="max-width:100%">`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
82
src/components/common/ConfirmModal.vue
Normal file
82
src/components/common/ConfirmModal.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@click.self="$emit('cancel')"
|
||||
>
|
||||
<div class="bg-panel rounded-xl w-96 overflow-hidden shadow-2xl border border-gray-700 transform transition-all scale-100">
|
||||
<div class="p-4 border-b border-gray-700 font-bold bg-gray-800/50 flex justify-between items-center">
|
||||
<span>{{ title }}</span>
|
||||
<i class="fas fa-times cursor-pointer hover:text-white text-gray-500" @click="$emit('cancel')"></i>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-full flex items-center justify-center text-xl"
|
||||
:class="{
|
||||
'bg-danger/20 text-danger': type === 'danger',
|
||||
'bg-warning/20 text-warning': type === 'warning',
|
||||
'bg-primary/20 text-primary': type === 'info',
|
||||
}"
|
||||
>
|
||||
<i
|
||||
:class="{
|
||||
'fas fa-exclamation-triangle': type === 'danger',
|
||||
'fas fa-question-circle': type === 'warning',
|
||||
'fas fa-info-circle': type === 'info',
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-gray-300 leading-relaxed">{{ message }}</p>
|
||||
<p v-if="description" class="text-xs text-gray-400 mt-2">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex border-t border-gray-700">
|
||||
<button
|
||||
class="flex-1 py-3 text-gray-400 hover:bg-gray-700 transition"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 font-bold hover:bg-gray-700 transition border-l border-gray-700"
|
||||
:class="{
|
||||
'text-danger': type === 'danger',
|
||||
'text-primary': type !== 'danger',
|
||||
}"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
show: boolean
|
||||
title?: string
|
||||
message: string
|
||||
description?: string
|
||||
type?: 'danger' | 'warning' | 'info'
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: '确认操作',
|
||||
type: 'info',
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
|
||||
102
src/components/common/Toast.vue
Normal file
102
src/components/common/Toast.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<TransitionGroup
|
||||
name="toast"
|
||||
tag="div"
|
||||
class="fixed top-4 right-4 z-[10000] flex flex-col gap-2 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="pointer-events-auto min-w-[300px] max-w-[500px] bg-panel border border-gray-700 rounded-lg shadow-2xl p-4 flex items-start gap-3 animate-slide-in"
|
||||
:class="{
|
||||
'border-success': toast.type === 'success',
|
||||
'border-danger': toast.type === 'error',
|
||||
'border-yellow-500': toast.type === 'warning',
|
||||
'border-primary': toast.type === 'info',
|
||||
}"
|
||||
>
|
||||
<!-- 图标 -->
|
||||
<div
|
||||
class="flex-shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-sm"
|
||||
:class="{
|
||||
'bg-success/20 text-success': toast.type === 'success',
|
||||
'bg-danger/20 text-danger': toast.type === 'error',
|
||||
'bg-yellow-500/20 text-yellow-500': toast.type === 'warning',
|
||||
'bg-primary/20 text-primary': toast.type === 'info',
|
||||
}"
|
||||
>
|
||||
<i
|
||||
:class="{
|
||||
'fas fa-check-circle': toast.type === 'success',
|
||||
'fas fa-exclamation-circle': toast.type === 'error',
|
||||
'fas fa-exclamation-triangle': toast.type === 'warning',
|
||||
'fas fa-info-circle': toast.type === 'info',
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-white">{{ toast.message }}</div>
|
||||
<div v-if="toast.description" class="text-xs text-gray-400 mt-1">
|
||||
{{ toast.description }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
class="flex-shrink-0 text-gray-400 hover:text-white transition"
|
||||
@click="removeToast(toast.id)"
|
||||
>
|
||||
<i class="fas fa-times text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const toasts = computed(() => toastStore.toasts)
|
||||
|
||||
function removeToast(id: string) {
|
||||
toastStore.removeToast(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user