144 lines
3.0 KiB
Vue
144 lines
3.0 KiB
Vue
<template>
|
||
<Teleport to="body">
|
||
<div class="toast-container">
|
||
<TransitionGroup name="toast" tag="div" class="toast-stack">
|
||
<div
|
||
v-for="item in toastStore.displaying"
|
||
:key="item.id"
|
||
:class="['toast-item', `toast-item--${item.type}`]"
|
||
role="status"
|
||
aria-live="polite"
|
||
>
|
||
<span class="toast-message">
|
||
{{ item.count > 1 ? `${item.message} (×${item.count})` : item.message }}
|
||
</span>
|
||
</div>
|
||
</TransitionGroup>
|
||
</div>
|
||
</Teleport>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { onMounted, onUnmounted } from 'vue'
|
||
import { useToastStore } from '@/stores/toast'
|
||
import type { ToastItem } from '@/stores/toast'
|
||
|
||
const toastStore = useToastStore()
|
||
const TIMEOUT_MS = 3000
|
||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||
|
||
function scheduleRemove(item: ToastItem) {
|
||
const t = setTimeout(() => {
|
||
toastStore.remove(item.id)
|
||
timers.delete(item.id)
|
||
}, TIMEOUT_MS)
|
||
timers.set(item.id, t)
|
||
}
|
||
|
||
function syncTimers() {
|
||
for (const item of toastStore.displaying) {
|
||
const existing = timers.get(item.id)
|
||
if (existing) clearTimeout(existing)
|
||
scheduleRemove(item)
|
||
}
|
||
for (const [id] of timers) {
|
||
if (!toastStore.displaying.some((d) => d.id === id)) {
|
||
clearTimeout(timers.get(id)!)
|
||
timers.delete(id)
|
||
}
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
syncTimers()
|
||
const stop = toastStore.$subscribe(syncTimers)
|
||
onUnmounted(() => {
|
||
stop()
|
||
timers.forEach((t) => clearTimeout(t))
|
||
timers.clear()
|
||
})
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.toast-container {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 10000;
|
||
pointer-events: none;
|
||
/* 避开顶部导航栏(约 64px)+ 安全区域 */
|
||
padding: max(72px, calc(64px + env(safe-area-inset-top))) 16px 16px;
|
||
display: flex;
|
||
justify-content: center;
|
||
}
|
||
|
||
.toast-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 10px;
|
||
width: min(520px, calc(100vw - 32px));
|
||
}
|
||
|
||
.toast-item {
|
||
position: relative;
|
||
width: 100%;
|
||
padding: 12px 28px;
|
||
border-radius: 8px;
|
||
backdrop-filter: blur(8px);
|
||
text-align: center;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.toast-item--success {
|
||
background: rgba(76, 175, 80, 0.65);
|
||
color: rgba(255, 255, 255, 0.95);
|
||
}
|
||
|
||
.toast-item--error {
|
||
background: rgba(244, 67, 54, 0.65);
|
||
color: rgba(255, 255, 255, 0.95);
|
||
}
|
||
|
||
.toast-item--warning {
|
||
background: rgba(255, 152, 0, 0.65);
|
||
color: rgba(33, 33, 33, 0.95);
|
||
}
|
||
|
||
.toast-item--info {
|
||
background: rgba(33, 150, 243, 0.65);
|
||
color: rgba(255, 255, 255, 0.95);
|
||
}
|
||
|
||
.toast-message {
|
||
display: block;
|
||
width: 100%;
|
||
}
|
||
|
||
/* Transition */
|
||
.toast-enter-active,
|
||
.toast-leave-active {
|
||
transition: all 0.25s ease;
|
||
}
|
||
|
||
.toast-enter-from {
|
||
opacity: 0;
|
||
transform: translateY(-12px);
|
||
}
|
||
|
||
.toast-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-8px);
|
||
}
|
||
|
||
/* 下方 Toast 在上方消失时平缓上移 */
|
||
.toast-move {
|
||
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
</style>
|