mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-11 12:08:30 +08:00
- 更新 Logo 相关组件 (AetherLogo, HeaderLogo, RippleLogo 等) - 优化图表组件 (BarChart, LineChart, ScatterChart) - 改进公共组件 (AlertDialog, EmptyState, LoadingState) - 调整布局组件 (AppShell, SidebarNav, PageHeader 等) - 优化 ActivityHeatmap 统计组件
200 lines
4.9 KiB
Vue
200 lines
4.9 KiB
Vue
<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>
|