458 lines
12 KiB
Vue
458 lines
12 KiB
Vue
|
|
<script setup lang="ts">
|
|||
|
|
import { computed, ref, watch } from 'vue';
|
|||
|
|
import { useInfiniteScroll } from '@vueuse/core';
|
|||
|
|
|
|||
|
|
defineOptions({ name: 'NotificationBell' });
|
|||
|
|
|
|||
|
|
interface NotificationItem {
|
|||
|
|
id: string;
|
|||
|
|
title: string;
|
|||
|
|
timeLabel: string;
|
|||
|
|
unread: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const PAGE_SIZE = 10;
|
|||
|
|
|
|||
|
|
// 通知 mock:扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
|
|||
|
|
function buildMockNotifications(): NotificationItem[] {
|
|||
|
|
const titles = [
|
|||
|
|
'你被指派为执行「迭代 24.06」负责人',
|
|||
|
|
'任务「SSO 改造」状态变更:开发中 → 待验收',
|
|||
|
|
'需求「多币种支持」评审通过',
|
|||
|
|
'工单 #1042 已分派给你',
|
|||
|
|
'需求「订单导出」被退回,请补充材料',
|
|||
|
|
'@ 你的评论已被回复',
|
|||
|
|
'项目「客户中心 2.0」周报已生成',
|
|||
|
|
'工单 #1098 客户回复待处理',
|
|||
|
|
'执行「迭代 24.05」已结束',
|
|||
|
|
'需求「批量审批」分配给你'
|
|||
|
|
];
|
|||
|
|
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
|
|||
|
|
return Array.from({ length: 60 }, (_, i) => ({
|
|||
|
|
id: `m${i + 1}`,
|
|||
|
|
title: `${titles[i % titles.length]}(#${i + 1})`,
|
|||
|
|
timeLabel: times[Math.floor(i / 6) % times.length],
|
|||
|
|
unread: i < 14
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const notifications = ref<NotificationItem[]>(buildMockNotifications());
|
|||
|
|
|
|||
|
|
const unreadAll = computed(() => notifications.value.filter(n => n.unread));
|
|||
|
|
const readAll = computed(() => notifications.value.filter(n => !n.unread));
|
|||
|
|
const unreadCount = computed(() => unreadAll.value.length);
|
|||
|
|
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
|||
|
|
|
|||
|
|
const drawerOpen = ref(false);
|
|||
|
|
const activeTab = ref<'unread' | 'read'>('unread');
|
|||
|
|
const searchKeyword = ref('');
|
|||
|
|
|
|||
|
|
function matchesKeyword(item: NotificationItem) {
|
|||
|
|
const kw = searchKeyword.value.trim();
|
|||
|
|
if (!kw) return true;
|
|||
|
|
return item.title.toLowerCase().includes(kw.toLowerCase());
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
|
|||
|
|
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
|
|||
|
|
|
|||
|
|
const unreadPageSize = ref(PAGE_SIZE);
|
|||
|
|
const readPageSize = ref(PAGE_SIZE);
|
|||
|
|
|
|||
|
|
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
|
|||
|
|
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
|
|||
|
|
|
|||
|
|
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
|
|||
|
|
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
|
|||
|
|
|
|||
|
|
watch(searchKeyword, () => {
|
|||
|
|
unreadPageSize.value = PAGE_SIZE;
|
|||
|
|
readPageSize.value = PAGE_SIZE;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
|
|||
|
|
|
|||
|
|
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
|||
|
|
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
|||
|
|
const readScrollbar = ref<ScrollbarRefValue>(null);
|
|||
|
|
|
|||
|
|
useInfiniteScroll(
|
|||
|
|
() => unreadScrollbar.value?.wrapRef,
|
|||
|
|
() => {
|
|||
|
|
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
|
|||
|
|
},
|
|||
|
|
{ distance: 48 }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
useInfiniteScroll(
|
|||
|
|
() => readScrollbar.value?.wrapRef,
|
|||
|
|
() => {
|
|||
|
|
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
|
|||
|
|
},
|
|||
|
|
{ distance: 48 }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
function openDrawer() {
|
|||
|
|
drawerOpen.value = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function closeDrawer() {
|
|||
|
|
drawerOpen.value = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function markRead(item: NotificationItem) {
|
|||
|
|
if (!item.unread) return;
|
|||
|
|
item.unread = false;
|
|||
|
|
// eslint-disable-next-line no-console
|
|||
|
|
console.warn('[notification] mark-read', item.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function markAllRead() {
|
|||
|
|
notifications.value.forEach(item => {
|
|||
|
|
item.unread = false;
|
|||
|
|
});
|
|||
|
|
// eslint-disable-next-line no-console
|
|||
|
|
console.warn('[notification] mark-all-read');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function openItem(item: NotificationItem) {
|
|||
|
|
markRead(item);
|
|||
|
|
// eslint-disable-next-line no-console
|
|||
|
|
console.warn('[notification] open', item.id);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onDrawerClosed() {
|
|||
|
|
searchKeyword.value = '';
|
|||
|
|
}
|
|||
|
|
</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" :with-header="false" @closed="onDrawerClosed">
|
|||
|
|
<div class="notification-bell__panel">
|
|||
|
|
<header class="notification-bell__header">
|
|||
|
|
<span class="notification-bell__title">
|
|||
|
|
通知
|
|||
|
|
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
|||
|
|
</span>
|
|||
|
|
<span class="notification-bell__header-actions">
|
|||
|
|
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
|||
|
|
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
|
|||
|
|
<SvgIcon icon="mdi:close" />
|
|||
|
|
</button>
|
|||
|
|
</span>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<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">{{ filteredUnread.length }}</span>
|
|||
|
|
</span>
|
|||
|
|
</template>
|
|||
|
|
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
|||
|
|
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
|
|||
|
|
<li
|
|||
|
|
v-for="row in visibleUnread"
|
|||
|
|
:key="row.id"
|
|||
|
|
class="notification-bell__row is-unread"
|
|||
|
|
@click="openItem(row)"
|
|||
|
|
>
|
|||
|
|
<span class="notification-bell__row-dot" />
|
|||
|
|
<div class="notification-bell__row-body">
|
|||
|
|
<div class="notification-bell__row-title">{{ row.title }}</div>
|
|||
|
|
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
|||
|
|
</div>
|
|||
|
|
</li>
|
|||
|
|
</ul>
|
|||
|
|
<div v-else class="notification-bell__empty">
|
|||
|
|
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
|||
|
|
</div>
|
|||
|
|
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
|
|||
|
|
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
|
|||
|
|
</div>
|
|||
|
|
</ElScrollbar>
|
|||
|
|
</ElTabPane>
|
|||
|
|
|
|||
|
|
<ElTabPane name="read">
|
|||
|
|
<template #label>
|
|||
|
|
<span class="notification-bell__tab-label">
|
|||
|
|
已读
|
|||
|
|
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
|
|||
|
|
</span>
|
|||
|
|
</template>
|
|||
|
|
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
|||
|
|
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
|
|||
|
|
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
|
|||
|
|
<span class="notification-bell__row-dot" />
|
|||
|
|
<div class="notification-bell__row-body">
|
|||
|
|
<div class="notification-bell__row-title">{{ row.title }}</div>
|
|||
|
|
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
|||
|
|
</div>
|
|||
|
|
</li>
|
|||
|
|
</ul>
|
|||
|
|
<div v-else class="notification-bell__empty">
|
|||
|
|
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
|||
|
|
</div>
|
|||
|
|
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
|
|||
|
|
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
|
|||
|
|
</div>
|
|||
|
|
</ElScrollbar>
|
|||
|
|
</ElTabPane>
|
|||
|
|
</ElTabs>
|
|||
|
|
</div>
|
|||
|
|
</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 {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 12px;
|
|||
|
|
padding-bottom: 12px;
|
|||
|
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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__header-actions {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.notification-bell__close {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 28px;
|
|||
|
|
height: 28px;
|
|||
|
|
padding: 0;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 6px;
|
|||
|
|
background-color: transparent;
|
|||
|
|
color: var(--el-text-color-secondary);
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 18px;
|
|||
|
|
transition:
|
|||
|
|
background-color 120ms ease,
|
|||
|
|
color 120ms ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.notification-bell__close:hover {
|
|||
|
|
background-color: var(--el-fill-color-light);
|
|||
|
|
color: var(--el-text-color-primary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.notification-bell__search {
|
|||
|
|
padding: 12px 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;
|
|||
|
|
cursor: pointer;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
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-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>
|