refine(notify): 铃铛与公告编辑体验优化, 新增通知中心页

- 铃铛: 只显示未读、红点改未读数字badge(99+)、点击查看正文、按类型切换图标(Bell/Inbox)、去除内容摘要/删除按钮/未读小点、标题省略避让已读按钮、important 标题红色、上下栏紧凑
- 公告编辑: Modal改Drawer(85%宽)、正文/标题占满(label2/wrapper22)、元信息两列横排、severity/isTop必填、生效/过期时间tooltip、正文变动关闭二次确认
- 公告列表: 操作列竖线分隔
- 通知中心(新): 铃铛查看全部跳转(/notify/center), 标题点击查看正文(Modal), important标题红色, 操作列精简
- 时间格式化(formatDateTime)、铃铛查看不再弹已读提示、i18n(zh/en)补全
This commit is contained in:
DaxPay Dev
2026-06-24 18:48:39 +08:00
parent 1461e103c0
commit 418198fe80
13 changed files with 508 additions and 148 deletions

View File

@@ -1,32 +1,45 @@
<script lang="ts" setup>
import type { NotificationItem } from '@vben/layouts';
import { computed, onMounted, onUnmounted, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useWatermark } from '@vben/hooks';
import { BasicLayout, Notification } from '@vben/layouts';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { formatDateTime } from '@vben/utils';
import { MdPreview } from 'md-editor-v3';
import { LoginExpiredModal } from '#/components/login-expired-modal';
import { useMessage } from '#/hooks/useMessage';
import { useAuthStore } from '#/store';
import { useNotifyStore } from '#/store/notify';
import LoginForm from '#/views/_core/authentication/login.vue';
import { LockScreen, UserDropdown } from '#/widgets/user-dropdown';
import 'md-editor-v3/lib/style.css';
const notifyStore = useNotifyStore();
const { message } = useMessage();
// 铃铛"查看正文"弹窗
const detailOpen = ref(false);
const detail = ref<NotificationItem>();
// 通知项(公告 + 个人消息), 适配铃铛组件结构
const notifications = computed<NotificationItem[]>(() =>
notifyStore.list.map((item) => ({
avatar: 'https://avatar.vercel.sh/notify.svg',
date: item.createTime ?? '',
date: formatDateTime(item.createTime),
id: item.id!,
isRead: item.isRead,
link: item.link,
message: item.message ?? '',
severity: item.severity,
title: item.title ?? '',
type: item.type,
})),
);
@@ -35,7 +48,6 @@
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const showDot = computed(() => notifyStore.unreadCount > 0);
// 登录后拉取通知并建立 SSE 实时连接
onMounted(() => {
@@ -52,13 +64,13 @@
router.push('/');
}
async function handleLogout() {
await authStore.logout(false);
// 铃铛"查看全部" → 通知中心
function handleViewAll() {
router.push('/notify/center');
}
// 清空 = 全部已读
function handleNoticeClear() {
notifyStore.markAllRead();
async function handleLogout() {
await authStore.logout(false);
}
// 根据id查原始通知项(取type)
@@ -70,6 +82,16 @@
const brief = findBrief(id);
if (brief?.type && brief.id) {
notifyStore.markRead(brief.type, brief.id);
message.success($t('system.notify.markReadSuccess'));
}
}
// 点击铃铛通知项查看正文(查看即标记已读)
function handleView(item: NotificationItem) {
detail.value = item;
detailOpen.value = true;
if (!item.isRead && item.type && item.id) {
notifyStore.markRead(item.type, String(item.id));
}
}
@@ -82,6 +104,7 @@
function handleMakeAll() {
notifyStore.markAllRead();
message.success($t('system.notify.readAllSuccess'));
}
watch(
() => ({
@@ -110,12 +133,13 @@
</template>
<template #notification>
<Notification
:dot="showDot"
:count="notifyStore.unreadCount"
:notifications="notifications"
@clear="handleNoticeClear"
@read="(item) => item.id && markRead(item.id)"
@remove="(item) => item.id && remove(item.id)"
@make-all="handleMakeAll"
@view="handleView"
@view-all="handleViewAll"
/>
</template>
<template #extra>
@@ -127,4 +151,8 @@
<LockScreen :text="userStore.userInfo?.name" @to-login="handleLogout" />
</template>
</BasicLayout>
<!-- 铃铛通知正文查看弹窗 -->
<a-modal :open="detailOpen" :title="detail?.title" :footer="null" width="800" @cancel="detailOpen = false">
<MdPreview v-if="detail" :model-value="detail.message ?? ''" />
</a-modal>
</template>

View File

@@ -9,6 +9,8 @@
"seq": "No.",
"createTime": "Create Time",
"operation": "Operation",
"query": "Query",
"reset": "Reset",
"action": "Action",
"more": "More",
"confirmDelete": "Confirm delete?",

View File

@@ -22,5 +22,26 @@
"statusDraft": "Draft",
"statusPublished": "Published",
"statusOffline": "Offline",
"createTime": "Create Time"
"createTime": "Create Time",
"centerTitle": "Notification Center",
"typeField": "Type",
"noticeType": "Notice",
"messageType": "Message",
"readStatus": "Read",
"read": "Read",
"unread": "Unread",
"all": "All",
"onlyUnread": "Unread Only",
"readAll": "Mark All Read",
"viewDetail": "View Content",
"markRead": "Mark as Read",
"confirmReadAll": "Mark all notifications as read?",
"empty": "No notifications",
"markReadSuccess": "Marked as read",
"readAllSuccess": "All marked as read",
"severityRequired": "Please select severity",
"isTopRequired": "Please confirm whether to pin",
"effectiveTimeHelp": "If set, the notice becomes visible after this time. Leave empty to show it immediately after publishing.",
"expireTimeHelp": "If set, the notice is hidden after this time. Leave empty to keep it permanently.",
"confirmCloseContent": "Content has been modified. Discard changes and close?"
}

View File

@@ -9,6 +9,8 @@
"seq": "序号",
"createTime": "创建时间",
"operation": "操作",
"query": "查询",
"reset": "重置",
"action": "操作",
"more": "更多",
"confirmDelete": "是否删除",

View File

@@ -22,5 +22,26 @@
"statusDraft": "草稿",
"statusPublished": "已发布",
"statusOffline": "已下线",
"createTime": "创建时间"
"createTime": "创建时间",
"centerTitle": "通知中心",
"typeField": "类型",
"noticeType": "公告",
"messageType": "个人消息",
"readStatus": "读状态",
"read": "已读",
"unread": "未读",
"all": "全部",
"onlyUnread": "仅未读",
"readAll": "全部已读",
"viewDetail": "查看正文",
"markRead": "标为已读",
"confirmReadAll": "确认将所有通知标记为已读吗?",
"empty": "暂无通知",
"markReadSuccess": "已标记为已读",
"readAllSuccess": "已全部标记为已读",
"severityRequired": "请选择重要程度",
"isTopRequired": "请确认是否置顶",
"effectiveTimeHelp": "设置后公告在该时间后才对用户可见,不填则发布后立即可见",
"expireTimeHelp": "设置后公告在该时间后不再可见,不填则永久有效",
"confirmCloseContent": "正文内容已修改,确认放弃修改并关闭吗?"
}

View File

@@ -128,4 +128,18 @@ const coreRoutes: RouteRecordRaw[] = [
socialBindCallbackRoute,
];
export { coreRoutes, fallbackNotFoundRoute, profileRoute, socialBindCallbackRoute };
/** 通知中心路由(需要登录,从铃铛"查看全部"进入,无菜单) */
const notifyCenterRoute: RouteRecordRaw = {
name: 'NotifyCenter',
path: 'notify/center',
component: () => import('#/views/system/notify/center/NotifyCenter.vue'),
meta: {
// 通知中心
title: $t('system.notify.centerTitle'),
hideInMenu: true,
hideInBreadcrumb: true,
activePath: '/notify/center',
},
};
export { coreRoutes, fallbackNotFoundRoute, notifyCenterRoute, profileRoute, socialBindCallbackRoute };

View File

@@ -2,7 +2,7 @@ import type { RouteRecordRaw } from 'vue-router';
import { traverseTreeValues } from '@vben/utils';
import { coreRoutes, fallbackNotFoundRoute, profileRoute } from './core';
import { coreRoutes, fallbackNotFoundRoute, notifyCenterRoute, profileRoute } from './core';
// 基本路由名称集合coreRoutes 不含 Profile这些路由不走权限拦截
const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
@@ -10,10 +10,10 @@ const coreRouteNames = traverseTreeValues(coreRoutes, (route) => route.name);
// 路由列表
const routes: RouteRecordRaw[] = [...coreRoutes, fallbackNotFoundRoute];
// 将 Profile 注入 Root children使用 BasicLayout走完整认证流程
// 将 Profile、通知中心注入 Root children使用 BasicLayout走完整认证流程
const rootRoute = routes.find((r) => r.name === 'Root');
if (rootRoute) {
rootRoute.children = [...(rootRoute.children || []), profileRoute];
rootRoute.children = [...(rootRoute.children || []), profileRoute, notifyCenterRoute];
}
export { coreRouteNames, routes };

View File

@@ -27,7 +27,8 @@ export const useNotifyStore = defineStore('notify', () => {
* 拉取铃铛列表
*/
async function fetchList() {
const { data } = await NotifyUserApi.page();
// 铃铛仅展示未读(已读不显示), 全部数据在通知中心查看
const { data } = await NotifyUserApi.page({ onlyUnread: true });
list.value = data ?? [];
}

View File

@@ -0,0 +1,220 @@
<script lang="ts" setup>
import type { NotifyNoticeBrief } from '#/api/system/notify/user.api';
import { onMounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { MdPreview } from 'md-editor-v3';
import { NotifyUserApi } from '#/api/system/notify/user.api';
import { useMessage } from '#/hooks/useMessage';
import { useNotifyStore } from '#/store/notify';
import 'md-editor-v3/lib/style.css';
const store = useNotifyStore();
const { confirm, message } = useMessage();
const loading = ref(false);
// 通知列表
const list = ref<NotifyNoticeBrief[]>([]);
// 标题筛选
const title = ref('');
// 仅未读
const onlyUnread = ref(false);
// 详情弹窗
const detailOpen = ref(false);
const detail = ref<NotifyNoticeBrief>();
/**
* 类型文本
*/
function typeText(v?: string) {
return v === 'message' ? $t('system.notify.messageType') : $t('system.notify.noticeType');
}
/**
* 拉取通知列表
*/
function fetchList() {
loading.value = true;
NotifyUserApi.page({ onlyUnread: onlyUnread.value })
.then(({ data }) => {
let records = data ?? [];
if (title.value) {
const kw = title.value.trim();
records = records.filter((n) => (n.title ?? '').includes(kw));
}
list.value = records;
loading.value = false;
})
.catch(() => {
list.value = [];
loading.value = false;
});
}
/**
* 重置筛选
*/
function resetFilter() {
title.value = '';
onlyUnread.value = false;
fetchList();
}
/**
* 标记单条已读
*/
async function handleRead(row: NotifyNoticeBrief) {
if (!row.type || !row.id) return;
await NotifyUserApi.read(row.type, row.id);
row.isRead = true;
store.refresh().catch(() => {});
}
/**
* 查看正文(未读则自动标记已读)
*/
function handleView(row: NotifyNoticeBrief) {
detail.value = row;
detailOpen.value = true;
if (!row.isRead && row.type && row.id) {
handleRead(row);
}
}
/**
* 全部已读
*/
function handleReadAll() {
confirm({
title: $t('system.notify.readAll'),
content: $t('system.notify.confirmReadAll'),
onOk: () => {
NotifyUserApi.readAll().then(() => {
message.success($t('common.saveSuccess'));
fetchList();
store.refresh().catch(() => {});
});
},
});
}
onMounted(() => {
fetchList();
});
</script>
<template>
<div class="m-3 list-page-compact rounded-lg bg-background p-3">
<!-- 筛选 -->
<a-card>
<a-form layout="inline">
<a-form-item :label="$t('system.notify.titleField')">
<a-input
v-model:value="title"
allow-clear
:placeholder="$t('system.notify.inputTitle')"
@press-enter="fetchList"
/>
</a-form-item>
<a-form-item :label="$t('system.notify.readStatus')">
<a-radio-group v-model:value="onlyUnread" @change="fetchList" button-style="solid">
<a-radio-button :value="false">{{ $t('system.notify.all') }}</a-radio-button>
<a-radio-button :value="true">{{ $t('system.notify.onlyUnread') }}</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item>
<a-button type="primary" @click="fetchList">{{ $t('common.query') }}</a-button>
</a-form-item>
<a-form-item>
<a-button @click="resetFilter">{{ $t('common.reset') }}</a-button>
</a-form-item>
</a-form>
</a-card>
<div class="mt-4">
<a-card>
<vxe-toolbar custom refresh :refresh-options="{ queryMethod: fetchList }">
<template #buttons>
<a-space>
<a-button type="primary" :disabled="!list.some((n) => !n.isRead)" @click="handleReadAll">
{{ $t('system.notify.readAll') }}
</a-button>
</a-space>
</template>
</vxe-toolbar>
<!-- 通知列表 -->
<vxe-table :row-config="{ keyField: 'id' }" :data="list" :loading="loading">
<!-- 序号 -->
<vxe-column type="seq" :title="$t('common.seq')" width="60" align="center" />
<!-- 标题(点击查看正文, important 红色) -->
<vxe-column field="title" :title="$t('system.notify.titleField')" :min-width="220">
<template #default="{ row }">
<a
class="block truncate cursor-pointer font-medium hover:underline"
:class="{ 'text-red-500': row.severity === 'important' }"
@click="handleView(row)"
>
{{ row.title }}
</a>
</template>
</vxe-column>
<!-- 类型 -->
<vxe-column field="type" :title="$t('system.notify.typeField')" :width="100" align="center">
<template #default="{ row }">
<a-tag :color="row.type === 'message' ? 'blue' : 'green'">{{ typeText(row.type) }}</a-tag>
</template>
</vxe-column>
<!-- 重要程度 -->
<vxe-column field="severity" :title="$t('system.notify.severity')" :width="100" align="center">
<template #default="{ row }">
<a-tag v-if="row.severity === 'important'" color="red">{{ $t('system.notify.severityImportant') }}</a-tag>
<span v-else-if="row.type === 'notice'">{{ $t('system.notify.severityNormal') }}</span>
<span v-else>-</span>
</template>
</vxe-column>
<!-- 置顶 -->
<vxe-column field="isTop" :title="$t('system.notify.isTop')" :width="80" align="center">
<template #default="{ row }">
<a-tag v-if="row.isTop" color="gold">{{ $t('system.notify.isTop') }}</a-tag>
<span v-else>-</span>
</template>
</vxe-column>
<!-- 读状态 -->
<vxe-column field="isRead" :title="$t('system.notify.readStatus')" :width="100" align="center">
<template #default="{ row }">
<a-tag v-if="row.isRead" color="default">{{ $t('system.notify.read') }}</a-tag>
<a-tag v-else color="processing">{{ $t('system.notify.unread') }}</a-tag>
</template>
</vxe-column>
<!-- 创建时间 -->
<vxe-column
field="createTime"
:title="$t('system.notify.createTime')"
:width="160"
formatter="formatDateTime"
/>
<!-- 操作 -->
<vxe-column fixed="right" :width="100" :show-overflow="false" :title="$t('common.operation')">
<template #default="{ row }">
<!-- 标为已读 -->
<a-button v-if="!row.isRead" type="link" size="small" @click="handleRead(row)">{{
$t('system.notify.markRead')
}}</a-button>
<span v-else>-</span>
</template>
</vxe-column>
</vxe-table>
</a-card>
</div>
<!-- 正文详情 -->
<a-modal :open="detailOpen" :title="detail?.title" :footer="null" width="800" @cancel="detailOpen = false">
<MdPreview v-if="detail" :model-value="detail.message ?? ''" />
</a-modal>
</div>
</template>

View File

@@ -15,29 +15,23 @@
const emits = defineEmits(['ok']);
const { message } = useMessage();
const { confirm, message } = useMessage();
const {
initFormEditType,
handleCancel,
labelCol,
wrapperCol,
visible,
title,
confirmLoading,
showable,
editable,
formEditType,
} = useFormEdit();
const { initFormEditType, handleCancel, visible, title, confirmLoading, showable, editable, formEditType } =
useFormEdit();
const formRef = ref();
// 公告表单(时间用 dayjs 对象, 提交时转 ISO 字符串)
const form = ref<Record<string, any>>({ severity: 'normal', isTop: false });
// 正文初始内容(用于关闭时检测是否有未保存修改)
const loadedContent = ref('');
// 表单校验规则
const rules = {
title: [{ required: true, message: $t('system.notify.inputTitle') }],
content: [{ required: true, message: $t('system.notify.inputContent') }],
severity: [{ required: true, message: $t('system.notify.severityRequired') }],
isTop: [{ required: true, message: $t('system.notify.isTopRequired') }],
};
// 重要程度选项
@@ -68,6 +62,8 @@
effectiveTime: data.effectiveTime ? dayjs(data.effectiveTime) : undefined,
expireTime: data.expireTime ? dayjs(data.expireTime) : undefined,
};
// 记录正文初始值, 用于关闭时检测变动
loadedContent.value = data.content ?? '';
confirmLoading.value = false;
});
} else {
@@ -82,9 +78,26 @@
nextTick(() => {
formRef.value?.resetFields();
form.value = { severity: 'normal', isTop: false };
loadedContent.value = '';
});
}
/**
* 关闭抽屉(正文有未保存修改时二次确认)
*/
function closeWithConfirm() {
const content = form.value.content ?? '';
if (content && content !== loadedContent.value) {
confirm({
title: $t('common.warning'),
content: $t('system.notify.confirmCloseContent'),
onOk: () => handleCancel(),
});
} else {
handleCancel();
}
}
/**
* 提交
*/
@@ -116,76 +129,119 @@
</script>
<template>
<a-modal
<a-drawer
:open="visible"
:title="title"
:confirm-loading="confirmLoading"
:width="900"
width="85%"
:mask-closable="showable"
:ok-text="$t('common.save')"
:cancel-text="$t('common.cancel')"
:ok-button-props="{ disabled: showable }"
@ok="handleOk"
@cancel="handleCancel"
wrap-class-name="notice-drawer"
@close="closeWithConfirm"
>
<template #footer>
<a-space>
<a-button @click="closeWithConfirm">{{ $t('common.cancel') }}</a-button>
<a-button v-if="!showable" type="primary" :loading="confirmLoading" @click="handleOk">
{{ $t('common.save') }}
</a-button>
</a-space>
</template>
<a-spin :spinning="confirmLoading">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="labelCol"
:wrapper-col="wrapperCol"
class="form-compact"
>
<!-- 主键 -->
<a-form-item label="ID" name="id" :hidden="true">
<a-input v-model:value="form.id" :disabled="showable" />
</a-form-item>
<!-- 标题 -->
<a-form-item :label="$t('system.notify.titleField')" name="title">
<a-input v-model:value="form.title" :disabled="showable" :placeholder="$t('system.notify.inputTitle')" />
</a-form-item>
<!-- 重要程度 -->
<a-form-item :label="$t('system.notify.severity')" name="severity">
<a-select v-model:value="form.severity" :disabled="showable" :options="severityOptions" />
</a-form-item>
<!-- 置顶 -->
<a-form-item :label="$t('system.notify.isTop')" name="isTop">
<a-switch v-model:checked="form.isTop" :disabled="showable" />
</a-form-item>
<!-- 生效时间 -->
<a-form-item :label="$t('system.notify.effectiveTime')" name="effectiveTime">
<a-date-picker
v-model:value="form.effectiveTime"
:disabled="showable"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<!-- 过期时间 -->
<a-form-item :label="$t('system.notify.expireTime')" name="expireTime">
<a-date-picker
v-model:value="form.expireTime"
:disabled="showable"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
<!-- 正文 -->
<a-form-item :label="$t('system.notify.content')" name="content" :wrapper-col="{ sm: { span: 16 } }">
<!-- 编辑模式 -->
<MdEditor
v-if="editable || !showable"
v-model="form.content"
:toolbars-exclude="['github', 'save', 'pageFullscreen', 'catalog', 'htmlPreview']"
:style="{ height: '300px' }"
/>
<!-- 查看模式 -->
<MdPreview v-else :model-value="form.content" :style="{ minHeight: '200px' }" />
</a-form-item>
</a-form>
<div class="notice-form-wrap">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ sm: { span: 4 } }"
:wrapper-col="{ sm: { span: 20 } }"
class="form-compact"
>
<!-- 主键 -->
<a-form-item label="ID" name="id" :hidden="true">
<a-input v-model:value="form.id" :disabled="showable" />
</a-form-item>
<!-- 标题 -->
<a-form-item
:label="$t('system.notify.titleField')"
name="title"
:label-col="{ sm: { span: 2 } }"
:wrapper-col="{ sm: { span: 22 } }"
>
<a-input v-model:value="form.title" :disabled="showable" :placeholder="$t('system.notify.inputTitle')" />
</a-form-item>
<a-row :gutter="16">
<!-- 重要程度 -->
<a-col :md="12" :sm="24">
<a-form-item :label="$t('system.notify.severity')" name="severity">
<a-select v-model:value="form.severity" :disabled="showable" :options="severityOptions" />
</a-form-item>
</a-col>
<!-- 置顶 -->
<a-col :md="12" :sm="24">
<a-form-item :label="$t('system.notify.isTop')" name="isTop">
<a-switch v-model:checked="form.isTop" :disabled="showable" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<!-- 生效时间 -->
<a-col :md="12" :sm="24">
<a-form-item
:label="$t('system.notify.effectiveTime')"
name="effectiveTime"
:tooltip="$t('system.notify.effectiveTimeHelp')"
>
<a-date-picker
v-model:value="form.effectiveTime"
:disabled="showable"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
</a-col>
<!-- 过期时间 -->
<a-col :md="12" :sm="24">
<a-form-item
:label="$t('system.notify.expireTime')"
name="expireTime"
:tooltip="$t('system.notify.expireTimeHelp')"
>
<a-date-picker
v-model:value="form.expireTime"
:disabled="showable"
show-time
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 正文 -->
<a-form-item
:label="$t('system.notify.content')"
name="content"
:label-col="{ sm: { span: 2 } }"
:wrapper-col="{ sm: { span: 22 } }"
>
<!-- 编辑模式 -->
<MdEditor
v-if="editable || !showable"
v-model="form.content"
:toolbars-exclude="['github', 'save', 'pageFullscreen', 'catalog', 'htmlPreview']"
:style="{ height: 'calc(100vh - 300px)' }"
/>
<!-- 查看模式 -->
<MdPreview v-else :model-value="form.content" :style="{ minHeight: 'calc(100vh - 300px)' }" />
</a-form-item>
</a-form>
</div>
</a-spin>
</a-modal>
</a-drawer>
</template>
<style scoped>
.notice-form-wrap {
max-width: 1100px;
margin: 0 auto;
}
</style>

View File

@@ -226,6 +226,9 @@
<vxe-column fixed="right" width="280" :show-overflow="false" :title="$t('common.operation')">
<template #default="{ row }">
<a-space :size="2">
<template #separator>
<a-divider type="vertical" />
</template>
<!-- 查看 -->
<a-button
v-if="hasPermission(PermCodes.System.Notify.VIEW)"

View File

@@ -3,7 +3,7 @@ import type { NotificationItem } from './types';
import { useRouter } from 'vue-router';
import { Bell, CircleCheckBig, CircleX, MailCheck } from '@vben/icons';
import { Bell, CircleCheckBig, Inbox, MailCheck } from '@vben/icons';
import { $t } from '@vben/locales';
import {
@@ -17,9 +17,9 @@ import { useToggle } from '@vueuse/core';
interface Props {
/**
* 显示圆点
* 未读数(显示为铃铛角标, >99 显示 99+)
*/
dot?: boolean;
count?: number;
/**
* 消息列表
*/
@@ -29,15 +29,15 @@ interface Props {
defineOptions({ name: 'NotificationPopup' });
withDefaults(defineProps<Props>(), {
dot: false,
count: 0,
notifications: () => [],
});
const emit = defineEmits<{
clear: [];
makeAll: [];
read: [NotificationItem];
remove: [NotificationItem];
view: [NotificationItem];
viewAll: [];
}>();
@@ -57,14 +57,12 @@ function handleMakeAll() {
emit('makeAll');
}
function handleClear() {
emit('clear');
}
function handleClick(item: NotificationItem) {
// 如果通知项有链接,点击时跳转
// 有链接则跳转, 否则触发查看正文
if (item.link) {
navigateTo(item.link, item.query, item.state);
} else {
emit('view', item);
}
}
@@ -92,16 +90,18 @@ function navigateTo(
<div class="mr-2 flex-center h-full" @click.stop="toggle()">
<VbenIconButton class="bell-button relative text-foreground">
<span
v-if="dot"
class="absolute top-0.5 right-0.5 size-2 rounded-sm bg-primary"
></span>
v-if="count && count > 0"
class="absolute -right-1 -top-1 flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium leading-4 text-primary-foreground"
>
{{ count > 99 ? '99+' : count }}
</span>
<Bell class="size-4" />
</VbenIconButton>
</div>
</template>
<div class="relative">
<div class="flex items-center justify-between p-4 py-3">
<div class="flex items-center justify-between px-4 py-1">
<div class="text-foreground">{{ $t('ui.widgets.notifications') }}</div>
<VbenIconButton
:disabled="notifications.length <= 0"
@@ -115,26 +115,26 @@ function navigateTo(
<ul class="flex! max-h-90 w-full flex-col">
<template v-for="item in notifications" :key="item.id ?? item.title">
<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 px-3 py-2 hover:bg-accent"
@click="handleClick(item)"
>
<span
v-if="!item.isRead"
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"
class="relative flex size-10 shrink-0 items-center justify-center rounded-full"
:class="
item.type === 'message'
? 'bg-blue-500/15 text-blue-500'
: 'bg-emerald-500/15 text-emerald-500'
"
>
<img
:src="item.avatar"
class="aspect-square size-full object-cover"
/>
<Inbox v-if="item.type === 'message'" class="size-5" />
<Bell v-else class="size-5" />
</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 }}
<div class="flex min-w-0 flex-1 flex-col gap-1 pr-12 leading-none">
<p
class="truncate font-semibold"
:class="{ 'text-red-500': item.severity === 'important' }"
>
{{ item.title }}
</p>
<p class="line-clamp-2 text-xs text-muted-foreground">
{{ item.date }}
@@ -153,16 +153,6 @@ function navigateTo(
>
<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>
</div>
</li>
</template>
@@ -175,17 +165,7 @@ function navigateTo(
</div>
</template>
<div
class="flex items-center justify-between border-t border-border px-4 py-3"
>
<VbenButton
:disabled="notifications.length <= 0"
size="sm"
variant="ghost"
@click="handleClear"
>
{{ $t('ui.widgets.clearNotifications') }}
</VbenButton>
<div class="flex items-center justify-end border-t border-border px-4 py-1">
<VbenButton size="sm" @click="handleViewAll">
{{ $t('ui.widgets.viewAll') }}
</VbenButton>

View File

@@ -1,6 +1,18 @@
interface NotificationItem {
id: number | string;
avatar: string;
avatar?: string;
/**
* 通知类型(notice 公告 / message 个人消息), 用于切换图标
*/
type?: string;
/**
* 未读数, 用于铃铛图标角标显示
*/
count?: number;
/**
* 重要程度(important 重要), 用于标题红色标注
*/
severity?: string;
date: string;
isRead?: boolean;
message: string;