mirror of
https://gitee.com/bootx/dax-pay-ui
synced 2026-08-13 07:45:41 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"seq": "No.",
|
||||
"createTime": "Create Time",
|
||||
"operation": "Operation",
|
||||
"query": "Query",
|
||||
"reset": "Reset",
|
||||
"action": "Action",
|
||||
"more": "More",
|
||||
"confirmDelete": "Confirm delete?",
|
||||
|
||||
@@ -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?"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"seq": "序号",
|
||||
"createTime": "创建时间",
|
||||
"operation": "操作",
|
||||
"query": "查询",
|
||||
"reset": "重置",
|
||||
"action": "操作",
|
||||
"more": "更多",
|
||||
"confirmDelete": "是否删除",
|
||||
|
||||
@@ -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": "正文内容已修改,确认放弃修改并关闭吗?"
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 ?? [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user