Files
template-nuxt4/app/components/NotificationBell.vue
2026-04-29 01:33:33 +08:00

384 lines
8.3 KiB
Vue
Raw Permalink 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>
<a-popover
v-model:open="popoverVisible"
:arrow="true"
:overlay-class-name="`notification-popover ${theme}`"
placement="bottomRight"
trigger="click"
@openChange="onPopoverChange"
>
<template #content>
<div class="bell-dropdown">
<!-- 头部标题 + 全部已读 -->
<div class="dropdown-header">
<span class="dropdown-title">消息通知</span>
<a-button
v-if="unreadTotal > 0"
:loading="markAllLoading"
class="mark-all-btn"
size="small"
type="link"
@click="handleMarkAll"
>
全部已读
</a-button>
</div>
<!-- Tab 过滤 -->
<div class="dropdown-tabs">
<a-radio-group v-model:value="activeType" button-style="solid" size="small" @change="handleTypeChange">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="ticket">工单</a-radio-button>
<a-radio-button value="system">系统</a-radio-button>
<a-radio-button value="review">审核</a-radio-button>
</a-radio-group>
</div>
<!-- 通知列表 -->
<div class="dropdown-list" @scroll="onListScroll">
<a-spin :spinning="loading">
<template v-if="filteredRecentList.length">
<div
v-for="item in filteredRecentList"
:key="item.id"
:class="{ unread: !item.isRead }"
class="notification-item"
@click="handleItemClick(item)"
>
<div class="item-icon">
{{ notificationTypeMap[item.type!]?.icon || '📢' }}
</div>
<div class="item-body">
<div class="item-title">{{ item.title }}</div>
<div class="item-content">{{ item.content }}</div>
<div class="item-meta">
<span class="item-time">{{ formatTime(item.createTime) }}</span>
<span v-if="item.senderName" class="item-sender">{{ item.senderName }}</span>
</div>
</div>
<div v-if="!item.isRead" class="item-dot" />
</div>
</template>
<a-empty v-else :image-style="{ height: '60px' }" description="暂无消息" />
</a-spin>
</div>
<!-- 底部查看全部 -->
<div class="dropdown-footer">
<a-button block type="link" @click="goToAll">
查看全部通知
<RightOutlined />
</a-button>
</div>
</div>
</template>
<!-- 铃铛触发器 -->
<div :class="[theme]" class="bell-trigger" @click.stop>
<a-badge :count="unreadTotal" :dot="false" :overflow-count="99" size="small">
<BellOutlined class="bell-icon" />
</a-badge>
</div>
</a-popover>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { BellOutlined, RightOutlined } from '@ant-design/icons-vue'
import { useNotificationCenter, notificationTypeMap } from '@/composables/useNotificationCenter'
import type { Notification, NotificationType } from '@/api/app/notification/model'
const props = withDefaults(defineProps<{
/** 主题light 用于 console 白色头部dark 用于 SiteHeader 暗色头部 */
theme?: 'light' | 'dark'
}>(), {
theme: 'light',
})
const {
unreadTotal,
recentList,
loading,
fetchUnreadCount,
fetchRecentNotifications,
markNotificationRead,
markAllAsRead,
startPolling,
stopPolling,
formatTime,
getNotificationLink,
} = useNotificationCenter()
const popoverVisible = ref(false)
const activeType = ref('')
const markAllLoading = ref(false)
// 过滤后的最近通知
const filteredRecentList = computed(() => {
if (!activeType.value) return recentList.value
return recentList.value.filter((n) => n.type === activeType.value)
})
function onPopoverChange(visible: boolean) {
if (visible) {
fetchRecentNotifications()
}
}
function handleTypeChange() {
// 切换类型时重新加载
fetchRecentNotifications()
}
async function handleItemClick(item: Notification) {
// 标记已读
if (!item.isRead && item.id) {
await markNotificationRead(item.id)
}
// 关闭弹窗
popoverVisible.value = false
// 跳转
const link = getNotificationLink(item)
navigateTo(link)
}
async function handleMarkAll() {
markAllLoading.value = true
try {
const type = activeType.value || undefined
await markAllAsRead(type)
} finally {
markAllLoading.value = false
}
}
function goToAll() {
popoverVisible.value = false
navigateTo('/console/notifications')
}
function onListScroll() {
// 暂不实现无限滚动,铃铛下拉只展示最近 10 条
}
onMounted(() => {
startPolling()
})
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped>
/* ===== 铃铛触发器 ===== */
.bell-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 9999px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
position: relative;
}
.bell-trigger.light {
color: #374151;
}
.bell-trigger.light:hover {
background: rgba(0, 0, 0, 0.04);
}
.bell-trigger.dark {
color: rgba(255, 255, 255, 0.85);
}
.bell-trigger.dark:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.bell-icon {
font-size: 18px;
}
/* ===== 下拉面板 ===== */
.bell-dropdown {
width: 380px;
max-height: 520px;
display: flex;
flex-direction: column;
}
.dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px 8px;
border-bottom: 1px solid #f0f0f0;
}
.dropdown-title {
font-size: 15px;
font-weight: 600;
color: rgba(0, 0, 0, 0.88);
}
.mark-all-btn {
font-size: 12px;
padding: 0;
height: auto;
}
.dropdown-tabs {
padding: 8px 16px 4px;
}
.dropdown-tabs :deep(.ant-radio-group) {
width: 100%;
}
.dropdown-tabs :deep(.ant-radio-button-wrapper) {
flex: 1;
text-align: center;
font-size: 12px;
padding: 0 4px;
height: 28px;
line-height: 26px;
}
/* ===== 通知列表 ===== */
.dropdown-list {
flex: 1;
overflow-y: auto;
max-height: 360px;
padding: 4px 0;
}
.dropdown-list::-webkit-scrollbar {
width: 4px;
}
.dropdown-list::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.12);
border-radius: 4px;
}
.notification-item {
display: flex;
gap: 10px;
padding: 10px 16px;
cursor: pointer;
transition: background 0.15s;
position: relative;
}
.notification-item:hover {
background: rgba(0, 0, 0, 0.02);
}
.notification-item.unread {
background: rgba(24, 144, 255, 0.03);
}
.notification-item.unread::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
background: #1890ff;
border-radius: 0 3px 3px 0;
}
.item-icon {
flex-shrink: 0;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
border-radius: 10px;
font-size: 16px;
}
.item-body {
flex: 1;
min-width: 0;
}
.item-title {
font-size: 13px;
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-item.unread .item-title {
font-weight: 600;
}
.item-content {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: 2px;
}
.item-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
font-size: 11px;
color: rgba(0, 0, 0, 0.35);
}
.item-dot {
flex-shrink: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background: #1890ff;
margin-top: 14px;
}
/* ===== 底部 ===== */
.dropdown-footer {
padding: 6px 16px;
border-top: 1px solid #f0f0f0;
}
.dropdown-footer :deep(.ant-btn-link) {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
padding: 4px 0;
}
.dropdown-footer :deep(.ant-btn-link:hover) {
color: #1890ff;
}
</style>
<style>
/* 暗色主题下的 popover 适配 */
.notification-popover.dark .ant-popover-inner {
background: #1f2937;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
}
.notification-popover.dark .ant-popover-arrow::before {
background: #1f2937;
}
</style>