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

516 lines
13 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 {
fetchGetMyNotifyMessagePage,
fetchGetUnreadNotifyCount,
fetchUpdateAllNotifyMessageRead,
fetchUpdateNotifyMessageRead
} from '@/service/api';
import { formatRelativeTime } from '@/utils/datetime';
defineOptions({ name: 'NotificationBell' });
const PAGE_SIZE = 10;
const UNREAD_COUNT_POLL_INTERVAL = 30 * 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('');
function keywordParam() {
return searchKeyword.value.trim() || 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');
}
}
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(() => {
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="markRead(row)"
>
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</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 class="notification-bell__tab-count">{{ listStates.read.total }}</span>
</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">
<span class="notification-bell__row-dot" />
<div class="notification-bell__row-body">
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</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>
</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: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background-color: var(--el-color-danger);
color: #fff;
font-size: 10px;
font-weight: 600;
line-height: 16px;
text-align: center;
}
.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;
}
.notification-bell__row + .notification-bell__row {
border-top: 1px dashed var(--el-border-color-lighter);
}
.notification-bell__row.is-unread {
cursor: pointer;
transition: background-color 120ms ease;
}
.notification-bell__row.is-unread: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-time {
margin-top: 4px;
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;
}
</style>