notification.vue 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. <script lang="ts" setup>
  2. import type { NotificationItem } from './types';
  3. import { useRouter } from 'vue-router';
  4. import { Bell, CircleCheckBig, CircleX, MailCheck } from '@vben/icons';
  5. import { $t } from '@vben/locales';
  6. import {
  7. VbenButton,
  8. VbenIconButton,
  9. VbenPopover,
  10. VbenScrollbar,
  11. } from '@vben-core/shadcn-ui';
  12. import { useToggle } from '@vueuse/core';
  13. interface Props {
  14. /**
  15. * 显示圆点
  16. */
  17. dot?: boolean;
  18. /**
  19. * 消息列表
  20. */
  21. notifications?: NotificationItem[];
  22. }
  23. defineOptions({ name: 'NotificationPopup' });
  24. withDefaults(defineProps<Props>(), {
  25. dot: false,
  26. notifications: () => [],
  27. });
  28. const emit = defineEmits<{
  29. clear: [];
  30. makeAll: [];
  31. read: [NotificationItem];
  32. remove: [NotificationItem];
  33. viewAll: [];
  34. }>();
  35. const router = useRouter();
  36. const [open, toggle] = useToggle();
  37. function close() {
  38. open.value = false;
  39. }
  40. function handleViewAll() {
  41. emit('viewAll');
  42. close();
  43. }
  44. function handleMakeAll() {
  45. emit('makeAll');
  46. }
  47. function handleClear() {
  48. emit('clear');
  49. }
  50. function handleClick(item: NotificationItem) {
  51. // 如果通知项有链接,点击时跳转
  52. if (item.link) {
  53. navigateTo(item.link, item.query, item.state);
  54. }
  55. }
  56. function navigateTo(
  57. link: string,
  58. query?: Record<string, any>,
  59. state?: Record<string, any>,
  60. ) {
  61. if (link.startsWith('http://') || link.startsWith('https://')) {
  62. // 外部链接,在新标签页打开
  63. window.open(link, '_blank');
  64. } else {
  65. // 内部路由链接,支持 query 参数和 state
  66. router.push({
  67. path: link,
  68. query: query || {},
  69. state,
  70. });
  71. }
  72. }
  73. </script>
  74. <template>
  75. <VbenPopover
  76. v-model:open="open"
  77. content-class="relative right-2 w-90 p-0"
  78. >
  79. <template #trigger>
  80. <div class="mr-2 flex-center h-full" @click.stop="toggle()">
  81. <VbenIconButton class="bell-button relative text-foreground">
  82. <span
  83. v-if="dot"
  84. class="absolute top-0.5 right-0.5 size-2 rounded-sm bg-primary"
  85. ></span>
  86. <Bell class="size-4" />
  87. </VbenIconButton>
  88. </div>
  89. </template>
  90. <div class="relative">
  91. <div class="flex items-center justify-between p-4 py-3">
  92. <div class="text-foreground">{{ $t('ui.widgets.notifications') }}</div>
  93. <VbenIconButton
  94. :disabled="notifications.length <= 0"
  95. :tooltip="$t('ui.widgets.markAllAsRead')"
  96. @click="handleMakeAll"
  97. >
  98. <MailCheck class="size-4" />
  99. </VbenIconButton>
  100. </div>
  101. <VbenScrollbar v-if="notifications.length > 0">
  102. <ul class="flex! max-h-90 w-full flex-col">
  103. <template v-for="item in notifications" :key="item.id ?? item.title">
  104. <li
  105. class="relative flex w-full cursor-pointer items-start gap-5 border-t border-border p-3 hover:bg-accent"
  106. @click="handleClick(item)"
  107. >
  108. <span
  109. v-if="!item.isRead"
  110. class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
  111. ></span>
  112. <span
  113. class="relative flex size-10 shrink-0 overflow-hidden rounded-full"
  114. >
  115. <img
  116. :src="item.avatar"
  117. class="aspect-square size-full object-cover"
  118. />
  119. </span>
  120. <div class="flex flex-col gap-1 leading-none">
  121. <p class="font-semibold">{{ item.title }}</p>
  122. <p class="my-1 line-clamp-2 text-xs text-muted-foreground">
  123. {{ item.message }}
  124. </p>
  125. <p class="line-clamp-2 text-xs text-muted-foreground">
  126. {{ item.date }}
  127. </p>
  128. </div>
  129. <div
  130. class="absolute top-1/2 right-3 flex -translate-y-1/2 flex-col gap-2"
  131. >
  132. <VbenIconButton
  133. v-if="!item.isRead"
  134. size="xs"
  135. variant="ghost"
  136. class="h-6 px-2"
  137. :tooltip="$t('common.confirm')"
  138. @click.stop="emit('read', item)"
  139. >
  140. <CircleCheckBig class="size-4" />
  141. </VbenIconButton>
  142. <VbenIconButton
  143. v-if="item.isRead"
  144. size="xs"
  145. variant="ghost"
  146. class="h-6 px-2 text-destructive"
  147. :tooltip="$t('common.delete')"
  148. @click.stop="emit('remove', item)"
  149. >
  150. <CircleX class="size-4" />
  151. </VbenIconButton>
  152. </div>
  153. </li>
  154. </template>
  155. </ul>
  156. </VbenScrollbar>
  157. <template v-else>
  158. <div class="flex-center min-h-37.5 w-full text-muted-foreground">
  159. {{ $t('common.noData') }}
  160. </div>
  161. </template>
  162. <div
  163. class="flex items-center justify-between border-t border-border px-4 py-3"
  164. >
  165. <VbenButton
  166. :disabled="notifications.length <= 0"
  167. size="sm"
  168. variant="ghost"
  169. @click="handleClear"
  170. >
  171. {{ $t('ui.widgets.clearNotifications') }}
  172. </VbenButton>
  173. <VbenButton size="sm" @click="handleViewAll">
  174. {{ $t('ui.widgets.viewAll') }}
  175. </VbenButton>
  176. </div>
  177. </div>
  178. </VbenPopover>
  179. </template>
  180. <style scoped>
  181. :deep(.bell-button) {
  182. &:hover {
  183. svg {
  184. animation: bell-ring 1s both;
  185. }
  186. }
  187. }
  188. @keyframes bell-ring {
  189. 0%,
  190. 100% {
  191. transform-origin: top;
  192. }
  193. 15% {
  194. transform: rotateZ(10deg);
  195. }
  196. 30% {
  197. transform: rotateZ(-10deg);
  198. }
  199. 45% {
  200. transform: rotateZ(5deg);
  201. }
  202. 60% {
  203. transform: rotateZ(-5deg);
  204. }
  205. 75% {
  206. transform: rotateZ(2deg);
  207. }
  208. }
  209. </style>