feat(projects): 工作台小组件设计
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
<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>
|
||||
@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
|
||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||
import GlobalSearch from '../global-search/index.vue';
|
||||
import ThemeButton from './components/theme-button.vue';
|
||||
import NotificationBell from './components/notification-bell.vue';
|
||||
import UserAvatar from './components/user-avatar.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalHeader' });
|
||||
@@ -48,6 +49,7 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
<div>
|
||||
<ThemeButton />
|
||||
</div>
|
||||
<NotificationBell />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</DarkModeContainer>
|
||||
|
||||
Reference in New Issue
Block a user