2026-05-28 08:20:01 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-06-11 14:02:26 +08:00
|
|
|
|
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';
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
|
|
|
|
|
defineOptions({ name: 'NotificationBell' });
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
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;
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
function createListState(): MessageListState {
|
|
|
|
|
|
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
|
|
|
|
|
|
}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
const listStates = reactive<Record<TabKey, MessageListState>>({
|
|
|
|
|
|
unread: createListState(),
|
|
|
|
|
|
read: createListState()
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const unreadCount = ref(0);
|
2026-05-28 08:20:01 +08:00
|
|
|
|
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
|
|
|
|
|
|
|
|
|
|
|
const drawerOpen = ref(false);
|
2026-06-11 14:02:26 +08:00
|
|
|
|
const activeTab = ref<TabKey>('unread');
|
2026-05-28 08:20:01 +08:00
|
|
|
|
const searchKeyword = ref('');
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
function keywordParam() {
|
|
|
|
|
|
return searchKeyword.value.trim() || undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshUnreadCount() {
|
|
|
|
|
|
const { data, error } = await fetchGetUnreadNotifyCount();
|
|
|
|
|
|
if (!error && typeof data === 'number') {
|
|
|
|
|
|
unreadCount.value = data;
|
|
|
|
|
|
}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
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;
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
state.items.push(...data.list);
|
|
|
|
|
|
state.total = data.total;
|
|
|
|
|
|
state.pageNo += 1;
|
|
|
|
|
|
}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
function hasMore(tab: TabKey) {
|
|
|
|
|
|
const state = listStates[tab];
|
|
|
|
|
|
return state.loaded && state.items.length < state.total;
|
|
|
|
|
|
}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
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);
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
|
|
|
|
|
watch(searchKeyword, () => {
|
2026-06-11 14:02:26 +08:00
|
|
|
|
applyKeywordSearch();
|
2026-05-28 08:20:01 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
watch(activeTab, tab => {
|
|
|
|
|
|
ensureLoaded(tab);
|
|
|
|
|
|
});
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
|
|
|
|
|
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
|
|
|
|
|
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
|
|
|
|
|
const readScrollbar = ref<ScrollbarRefValue>(null);
|
|
|
|
|
|
|
|
|
|
|
|
useInfiniteScroll(
|
|
|
|
|
|
() => unreadScrollbar.value?.wrapRef,
|
|
|
|
|
|
() => {
|
2026-06-11 14:02:26 +08:00
|
|
|
|
if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
|
|
|
|
|
|
loadPage('unread');
|
|
|
|
|
|
}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{ distance: 48 }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
useInfiniteScroll(
|
|
|
|
|
|
() => readScrollbar.value?.wrapRef,
|
|
|
|
|
|
() => {
|
2026-06-11 14:02:26 +08:00
|
|
|
|
if (drawerOpen.value && hasMore('read') && !listStates.read.loading) {
|
|
|
|
|
|
loadPage('read');
|
|
|
|
|
|
}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{ distance: 48 }
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
function openDrawer() {
|
|
|
|
|
|
drawerOpen.value = true;
|
2026-06-11 14:02:26 +08:00
|
|
|
|
// 每次打开面板都从第 1 页重拉(与后端对齐的消费口径)
|
|
|
|
|
|
resetList('unread');
|
|
|
|
|
|
resetList('read');
|
|
|
|
|
|
loadPage(activeTab.value);
|
|
|
|
|
|
refreshUnreadCount();
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeDrawer() {
|
|
|
|
|
|
drawerOpen.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
function onDrawerClosed() {
|
|
|
|
|
|
searchKeyword.value = '';
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
async function markRead(item: Api.NotifyMessage.NotifyMessage) {
|
|
|
|
|
|
const { error } = await fetchUpdateNotifyMessageRead([item.id]);
|
|
|
|
|
|
if (error) return;
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
// 本地移除、不按原页号回拉,避免未读集合收缩导致的分页漂移
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
async function markAllRead() {
|
|
|
|
|
|
const { error } = await fetchUpdateAllNotifyMessageRead();
|
|
|
|
|
|
if (error) return;
|
|
|
|
|
|
|
|
|
|
|
|
unreadCount.value = 0;
|
|
|
|
|
|
resetList('unread');
|
|
|
|
|
|
resetList('read');
|
|
|
|
|
|
loadPage(activeTab.value);
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
2026-06-11 14:02:26 +08:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</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>
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<ElDrawer v-model="drawerOpen" size="480px" @closed="onDrawerClosed">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="notification-bell__header-main">
|
2026-05-28 08:20:01 +08:00
|
|
|
|
<span class="notification-bell__title">
|
|
|
|
|
|
通知
|
|
|
|
|
|
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
|
|
|
|
|
</span>
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-05-28 08:20:01 +08:00
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<div class="notification-bell__panel">
|
2026-05-28 08:20:01 +08:00
|
|
|
|
<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">
|
|
|
|
|
|
未读
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
|
2026-05-28 08:20:01 +08:00
|
|
|
|
<li
|
2026-06-11 14:02:26 +08:00
|
|
|
|
v-for="row in listStates.unread.items"
|
2026-05-28 08:20:01 +08:00
|
|
|
|
:key="row.id"
|
|
|
|
|
|
class="notification-bell__row is-unread"
|
2026-06-11 14:02:26 +08:00
|
|
|
|
@click="markRead(row)"
|
2026-05-28 08:20:01 +08:00
|
|
|
|
>
|
|
|
|
|
|
<span class="notification-bell__row-dot" />
|
|
|
|
|
|
<div class="notification-bell__row-body">
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
|
|
|
|
|
|
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<div v-else class="notification-bell__empty">
|
2026-06-11 14:02:26 +08:00
|
|
|
|
{{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</div>
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
|
|
|
|
|
|
{{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</ElScrollbar>
|
|
|
|
|
|
</ElTabPane>
|
|
|
|
|
|
|
|
|
|
|
|
<ElTabPane name="read">
|
|
|
|
|
|
<template #label>
|
|
|
|
|
|
<span class="notification-bell__tab-label">
|
|
|
|
|
|
已读
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<span class="notification-bell__tab-count">{{ listStates.read.total }}</span>
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<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">
|
2026-05-28 08:20:01 +08:00
|
|
|
|
<span class="notification-bell__row-dot" />
|
|
|
|
|
|
<div class="notification-bell__row-body">
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<div class="notification-bell__row-title">{{ row.templateContent }}</div>
|
|
|
|
|
|
<div class="notification-bell__row-time">{{ formatRelativeTime(row.createTime) }}</div>
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<div v-else class="notification-bell__empty">
|
2026-06-11 14:02:26 +08:00
|
|
|
|
{{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</div>
|
2026-06-11 14:02:26 +08:00
|
|
|
|
<div v-if="listStates.read.items.length > 0" class="notification-bell__footer-hint">
|
|
|
|
|
|
{{ listStates.read.loading ? '加载中…' : hasMore('read') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</ElScrollbar>
|
|
|
|
|
|
</ElTabPane>
|
|
|
|
|
|
</ElTabs>
|
|
|
|
|
|
</div>
|
2026-06-11 14:02:26 +08:00
|
|
|
|
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<ElButton @click="closeDrawer">关闭</ElButton>
|
|
|
|
|
|
</template>
|
2026-05-28 08:20:01 +08:00
|
|
|
|
</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;
|
2026-06-12 22:42:23 +08:00
|
|
|
|
top: 2px;
|
|
|
|
|
|
right: 2px;
|
|
|
|
|
|
min-width: 18px;
|
|
|
|
|
|
height: 18px;
|
|
|
|
|
|
padding: 0 5px;
|
|
|
|
|
|
border: 1px solid #fff;
|
2026-05-28 08:20:01 +08:00
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background-color: var(--el-color-danger);
|
|
|
|
|
|
color: #fff;
|
2026-06-12 22:42:23 +08:00
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: 700;
|
2026-05-28 08:20:01 +08:00
|
|
|
|
line-height: 16px;
|
|
|
|
|
|
text-align: center;
|
2026-06-12 22:42:23 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.notification-bell__panel {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
.notification-bell__header-main {
|
2026-05-28 08:20:01 +08:00
|
|
|
|
display: flex;
|
2026-06-11 14:02:26 +08:00
|
|
|
|
flex: 1;
|
2026-05-28 08:20:01 +08:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 12px;
|
2026-06-11 14:02:26 +08:00
|
|
|
|
min-width: 0;
|
|
|
|
|
|
margin-right: 8px;
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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 {
|
2026-06-11 14:02:26 +08:00
|
|
|
|
padding: 0 0 4px;
|
2026-05-28 08:20:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 14:02:26 +08:00
|
|
|
|
.notification-bell__row.is-unread {
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: background-color 120ms ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.notification-bell__row.is-unread:hover {
|
2026-05-28 08:20:01 +08:00
|
|
|
|
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>
|