Files
Aether/frontend/src/components/ToastWithProgress.vue
fawney19 e9a6233655 refactor(frontend): 优化公共组件和布局组件
- 更新 Logo 相关组件 (AetherLogo, HeaderLogo, RippleLogo 等)
- 优化图表组件 (BarChart, LineChart, ScatterChart)
- 改进公共组件 (AlertDialog, EmptyState, LoadingState)
- 调整布局组件 (AppShell, SidebarNav, PageHeader 等)
- 优化 ActivityHeatmap 统计组件
2025-12-12 16:15:16 +08:00

200 lines
4.9 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>
<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>
<!-- 图标 -->
<div
class="absolute inset-0 flex items-center justify-center"
:class="iconClasses"
>
<component
:is="icon"
class="w-4 h-4"
/>
</div>
</div>
<!-- 内容 -->
<div class="flex-1 min-w-0">
<p
v-if="toast.title"
class="text-sm font-medium"
:class="titleClasses"
>
{{ toast.title }}
</p>
<p
v-if="toast.message"
class="text-sm"
:class="messageClasses"
>
{{ toast.message }}
</p>
</div>
<!-- 关闭按钮 -->
<button
class="shrink-0 p-1 rounded transition-colors opacity-40 hover:opacity-100"
:class="closeClasses"
type="button"
aria-label="关闭"
@click="$emit('remove')"
>
<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>