feat(projects): 1、站内信、通知功能完善;2、项目列表按会议需求重新开发
This commit is contained in:
@@ -1,76 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useInfiniteScroll } from '@vueuse/core';
|
||||
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' });
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
timeLabel: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const UNREAD_COUNT_POLL_INTERVAL = 30 * 1000;
|
||||
|
||||
// 通知 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
|
||||
}));
|
||||
type TabKey = 'unread' | 'read';
|
||||
|
||||
interface MessageListState {
|
||||
items: Api.NotifyMessage.NotifyMessage[];
|
||||
pageNo: number;
|
||||
total: number;
|
||||
loading: boolean;
|
||||
/** 是否已按当前关键字拉过第一页(tab 懒加载 / 失效重拉用) */
|
||||
loaded: boolean;
|
||||
/** 竞态令牌:重置后递增,过期响应直接丢弃 */
|
||||
token: number;
|
||||
}
|
||||
|
||||
const notifications = ref<NotificationItem[]>(buildMockNotifications());
|
||||
function createListState(): MessageListState {
|
||||
return { items: [], pageNo: 1, total: 0, loading: false, loaded: false, token: 0 };
|
||||
}
|
||||
|
||||
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 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<'unread' | 'read'>('unread');
|
||||
const activeTab = ref<TabKey>('unread');
|
||||
const searchKeyword = ref('');
|
||||
|
||||
function matchesKeyword(item: NotificationItem) {
|
||||
const kw = searchKeyword.value.trim();
|
||||
if (!kw) return true;
|
||||
return item.title.toLowerCase().includes(kw.toLowerCase());
|
||||
function keywordParam() {
|
||||
return searchKeyword.value.trim() || undefined;
|
||||
}
|
||||
|
||||
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
|
||||
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
|
||||
async function refreshUnreadCount() {
|
||||
const { data, error } = await fetchGetUnreadNotifyCount();
|
||||
if (!error && typeof data === 'number') {
|
||||
unreadCount.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
const unreadPageSize = ref(PAGE_SIZE);
|
||||
const readPageSize = ref(PAGE_SIZE);
|
||||
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;
|
||||
}
|
||||
|
||||
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
|
||||
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
|
||||
async function loadPage(tab: TabKey) {
|
||||
const state = listStates[tab];
|
||||
if (state.loading) return;
|
||||
|
||||
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
|
||||
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
|
||||
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, () => {
|
||||
unreadPageSize.value = PAGE_SIZE;
|
||||
readPageSize.value = PAGE_SIZE;
|
||||
applyKeywordSearch();
|
||||
});
|
||||
|
||||
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
|
||||
watch(activeTab, tab => {
|
||||
ensureLoaded(tab);
|
||||
});
|
||||
|
||||
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
||||
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
||||
@@ -79,7 +124,9 @@ const readScrollbar = ref<ScrollbarRefValue>(null);
|
||||
useInfiniteScroll(
|
||||
() => unreadScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
|
||||
if (drawerOpen.value && hasMore('unread') && !listStates.unread.loading) {
|
||||
loadPage('unread');
|
||||
}
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
@@ -87,43 +134,78 @@ useInfiniteScroll(
|
||||
useInfiniteScroll(
|
||||
() => readScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
|
||||
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 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 = '';
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -137,21 +219,18 @@ function onDrawerClosed() {
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -165,29 +244,29 @@ function onDrawerClosed() {
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
未读
|
||||
<span class="notification-bell__tab-count">{{ filteredUnread.length }}</span>
|
||||
<span class="notification-bell__tab-count">{{ listStates.unread.total }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
||||
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
|
||||
<ul v-if="listStates.unread.items.length > 0" class="notification-bell__list">
|
||||
<li
|
||||
v-for="row in visibleUnread"
|
||||
v-for="row in listStates.unread.items"
|
||||
:key="row.id"
|
||||
class="notification-bell__row is-unread"
|
||||
@click="openItem(row)"
|
||||
@click="markRead(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 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">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||
{{ listStates.unread.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
<div v-if="listStates.unread.items.length > 0" class="notification-bell__footer-hint">
|
||||
{{ listStates.unread.loading ? '加载中…' : hasMore('unread') ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
@@ -196,29 +275,33 @@ function onDrawerClosed() {
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
已读
|
||||
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
|
||||
<span class="notification-bell__tab-count">{{ listStates.read.total }}</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)">
|
||||
<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.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
<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">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||
{{ listStates.read.loading ? '加载中…' : searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
<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>
|
||||
|
||||
@@ -278,13 +361,14 @@ function onDrawerClosed() {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__header {
|
||||
.notification-bell__header-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
min-width: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.notification-bell__title {
|
||||
@@ -305,37 +389,8 @@ function onDrawerClosed() {
|
||||
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;
|
||||
padding: 0 0 4px;
|
||||
}
|
||||
|
||||
.notification-bell__tabs {
|
||||
@@ -393,16 +448,19 @@ function onDrawerClosed() {
|
||||
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 {
|
||||
.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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user