EDMS/components/base/NotificationDisplay.vue
2025-06-05 14:57:08 +08:00

292 lines
8.9 KiB
Vue

<script setup>
import { computed } from 'vue';
import { useDesignSystem } from '~/composables/useDesignSystem';
import { useNotifications } from '~/composables/useNotifications';
import RsButton from '~/components/RsButton.vue';
const { statusColors, utils, animations } = useDesignSystem();
const { notifications, removeNotification } = useNotifications();
// Position classes for different notification positions
const positionClasses = {
'top-right': 'fixed top-4 right-4 z-50',
'top-left': 'fixed top-4 left-4 z-50',
'top-center': 'fixed top-4 left-1/2 transform -translate-x-1/2 z-50',
'bottom-right': 'fixed bottom-4 right-4 z-50',
'bottom-left': 'fixed bottom-4 left-4 z-50',
'bottom-center': 'fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50'
};
// Get notification classes based on type
const getNotificationClasses = (notification) => {
const baseClasses = [
'notification-item',
'max-w-sm w-full',
'bg-white dark:bg-gray-800',
'border border-gray-200 dark:border-gray-600',
'rounded-lg shadow-lg',
'p-4 mb-3',
'transform transition-all duration-300 ease-in-out'
];
// Add type-specific styling
if (notification.type && statusColors[notification.type]) {
baseClasses.push(`border-l-4 border-l-${notification.type === 'success' ? 'green' :
notification.type === 'error' ? 'red' :
notification.type === 'warning' ? 'yellow' : 'blue'}-500`);
}
return utils.composeClasses(...baseClasses);
};
// Get icon for notification type
const getTypeIcon = (type) => {
switch (type) {
case 'success': return 'mdi:check-circle';
case 'error': return 'mdi:alert-circle';
case 'warning': return 'mdi:alert';
case 'info': return 'mdi:information';
case 'loading': return 'mdi:loading';
case 'confirm': return 'mdi:help-circle';
default: return 'mdi:information';
}
};
// Group notifications by position
const notificationsByPosition = computed(() => {
const groups = {};
notifications.value.forEach(notification => {
const position = notification.position || 'top-right';
if (!groups[position]) {
groups[position] = [];
}
groups[position].push(notification);
});
return groups;
});
// Handle action click
const handleActionClick = (action, notification) => {
if (action.action) {
action.action();
}
};
// Auto-remove timer display
const getTimeRemaining = (notification) => {
if (notification.persistent || !notification.duration) return null;
const elapsed = Date.now() - notification.startTime;
const remaining = Math.max(0, notification.duration - elapsed);
return Math.ceil(remaining / 1000);
};
</script>
<template>
<div class="notification-container">
<!-- Render notification groups by position -->
<div
v-for="(notificationList, position) in notificationsByPosition"
:key="position"
:class="positionClasses[position]"
>
<TransitionGroup
:enter-active-class="animations.transitions.slideDown.enter"
:enter-from-class="animations.transitions.slideDown.enterFrom"
:enter-to-class="animations.transitions.slideDown.enterTo"
:leave-active-class="animations.transitions.fadeIn.leave"
:leave-from-class="animations.transitions.fadeIn.leaveFrom"
:leave-to-class="animations.transitions.fadeIn.leaveTo"
tag="div"
class="space-y-3"
>
<div
v-for="notification in notificationList"
:key="notification.id"
:class="getNotificationClasses(notification)"
>
<!-- Main Content -->
<div class="flex items-start space-x-3">
<!-- Icon -->
<div
v-if="notification.showIcon"
class="flex-shrink-0 mt-0.5"
>
<Icon
:name="notification.icon || getTypeIcon(notification.type)"
:class="[
'w-5 h-5',
notification.type === 'loading' ? 'animate-spin' : '',
notification.type === 'success' ? 'text-green-500' :
notification.type === 'error' ? 'text-red-500' :
notification.type === 'warning' ? 'text-yellow-500' :
notification.type === 'info' ? 'text-blue-500' :
'text-gray-500 dark:text-gray-400'
]"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<!-- Title -->
<h4
v-if="notification.title"
class="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1"
>
{{ notification.title }}
</h4>
<!-- Message -->
<div class="text-sm text-gray-700 dark:text-gray-300">
<p v-if="!notification.allowHtml">{{ notification.message }}</p>
<div v-else v-html="notification.message"></div>
</div>
<!-- Actions for confirmation type -->
<div
v-if="notification.type === 'confirm' && notification.actions"
class="flex items-center space-x-2 mt-3"
>
<RsButton
v-for="action in notification.actions"
:key="action.label"
:variant="action.variant || 'secondary-outline'"
size="sm"
@click="handleActionClick(action, notification)"
>
{{ action.label }}
</RsButton>
</div>
<!-- Progress bar for loading -->
<div
v-if="notification.type === 'loading' && notification.progress !== undefined"
class="mt-3"
>
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400 mb-1">
<span>Progress</span>
<span>{{ notification.progress }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-primary h-2 rounded-full transition-all duration-300"
:style="{ width: `${notification.progress}%` }"
></div>
</div>
</div>
<!-- Time remaining indicator -->
<div
v-if="!notification.persistent && notification.duration && getTimeRemaining(notification)"
class="mt-2 text-xs text-gray-400 dark:text-gray-500"
>
Auto-close in {{ getTimeRemaining(notification) }}s
</div>
</div>
<!-- Close button -->
<button
v-if="notification.showClose"
@click="removeNotification(notification.id)"
class="flex-shrink-0 p-1 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors duration-200"
aria-label="Close notification"
>
<Icon name="mdi:close" class="w-4 h-4" />
</button>
</div>
</div>
</TransitionGroup>
</div>
</div>
</template>
<style scoped>
.notification-container {
pointer-events: none;
}
.notification-item {
pointer-events: auto;
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Enhanced animations */
.notification-enter-active {
transition: all 0.3s ease-out;
}
.notification-leave-active {
transition: all 0.3s ease-in;
}
.notification-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
.notification-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.95);
}
/* Position-specific enter animations */
.top-left .notification-enter-from,
.bottom-left .notification-enter-from {
transform: translateX(-100%) scale(0.95);
}
.top-left .notification-leave-to,
.bottom-left .notification-leave-to {
transform: translateX(-100%) scale(0.95);
}
.top-center .notification-enter-from,
.bottom-center .notification-enter-from {
transform: translateY(-20px) scale(0.95);
}
.top-center .notification-leave-to,
.bottom-center .notification-leave-to {
transform: translateY(-20px) scale(0.95);
}
/* Mobile responsive */
@media (max-width: 640px) {
.notification-item {
@apply max-w-[calc(100vw-2rem)] mx-4;
}
/* Adjust positioning for mobile */
.fixed.top-4.right-4,
.fixed.top-4.left-4 {
@apply top-2 right-2 left-2;
}
.fixed.bottom-4.right-4,
.fixed.bottom-4.left-4 {
@apply bottom-2 right-2 left-2;
}
}
/* Dark mode enhancements */
.dark .notification-item {
background: rgba(31, 41, 55, 0.95);
border-color: rgba(75, 85, 99, 0.3);
}
/* Loading spinner animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Progress bar animation */
.progress-bar {
transition: width 0.3s ease-in-out;
}
</style>