258 lines
5.5 KiB
Vue
258 lines
5.5 KiB
Vue
<!-- components/Toast.vue -->
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="toast-fade">
|
|
<div v-if="visible" class="toast-container" :class="typeClass">
|
|
<div class="toast-content">
|
|
<div class="toast-icon">
|
|
<svg v-if="type === 'success'" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
<svg v-else-if="type === 'error'" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
<svg v-else viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</div>
|
|
<div class="toast-body">
|
|
<div class="toast-title">{{ title }}</div>
|
|
<div v-if="message" class="toast-message">{{ message }}</div>
|
|
<div v-if="url" class="toast-url">{{ url }}</div>
|
|
</div>
|
|
<button v-if="showClose" class="toast-close" @click="close">
|
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M6 18L18 6M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, watch } from 'vue'
|
|
|
|
const props = defineProps({
|
|
type: {
|
|
type: String,
|
|
default: 'info', // 'success' | 'error' | 'info'
|
|
validator: (value) => ['success', 'error', 'info'].includes(value)
|
|
},
|
|
title: {
|
|
type: String,
|
|
required: true
|
|
},
|
|
message: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
url: {
|
|
type: String,
|
|
default: ''
|
|
},
|
|
duration: {
|
|
type: Number,
|
|
default: 3000 // 3秒后自动关闭
|
|
},
|
|
showClose: {
|
|
type: Boolean,
|
|
default: true
|
|
}
|
|
})
|
|
|
|
const emit = defineEmits(['close'])
|
|
|
|
const visible = ref(false)
|
|
let timer = null
|
|
|
|
const typeClass = computed(() => `toast-${props.type}`)
|
|
|
|
function show() {
|
|
visible.value = true
|
|
|
|
if (props.duration > 0) {
|
|
clearTimeout(timer)
|
|
timer = setTimeout(() => {
|
|
close()
|
|
}, props.duration)
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
visible.value = false
|
|
clearTimeout(timer)
|
|
emit('close')
|
|
}
|
|
|
|
// 组件挂载时显示
|
|
watch(() => visible.value, (newVal) => {
|
|
if (!newVal) {
|
|
// 动画结束后清理
|
|
setTimeout(() => {
|
|
emit('close')
|
|
}, 300)
|
|
}
|
|
}, { immediate: false })
|
|
|
|
defineExpose({ show, close })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.toast-container {
|
|
position: fixed;
|
|
top: 80px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 99999;
|
|
min-width: 320px;
|
|
max-width: 500px;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.toast-content {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 16px 20px;
|
|
background: #fff;
|
|
border-radius: 16px;
|
|
box-shadow:
|
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
|
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
|
border: 1px solid rgba(0, 0, 0, 0.05);
|
|
backdrop-filter: blur(12px);
|
|
-webkit-backdrop-filter: blur(12px);
|
|
}
|
|
|
|
.toast-icon {
|
|
flex-shrink: 0;
|
|
width: 24px;
|
|
height: 24px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.toast-icon svg {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
.toast-success .toast-icon {
|
|
color: #10b981;
|
|
}
|
|
|
|
.toast-error .toast-icon {
|
|
color: #ef4444;
|
|
}
|
|
|
|
.toast-info .toast-icon {
|
|
color: #3b82f6;
|
|
}
|
|
|
|
.toast-body {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.toast-title {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
color: #111827;
|
|
line-height: 1.4;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.toast-message {
|
|
font-size: 13px;
|
|
color: #6b7280;
|
|
line-height: 1.5;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.toast-url {
|
|
font-size: 12px;
|
|
color: #9ca3af;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
background: #f3f4f6;
|
|
padding: 6px 10px;
|
|
border-radius: 8px;
|
|
word-break: break-all;
|
|
line-height: 1.4;
|
|
border: 1px solid #e5e7eb;
|
|
}
|
|
|
|
.toast-close {
|
|
flex-shrink: 0;
|
|
width: 20px;
|
|
height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: none;
|
|
background: transparent;
|
|
color: #9ca3af;
|
|
cursor: pointer;
|
|
border-radius: 6px;
|
|
transition: all 0.15s ease;
|
|
padding: 0;
|
|
}
|
|
|
|
.toast-close:hover {
|
|
background: #f3f4f6;
|
|
color: #374151;
|
|
}
|
|
|
|
.toast-close svg {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
|
|
/* 动画 */
|
|
.toast-fade-enter-active {
|
|
animation: toast-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
}
|
|
|
|
.toast-fade-leave-active {
|
|
animation: toast-out 0.25s cubic-bezier(0.4, 0, 1, 1);
|
|
}
|
|
|
|
@keyframes toast-in {
|
|
0% {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(-20px) scale(0.95);
|
|
}
|
|
100% {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0) scale(1);
|
|
}
|
|
}
|
|
|
|
@keyframes toast-out {
|
|
0% {
|
|
opacity: 1;
|
|
transform: translateX(-50%) translateY(0) scale(1);
|
|
}
|
|
100% {
|
|
opacity: 0;
|
|
transform: translateX(-50%) translateY(-10px) scale(0.98);
|
|
}
|
|
}
|
|
|
|
/* 响应式 */
|
|
@media (max-width: 640px) {
|
|
.toast-container {
|
|
min-width: calc(100vw - 32px);
|
|
max-width: calc(100vw - 32px);
|
|
top: 16px;
|
|
}
|
|
|
|
.toast-content {
|
|
padding: 14px 16px;
|
|
}
|
|
}
|
|
</style>
|