Files
cn-rdms-web/src/layouts/modules/global-header/components/notification-bell.vue

645 lines
17 KiB
Vue
Raw 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.

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useDebounceFn, useInfiniteScroll } from '@vueuse/core';
import { NOTIFY_MESSAGE_LEVEL_DICT_CODE } from '@/constants/dict';
import {
fetchGetMyNotifyMessagePage,
fetchGetUnreadNotifyCount,
fetchUpdateAllNotifyMessageRead,
fetchUpdateNotifyMessageRead
} from '@/service/api';
import { useDictStore } from '@/store/modules/dict';
import { formatDateTime, formatRelativeTime } from '@/utils/datetime';
defineOptions({ name: 'NotificationBell' });
const dictStore = useDictStore();
const PAGE_SIZE = 10;
const UNREAD_COUNT_POLL_INTERVAL = 15 * 1000;
type TabKey = 'unread' | 'read';
interface MessageListState {
items: Api.NotifyMessage.NotifyMessage[];
pageNo: number;
total: number;
loading: boolean;
/** 是否已按当前关键字拉过第一页tab 懒加载 / 失效重拉用) */
loaded: boolean;
/** 竞态令牌:重置后递增,过期响应直接丢弃 */
token: number;
}
function createListState(): MessageListState {
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
}
const listStates = reactive<Record<TabKey, MessageListState>>({
unread: createListState(),
read: createListState()
});
const unreadCount = ref(0);
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
const drawerOpen = ref(false);
const activeTab = ref<TabKey>('unread');
const searchKeyword = ref('');
const detailVisible = ref(false);
const detailMessage = ref<Api.NotifyMessage.NotifyMessage | null>(null);
function keywordParam() {
return searchKeyword.value.trim() || undefined;
}
/** 列表圆点颜色:跟随消息等级(与等级徽标同一字典色源);取不到时回 undefined由 CSS 兜底 */
function levelDotColor(level: number) {
return dictStore.getDictItem(NOTIFY_MESSAGE_LEVEL_DICT_CODE, level)?.colorType ?? undefined;
}
async function refreshUnreadCount() {
const { data, error } = await fetchGetUnreadNotifyCount();
if (!error && typeof data === 'number') {
unreadCount.value = data;
}
}
function resetList(tab: TabKey) {
const state = listStates[tab];
state.token += 1;
state.items = [];
state.pageNo = 1;
state.total = 0;
state.loading = false;
state.loaded = false;
}
async function loadPage(tab: TabKey) {
const state = listStates[tab];
if (state.loading) return;
const token = state.token;
state.loading = true;
const { data, error } = await fetchGetMyNotifyMessagePage({
pageNo: state.pageNo,
pageSize: PAGE_SIZE,
readStatus: tab === 'read',
keyword: keywordParam()
});
if (token !== state.token) return;
state.loading = false;
state.loaded = true;
if (error || !data) return;
state.items.push(...data.list);
state.total = data.total;
state.pageNo += 1;
}
function hasMore(tab: TabKey) {
const state = listStates[tab];
return state.loaded && state.items.length < state.total;
}
function ensureLoaded(tab: TabKey) {
const state = listStates[tab];
if (!state.loaded && !state.loading) {
loadPage(tab);
}
}
const applyKeywordSearch = useDebounceFn(() => {
if (!drawerOpen.value) return;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}, 300);
watch(searchKeyword, () => {
applyKeywordSearch();
});
watch(activeTab, tab => {
ensureLoaded(tab);
});
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
const unreadScrollbar = ref<ScrollbarRefValue>(null);
const readScrollbar = ref<ScrollbarRefValue>(null);
useInfiniteScroll(
() => unreadScrollbar.value?.wrapRef,
() => {
if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
loadPage('unread');
}
},
{ distance: 48 }
);
useInfiniteScroll(
() => readScrollbar.value?.wrapRef,
() => {
if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
loadPage('read');
}
},
{ distance: 48 }
);
function openDrawer() {
drawerOpen.value = true;
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
resetList('unread');
resetList('read');
loadPage(activeTab.value);
refreshUnreadCount();
}
function closeDrawer() {
drawerOpen.value = false;
}
function onDrawerClosed() {
searchKeyword.value = '';
}
async function markRead(item: Api.NotifyMessage.NotifyMessage) {
const { error } = await fetchUpdateNotifyMessageRead([item.id]);
if (error) return;
// 本地移除、不按原页号回拉,避免未读集合收缩导致的分页漂移
const state = listStates.unread;
const index = state.items.findIndex(row => row.id === item.id);
if (index >= 0) {
state.items.splice(index, 1);
state.total = Math.max(0, state.total - 1);
}
unreadCount.value = Math.max(0, unreadCount.value - 1);
// 已读列表失效,下次进入已读 tab 时从第 1 页重拉
resetList('read');
// 移除后剩余条目不足一页且还有更多时补拉,防止列表不再触发滚动加载
if (state.items.length < PAGE_SIZE && hasMore('unread')) {
loadPage('unread');
}
}
function openDetail(row: Api.NotifyMessage.NotifyMessage) {
// 弹框持有该行引用,正文不随未读列表移除而消失
detailMessage.value = row;
detailVisible.value = true;
// 未读消息「打开即已读」:后台静默标记,避免"看一半就跑到已读"
if (!row.readStatus) {
markRead(row);
}
}
async function markAllRead() {
const { error } = await fetchUpdateAllNotifyMessageRead();
if (error) return;
unreadCount.value = 0;
resetList('unread');
resetList('read');
loadPage(activeTab.value);
}
let pollTimer: ReturnType<typeof setInterval> | null = null;
onMounted(() => {
// 等级徽标颜色/文案走字典:若未在登录缓存内则按编码补拉一次(已缓存时不发请求)
dictStore.ensureDictData(NOTIFY_MESSAGE_LEVEL_DICT_CODE);
refreshUnreadCount();
pollTimer = setInterval(() => {
if (document.hidden) return;
refreshUnreadCount();
}, UNREAD_COUNT_POLL_INTERVAL);
});
onBeforeUnmount(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template>
<button
class="notification-bell__trigger"
type="button"
:aria-label="unreadCount > 0 ? `通知,${unreadCount} 条未读` : '通知'"
@click="openDrawer"
>
<SvgIcon icon="mdi:bell-outline" class="notification-bell__icon" />
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
</button>
<ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
<template #header>
<div class="notification-bell__header-main">
<span class="notification-bell__title">
通知
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
</span>
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
</div>
</template>
<div class="notification-bell__panel">
<div class="notification-bell__search">
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
<template #prefix>
<SvgIcon icon="mdi:magnify" />
</template>
</ElInput>
</div>
<ElTabs v-model="activeTab" class="notification-bell__tabs">
<ElTabPane name="unread">
<template #label>
<span class="notification-bell__tab-label">
未读
<span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
</span>
</template>
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
<ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
<li
v-for="row in listStates.unread.items"
:key="row.id"
class="notification-bell__row is-unread"
@click="openDetail(row)"
>
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
</div>
<div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
{{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
<ElTabPane name="read">
<template #label>
<span class="notification-bell__tab-label">已读</span>
</template>
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
<ul v-if="listStates.read.items.length > 0" class="notification-bell__list">
<li
v-for="row in listStates.read.items"
:key="row.id"
class="notification-bell__row"
@click="openDetail(row)"
>
<span class="notification-bell__row-dot" :style="{ backgroundColor: levelDotColor(row.level) }" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-meta">
<DictTag :dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE" :value="row.level" size="small" round />
<span class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</span>
</div>
</div>
</li>
</ul>
<div v-else class="notification-bell__empty">
{{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
</div>
<div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
{{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
</div>
</ElScrollbar>
</ElTabPane>
</ElTabs>
</div>
<template #footer>
<ElButton @click="closeDrawer">关闭</ElButton>
</template>
</ElDrawer>
<ElDialog v-model="detailVisible" width="520px" align-center class="notification-bell__detail">
<template #header>
<div class="notification-bell__detail-head">
<span class="notification-bell__detail-sender">{{ detailMessage?.templateNickname || '系统通知' }}</span>
<DictTag
v-if="detailMessage"
:dict-code="NOTIFY_MESSAGE_LEVEL_DICT_CODE"
:value="detailMessage.level"
size="small"
round
/>
</div>
</template>
<div v-if="detailMessage" class="notification-bell__detail-body">
<div class="notification-bell__detail-content">{{ detailMessage.templateContent }}</div>
<div class="notification-bell__detail-time">收到于 {{ formatDateTime(detailMessage.createTime) }}</div>
</div>
<template #footer>
<ElButton @click="detailVisible = false">关闭</ElButton>
</template>
</ElDialog>
</template>
<style scoped>
.notification-bell__trigger {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
margin: 0 4px;
border: none;
border-radius: 8px;
background-color: transparent;
color: var(--el-text-color-regular);
cursor: pointer;
transition:
background-color 160ms ease,
color 160ms ease;
}
.notification-bell__trigger:hover {
background-color: var(--el-fill-color-light);
color: var(--el-color-primary);
}
.notification-bell__trigger:focus-visible {
outline: 2px solid var(--el-color-primary);
outline-offset: 2px;
}
.notification-bell__icon {
font-size: 20px;
}
.notification-bell__badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border: 1px solid #fff;
border-radius: 999px;
background-color: var(--el-color-danger);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 16px;
text-align: center;
animation: notification-badge-pulse 1.6s ease-in-out infinite;
}
/* 扩散波纹:跟随心跳节奏向外晕开,增强未读提醒的醒目度 */
.notification-bell__badge::before {
content: '';
position: absolute;
inset: -1px;
border-radius: 999px;
background-color: var(--el-color-danger);
animation: notification-badge-ping 1.6s ease-out infinite;
z-index: -1;
}
@keyframes notification-badge-pulse {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.18);
}
}
@keyframes notification-badge-ping {
0% {
transform: scale(1);
opacity: 0.6;
}
70%,
100% {
transform: scale(1.9);
opacity: 0;
}
}
.notification-bell__panel {
display: flex;
flex-direction: column;
height: 100%;
}
.notification-bell__header-main {
display: flex;
flex: 1;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
margin-right: 8px;
}
.notification-bell__title {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
}
.notification-bell__title-count {
padding: 1px 8px;
border-radius: 999px;
background-color: var(--el-color-danger);
color: #fff;
font-size: 11px;
font-weight: 600;
}
.notification-bell__search {
padding: 0 0 4px;
}
.notification-bell__tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.notification-bell__tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
}
.notification-bell__tabs :deep(.el-tab-pane) {
height: 100%;
}
.notification-bell__tab-label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.notification-bell__tab-count {
padding: 0 7px;
border-radius: 999px;
background-color: var(--el-fill-color);
color: var(--el-text-color-secondary);
font-size: 10px;
font-weight: 600;
line-height: 16px;
}
.notification-bell__tabs :deep(.el-tabs__item.is-active) .notification-bell__tab-count {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.notification-bell__scroll {
height: 100%;
}
.notification-bell__list {
margin: 0;
padding: 0;
list-style: none;
}
.notification-bell__row {
position: relative;
display: grid;
grid-template-columns: 14px minmax(0, 1fr);
align-items: flex-start;
gap: 10px;
padding: 12px 4px;
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease;
}
.notification-bell__row + .notification-bell__row {
border-top: 1px dashed var(--el-border-color-lighter);
}
.notification-bell__row:hover {
background-color: var(--el-fill-color-light);
}
.notification-bell__row-dot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 50%;
background-color: transparent;
justify-self: center;
}
.notification-bell__row.is-unread .notification-bell__row-dot {
background-color: var(--el-color-primary);
}
.notification-bell__row-body {
min-width: 0;
}
.notification-bell__row-title {
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.5;
}
.notification-bell__row.is-unread .notification-bell__row-title {
color: var(--el-text-color-primary);
font-weight: 500;
}
.notification-bell__row-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.notification-bell__row-time {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.notification-bell__empty {
padding: 48px 16px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.notification-bell__footer-hint {
padding: 12px 0 4px;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 12px;
user-select: none;
}
.notification-bell__detail-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.notification-bell__detail-head {
display: flex;
align-items: center;
gap: 10px;
padding-right: 8px;
min-width: 0;
}
.notification-bell__detail-sender {
min-width: 0;
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-bell__detail-content {
color: var(--el-text-color-regular);
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
}
.notification-bell__detail-time {
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>