新版官网模板
This commit is contained in:
383
app/components/NotificationBell.vue
Normal file
383
app/components/NotificationBell.vue
Normal file
@@ -0,0 +1,383 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user