516 lines
13 KiB
Vue
516 lines
13 KiB
Vue
<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>
|