2025-12-10 20:52:44 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<div
|
|
|
|
|
|
role="alert"
|
|
|
|
|
|
class="flex items-start gap-4 px-6 py-3 rounded-lg border shadow-sm max-w-md"
|
|
|
|
|
|
:class="variantClasses"
|
|
|
|
|
|
>
|
|
|
|
|
|
<!-- 图标带圆形进度环 -->
|
|
|
|
|
|
<div class="relative shrink-0 w-8 h-8">
|
|
|
|
|
|
<!-- 进度环背景 -->
|
|
|
|
|
|
<svg
|
|
|
|
|
|
v-if="toast.duration && toast.duration > 0"
|
|
|
|
|
|
class="absolute inset-0 w-8 h-8 -rotate-90"
|
|
|
|
|
|
>
|
|
|
|
|
|
<circle
|
|
|
|
|
|
cx="16"
|
|
|
|
|
|
cy="16"
|
|
|
|
|
|
r="14"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
stroke-width="2"
|
|
|
|
|
|
class="opacity-15"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<circle
|
|
|
|
|
|
cx="16"
|
|
|
|
|
|
cy="16"
|
|
|
|
|
|
r="14"
|
|
|
|
|
|
fill="none"
|
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
|
stroke-width="2"
|
|
|
|
|
|
:stroke-dasharray="circumference"
|
|
|
|
|
|
:stroke-dashoffset="strokeDashoffset"
|
|
|
|
|
|
stroke-linecap="round"
|
|
|
|
|
|
class="transition-[stroke-dashoffset] duration-75"
|
|
|
|
|
|
:class="progressColorClass"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
<!-- 图标 -->
|
2025-12-12 16:15:16 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="absolute inset-0 flex items-center justify-center"
|
|
|
|
|
|
:class="iconClasses"
|
|
|
|
|
|
>
|
|
|
|
|
|
<component
|
|
|
|
|
|
:is="icon"
|
|
|
|
|
|
class="w-4 h-4"
|
|
|
|
|
|
/>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 内容 -->
|
|
|
|
|
|
<div class="flex-1 min-w-0">
|
2025-12-12 16:15:16 +08:00
|
|
|
|
<p
|
|
|
|
|
|
v-if="toast.title"
|
|
|
|
|
|
class="text-sm font-medium"
|
|
|
|
|
|
:class="titleClasses"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
{{ toast.title }}
|
|
|
|
|
|
</p>
|
2025-12-12 16:15:16 +08:00
|
|
|
|
<p
|
|
|
|
|
|
v-if="toast.message"
|
|
|
|
|
|
class="text-sm"
|
|
|
|
|
|
:class="messageClasses"
|
|
|
|
|
|
>
|
2025-12-10 20:52:44 +08:00
|
|
|
|
{{ toast.message }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 关闭按钮 -->
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="shrink-0 p-1 rounded transition-colors opacity-40 hover:opacity-100"
|
|
|
|
|
|
:class="closeClasses"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="关闭"
|
2025-12-12 16:15:16 +08:00
|
|
|
|
@click="$emit('remove')"
|
2025-12-10 20:52:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
<X class="w-3.5 h-3.5" />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
|
|
|
|
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-vue-next'
|
|
|
|
|
|
|
|
|
|
|
|
interface Toast {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
title?: string
|
|
|
|
|
|
message?: string
|
|
|
|
|
|
variant?: 'success' | 'error' | 'warning' | 'info'
|
|
|
|
|
|
duration?: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
toast: Toast
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
|
|
|
remove: []
|
|
|
|
|
|
}>()
|
|
|
|
|
|
|
|
|
|
|
|
const progress = ref(100)
|
|
|
|
|
|
let startTime = 0
|
|
|
|
|
|
let rafId: number | null = null
|
|
|
|
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
|
|
|
|
|
|
|
|
|
|
|
// 圆形进度环参数
|
|
|
|
|
|
const circumference = 2 * Math.PI * 14 // r=14
|
|
|
|
|
|
|
|
|
|
|
|
const strokeDashoffset = computed(() => {
|
|
|
|
|
|
return circumference * (1 - progress.value / 100)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const updateProgress = () => {
|
|
|
|
|
|
if (!props.toast.duration || props.toast.duration <= 0) return
|
|
|
|
|
|
|
|
|
|
|
|
const elapsed = Date.now() - startTime
|
|
|
|
|
|
const remaining = Math.max(0, 100 - (elapsed / props.toast.duration) * 100)
|
|
|
|
|
|
|
|
|
|
|
|
progress.value = remaining
|
|
|
|
|
|
|
|
|
|
|
|
if (remaining <= 0) {
|
|
|
|
|
|
emit('remove')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
rafId = requestAnimationFrame(updateProgress)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
if (props.toast.duration && props.toast.duration > 0) {
|
|
|
|
|
|
startTime = Date.now()
|
|
|
|
|
|
rafId = requestAnimationFrame(updateProgress)
|
|
|
|
|
|
// 保底 timeout,确保即使在后台也能移除
|
|
|
|
|
|
timeoutId = setTimeout(() => {
|
|
|
|
|
|
emit('remove')
|
|
|
|
|
|
}, props.toast.duration + 100)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (rafId) cancelAnimationFrame(rafId)
|
|
|
|
|
|
if (timeoutId) clearTimeout(timeoutId)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const icons = {
|
|
|
|
|
|
success: CheckCircle2,
|
|
|
|
|
|
error: XCircle,
|
|
|
|
|
|
warning: AlertTriangle,
|
|
|
|
|
|
info: Info
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const icon = computed(() => icons[props.toast.variant || 'info'])
|
|
|
|
|
|
|
|
|
|
|
|
const variantClasses = computed(() => {
|
|
|
|
|
|
const variant = props.toast.variant || 'info'
|
|
|
|
|
|
const classes: Record<string, string> = {
|
|
|
|
|
|
success: 'border-[#5F8D4E]/30 bg-white dark:bg-[var(--slate-dark)]',
|
|
|
|
|
|
error: 'border-[var(--error)]/30 bg-white dark:bg-[var(--slate-dark)]',
|
|
|
|
|
|
warning: 'border-[var(--book-cloth)]/30 bg-white dark:bg-[var(--slate-dark)]',
|
|
|
|
|
|
info: 'border-[var(--slate-medium)]/20 bg-white dark:bg-[var(--slate-dark)]'
|
|
|
|
|
|
}
|
|
|
|
|
|
return classes[variant]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const iconClasses = computed(() => {
|
|
|
|
|
|
const variant = props.toast.variant || 'info'
|
|
|
|
|
|
const classes: Record<string, string> = {
|
|
|
|
|
|
success: 'text-[#5F8D4E]',
|
|
|
|
|
|
error: 'text-[var(--error)]',
|
|
|
|
|
|
warning: 'text-[var(--book-cloth)]',
|
|
|
|
|
|
info: 'text-[var(--slate-medium)] dark:text-[var(--cloud-medium)]'
|
|
|
|
|
|
}
|
|
|
|
|
|
return classes[variant]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const progressColorClass = computed(() => {
|
|
|
|
|
|
const variant = props.toast.variant || 'info'
|
|
|
|
|
|
const classes: Record<string, string> = {
|
|
|
|
|
|
success: 'stroke-[#5F8D4E]',
|
|
|
|
|
|
error: 'stroke-[var(--error)]',
|
|
|
|
|
|
warning: 'stroke-[var(--book-cloth)]',
|
|
|
|
|
|
info: 'stroke-[var(--slate-medium)]'
|
|
|
|
|
|
}
|
|
|
|
|
|
return classes[variant]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const titleClasses = computed(() => {
|
|
|
|
|
|
return 'text-[var(--color-text)]'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const messageClasses = computed(() => {
|
|
|
|
|
|
return 'text-[var(--slate-medium)] dark:text-[var(--cloud-medium)]'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const closeClasses = computed(() => {
|
|
|
|
|
|
return 'text-[var(--slate-medium)] hover:bg-[var(--color-border-soft)] dark:text-[var(--cloud-medium)]'
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
</style>
|