feat: 通知模块自定义加强

This commit is contained in:
雪忆天堂
2026-04-13 16:05:42 +08:00
parent 5b84ac5b13
commit fe77bc8bc9
10 changed files with 256 additions and 98 deletions

View File

@@ -147,6 +147,34 @@ function remove(id: number | string) {
function handleMakeAll() { function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true)); notifications.value.forEach((item) => (item.isRead = true));
} }
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch( watch(
() => ({ () => ({
enable: preferences.app.watermark, enable: preferences.app.watermark,
@@ -189,6 +217,8 @@ watch(
@read="(item) => item.id && markRead(item.id)" @read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)" @remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll" @make-all="handleMakeAll"
@on-click="handleClick"
@view-all="viewAll"
/> />
</template> </template>
<template #extra> <template #extra>

View File

@@ -147,6 +147,34 @@ function remove(id: number | string) {
function handleMakeAll() { function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true)); notifications.value.forEach((item) => (item.isRead = true));
} }
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch( watch(
() => ({ () => ({
enable: preferences.app.watermark, enable: preferences.app.watermark,
@@ -189,6 +217,8 @@ watch(
@read="(item) => item.id && markRead(item.id)" @read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)" @remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll" @make-all="handleMakeAll"
@on-click="handleClick"
@view-all="viewAll"
/> />
</template> </template>
<template #extra> <template #extra>

View File

@@ -147,6 +147,34 @@ function remove(id: number | string) {
function handleMakeAll() { function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true)); notifications.value.forEach((item) => (item.isRead = true));
} }
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch( watch(
() => ({ () => ({
enable: preferences.app.watermark, enable: preferences.app.watermark,
@@ -189,6 +217,8 @@ watch(
@read="(item) => item.id && markRead(item.id)" @read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)" @remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll" @make-all="handleMakeAll"
@on-click="handleClick"
@view-all="viewAll"
/> />
</template> </template>
<template #extra> <template #extra>

View File

@@ -148,6 +148,33 @@ function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true)); notifications.value.forEach((item) => (item.isRead = true));
} }
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch( watch(
() => ({ () => ({
enable: preferences.app.watermark, enable: preferences.app.watermark,
@@ -190,6 +217,8 @@ watch(
@read="(item) => item.id && markRead(item.id)" @read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)" @remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll" @make-all="handleMakeAll"
@on-click="handleClick"
@view-all="viewAll"
/> />
</template> </template>
<template #extra> <template #extra>

View File

@@ -147,6 +147,34 @@ function remove(id: number | string) {
function handleMakeAll() { function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true)); notifications.value.forEach((item) => (item.isRead = true));
} }
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch( watch(
() => ({ () => ({
enable: preferences.app.watermark, enable: preferences.app.watermark,
@@ -189,6 +217,8 @@ watch(
@read="(item) => item.id && markRead(item.id)" @read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)" @remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll" @make-all="handleMakeAll"
@on-click="handleClick"
@view-all="viewAll"
/> />
</template> </template>
<template #extra> <template #extra>

View File

@@ -25,6 +25,7 @@ export {
CircleX, CircleX,
Copy, Copy,
CornerDownLeft, CornerDownLeft,
Download,
Ellipsis, Ellipsis,
Eraser, Eraser,
Expand, Expand,

View File

@@ -24,6 +24,7 @@ export {
VbenContextMenu, VbenContextMenu,
VbenCountToAnimator, VbenCountToAnimator,
VbenFullScreen, VbenFullScreen,
VbenIconButton,
VbenInputPassword, VbenInputPassword,
VbenLoading, VbenLoading,
VbenLogo, VbenLogo,

View File

@@ -1,8 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { NotificationItem } from './types'; import type { NotificationItem } from './types';
import { useRouter } from 'vue-router';
import { Bell, CircleCheckBig, CircleX, MailCheck } from '@vben/icons'; import { Bell, CircleCheckBig, CircleX, MailCheck } from '@vben/icons';
import { $t } from '@vben/locales'; import { $t } from '@vben/locales';
@@ -15,76 +13,48 @@ import {
import { useToggle } from '@vueuse/core'; import { useToggle } from '@vueuse/core';
interface Props {
/**
* 显示圆点
*/
dot?: boolean;
/**
* 消息列表
*/
notifications?: NotificationItem[];
}
defineOptions({ name: 'NotificationPopup' }); defineOptions({ name: 'NotificationPopup' });
withDefaults(defineProps<Props>(), { withDefaults(
dot: false, defineProps<{
notifications: () => [], /** 显示圆点 */
}); dot?: boolean;
/** 消息列表 */
notifications?: NotificationItem[];
}>(),
{
dot: false,
notifications: () => [],
},
);
const emit = defineEmits<{ const emit = defineEmits<{
clear: []; clear: [];
makeAll: []; makeAll: [];
onClick: [NotificationItem];
read: [NotificationItem]; read: [NotificationItem];
remove: [NotificationItem]; remove: [NotificationItem];
viewAll: []; viewAll: [];
}>(); }>();
const router = useRouter();
const [open, toggle] = useToggle(); const [open, toggle] = useToggle();
function close() { const close = () => {
open.value = false; open.value = false;
} };
function handleViewAll() { const handleViewAll = () => {
emit('viewAll'); emit('viewAll');
close(); close();
} };
function handleMakeAll() { const handleMakeAll = () => {
emit('makeAll'); emit('makeAll');
} };
function handleClear() { const handleClear = () => {
emit('clear'); emit('clear');
} };
function handleClick(item: NotificationItem) {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
}
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
</script> </script>
<template> <template>
<VbenPopover v-model:open="open" content-class="relative right-2 w-90 p-0"> <VbenPopover v-model:open="open" content-class="relative right-2 w-90 p-0">
@@ -104,66 +74,72 @@ function navigateTo(
<div class="flex items-center justify-between p-4 py-3"> <div class="flex items-center justify-between p-4 py-3">
<div class="text-foreground">{{ $t('ui.widgets.notifications') }}</div> <div class="text-foreground">{{ $t('ui.widgets.notifications') }}</div>
<VbenIconButton <VbenIconButton
:disabled="notifications.length <= 0" :disabled="!notifications || notifications.length <= 0"
:tooltip="$t('ui.widgets.markAllAsRead')" :tooltip="$t('ui.widgets.markAllAsRead')"
@click="handleMakeAll" @click="handleMakeAll"
> >
<MailCheck class="size-4" /> <MailCheck class="size-4" />
</VbenIconButton> </VbenIconButton>
</div> </div>
<VbenScrollbar v-if="notifications.length > 0"> <VbenScrollbar v-if="!notifications || notifications.length > 0">
<ul class="flex! max-h-90 w-full flex-col"> <ul class="flex! max-h-90 w-full flex-col">
<template v-for="item in notifications" :key="item.id ?? item.title"> <template v-for="item in notifications" :key="item.id ?? item.title">
<li <li
class="relative flex w-full cursor-pointer items-start gap-5 border-t border-border p-3 hover:bg-accent" class="relative flex w-full cursor-pointer items-start gap-5 border-t border-border p-3 hover:bg-accent"
@click="handleClick(item)" @click="emit('onClick', item)"
> >
<span <slot name="content" :item="item">
v-if="!item.isRead" <span
class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
></span>
<span
class="relative flex size-10 shrink-0 overflow-hidden rounded-full"
>
<img
:src="item.avatar"
class="aspect-square size-full object-cover"
/>
</span>
<div class="flex flex-col gap-1 leading-none">
<p class="font-semibold">{{ item.title }}</p>
<p class="my-1 line-clamp-2 text-xs text-muted-foreground">
{{ item.message }}
</p>
<p class="line-clamp-2 text-xs text-muted-foreground">
{{ item.date }}
</p>
</div>
<div
class="absolute top-1/2 right-3 flex -translate-y-1/2 flex-col gap-2"
>
<VbenIconButton
v-if="!item.isRead" v-if="!item.isRead"
size="xs" class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
variant="ghost" ></span>
class="h-6 px-2"
:tooltip="$t('common.confirm')" <span
@click.stop="emit('read', item)" class="relative flex size-10 shrink-0 overflow-hidden rounded-full"
> >
<CircleCheckBig class="size-4" /> <img
</VbenIconButton> :src="item.avatar"
<VbenIconButton class="aspect-square size-full object-cover"
v-if="item.isRead" />
size="xs" </span>
variant="ghost" <div class="flex flex-col gap-1 leading-none">
class="h-6 px-2 text-destructive" <p class="font-semibold">{{ item.title }}</p>
:tooltip="$t('common.delete')" <p class="my-1 line-clamp-2 text-xs text-muted-foreground">
@click.stop="emit('remove', item)" {{ item.message }}
</p>
<p class="line-clamp-2 text-xs text-muted-foreground">
{{ item.date }}
</p>
</div>
<div
class="absolute top-1/2 right-3 flex -translate-y-1/2 flex-row gap-1"
> >
<CircleX class="size-4" /> <slot name="action" :item="item">
</VbenIconButton> <slot name="action-prepend" :item="item"></slot>
</div> <VbenIconButton
v-if="!item.isRead"
size="xs"
variant="ghost"
class="h-6 px-2"
:tooltip="$t('common.confirm')"
@click.stop="emit('read', item)"
>
<CircleCheckBig class="size-4" />
</VbenIconButton>
<VbenIconButton
v-if="item.isRead"
size="xs"
variant="ghost"
class="h-6 px-2 text-destructive"
:tooltip="$t('common.delete')"
@click.stop="emit('remove', item)"
>
<CircleX class="size-4" />
</VbenIconButton>
<slot name="action-append" :item="item"></slot>
</slot>
</div>
</slot>
</li> </li>
</template> </template>
</ul> </ul>
@@ -179,7 +155,7 @@ function navigateTo(
class="flex items-center justify-between border-t border-border px-4 py-3" class="flex items-center justify-between border-t border-border px-4 py-3"
> >
<VbenButton <VbenButton
:disabled="notifications.length <= 0" :disabled="!notifications || notifications.length <= 0"
size="sm" size="sm"
variant="ghost" variant="ghost"
@click="handleClear" @click="handleClear"

View File

@@ -12,6 +12,8 @@ interface NotificationItem {
link?: string; link?: string;
query?: Record<string, any>; query?: Record<string, any>;
state?: Record<string, any>; state?: Record<string, any>;
/** 业务字段 */
[key: string]: any;
} }
export type { NotificationItem }; export type { NotificationItem };

View File

@@ -163,6 +163,33 @@ function handleMakeAll() {
function handleClickLogo() {} function handleClickLogo() {}
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch( watch(
() => ({ () => ({
enable: preferences.app.watermark, enable: preferences.app.watermark,
@@ -215,6 +242,8 @@ onBeforeMount(() => {
@read="(item) => item.id && markRead(item.id)" @read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)" @remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll" @make-all="handleMakeAll"
@on-click="handleClick"
@view-all="viewAll"
/> />
</template> </template>
<template #extra> <template #extra>