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