2026-02-25 18:40:46 +08:00

144 lines
3.0 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>
<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>