feat: 顶部通知铃铛与站内通知中心
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m7s

This commit is contained in:
luo
2026-07-24 17:12:29 +08:00
parent 4c6d871c35
commit d1ff4d5f6c
18 changed files with 1101 additions and 519 deletions

View File

@@ -35,6 +35,7 @@ export { SuperAdminService } from './superAdmin'
export { ExportTaskService } from './exportTask'
export { OrderPackageInvalidateTaskService } from './orderPackageInvalidateTask'
export { BulkPurchaseService } from './bulkPurchase'
export { NotificationService } from './notification'
// TODO: 按需添加其他业务模块
// export { SettingService } from './setting'

View File

@@ -0,0 +1,41 @@
import { BaseService } from '../BaseService'
import type {
BaseResponse,
NotificationItem,
NotificationListResponse,
NotificationQueryParams,
NotificationSummary,
NotificationTarget
} from '@/types/api'
export class NotificationService extends BaseService {
static getUnreadCount(): Promise<
BaseResponse<number | { count?: number; unread_count?: number }>
> {
return this.get<BaseResponse<number | { count?: number; unread_count?: number }>>(
'/api/admin/notifications/unread-count'
)
}
static getUnreadSummary(): Promise<BaseResponse<NotificationSummary>> {
return this.get<BaseResponse<NotificationSummary>>('/api/admin/notifications/unread-summary')
}
static getNotifications(
params?: NotificationQueryParams
): Promise<BaseResponse<NotificationListResponse>> {
return this.get<BaseResponse<NotificationListResponse>>('/api/admin/notifications', params)
}
static markRead(id: number | string): Promise<BaseResponse<NotificationItem>> {
return this.put<BaseResponse<NotificationItem>>(`/api/admin/notifications/${id}/read`, {})
}
static markAllRead(): Promise<BaseResponse> {
return this.put<BaseResponse>('/api/admin/notifications/read-all', {})
}
static getTarget(id: number | string): Promise<BaseResponse<NotificationTarget>> {
return this.get<BaseResponse<NotificationTarget>>(`/api/admin/notifications/${id}/target`)
}
}

View File

@@ -65,12 +65,16 @@
</div>
</div>
<!-- 通知 -->
<!--<div class="btn-box notice-btn" @click="visibleNotice">-->
<!-- <div class="btn notice-button">-->
<!-- <i class="iconfont-sys notice-btn">&#xe6c2;</i>-->
<!-- <span class="count notice-btn"></span>-->
<!-- </div>-->
<!--</div>-->
<div
v-if="hasAuth(NOTIFICATION_PERMISSIONS.page)"
class="btn-box notice-btn"
@click.stop="showNotice = !showNotice"
>
<div class="btn notice-button">
<i class="iconfont-sys">&#xe6c2;</i>
<span v-if="unreadCount > 0" class="notice-count">{{ formattedUnreadCount }}</span>
</div>
</div>
<!-- 聊天 -->
<!--<div class="btn-box chat-btn" @click="openChat">-->
<!-- <div class="btn chat-button">-->
@@ -175,7 +179,7 @@
</div>
<ArtWorkTab />
<art-notification v-model:value="showNotice" ref="notice" />
<art-notification v-model="showNotice" />
</div>
</template>
@@ -189,12 +193,17 @@
import { useI18n } from 'vue-i18n'
import { mittBus } from '@/utils/sys'
import { useMenuStore } from '@/store/modules/menu'
import { useNotificationStore } from '@/store/modules/notification'
import { useAuth } from '@/composables/useAuth'
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
import AppConfig from '@/config'
const isWindows = navigator.userAgent.includes('Windows')
const { locale } = useI18n()
const settingStore = useSettingStore()
const userStore = useUserStore()
const notificationStore = useNotificationStore()
const { hasAuth } = useAuth()
const router = useRouter()
const {
@@ -214,7 +223,6 @@
const { menuList } = storeToRefs(useMenuStore())
const showNotice = ref(false)
const notice = ref(null)
const userMenuPopover = ref()
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
@@ -229,6 +237,11 @@
const { width } = useWindowSize()
const unreadCount = computed(() => notificationStore.unreadCount)
const formattedUnreadCount = computed(() =>
unreadCount.value > 99 ? '99+' : String(unreadCount.value)
)
const menuTopWidth = computed(() => {
return width.value * 0.5
})
@@ -251,11 +264,9 @@
onMounted(() => {
initLanguage()
document.addEventListener('click', bodyCloseNotice)
})
onUnmounted(() => {
document.removeEventListener('click', bodyCloseNotice)
if (hasAuth(NOTIFICATION_PERMISSIONS.page)) {
void notificationStore.refreshUnreadCount().catch(() => undefined)
}
})
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
@@ -334,24 +345,6 @@
mittBus.emit('openSearchDialog')
}
const bodyCloseNotice = (e: any) => {
let { className } = e.target
if (showNotice.value) {
if (typeof className === 'object') {
showNotice.value = false
return
}
if (className.indexOf('notice-btn') === -1) {
showNotice.value = false
}
}
}
const openChat = () => {
mittBus.emit('openChat')
}
const lockScreen = () => {
mittBus.emit('openLockScreen')
}

View File

@@ -200,6 +200,10 @@
}
}
&.notice-button {
position: relative;
}
&.chat-button:hover {
i {
animation: shake 0.5s ease-in-out;
@@ -411,6 +415,21 @@
}
}
}
.notice-count {
position: absolute;
top: 2px;
right: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
line-height: 16px;
color: #fff;
text-align: center;
background: var(--el-color-danger);
border-radius: 10px;
}
}
@keyframes rotate180 {

View File

@@ -1,276 +1,168 @@
<template>
<div
class="notice"
v-show="visible"
:style="{
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
opacity: show ? 1 : 0
}"
@click.stop=""
>
<div class="header">
<span class="text">{{ $t('notice.title') }}</span>
<span class="btn">{{ $t('notice.btnRead') }}</span>
</div>
<ul class="bar">
<li
v-for="(item, index) in barList"
:key="index"
:class="{ active: barActiveIndex === index }"
@click="changeBar(index)"
>
{{ item.name }} ({{ item.num }})
</li>
</ul>
<div class="content">
<div class="scroll">
<!-- 通知 -->
<ul class="notice-list" v-show="barActiveIndex === 0">
<li v-for="(item, index) in noticeList" :key="index">
<div
class="icon"
:style="{ background: getNoticeStyle(item.type).backgroundColor + '!important' }"
>
<i
class="iconfont-sys"
:style="{ color: getNoticeStyle(item.type).iconColor + '!important' }"
v-html="getNoticeStyle(item.type).icon"
>
</i>
</div>
<div class="text">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 消息 -->
<ul class="user-list" v-show="barActiveIndex === 1">
<li v-for="(item, index) in msgList" :key="index">
<div class="avatar">
<span>{{ item.title.slice(0, 1) }}</span>
</div>
<div class="text">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 待办 -->
<ul class="base" v-show="barActiveIndex === 3">
<li v-for="(item, index) in pendingList" :key="index">
<h4>{{ item.title }}</h4>
<p>{{ item.time }}</p>
</li>
</ul>
<div v-if="visible" class="notice-overlay" @click="close">
<div class="notice" @click.stop>
<div class="header">
<span class="text">通知</span>
<button
v-if="hasAuth(NOTIFICATION_PERMISSIONS.readAll)"
class="read-all"
type="button"
@click="handleMarkAllRead"
>
全部已读
</button>
</div>
<div class="empty-tips" v-show="barActiveIndex === 0 && noticeList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
<div class="bar">
<button
v-for="tab in tabs"
:key="tab.value"
type="button"
:class="{ active: activeCategory === tab.value }"
@click="activeCategory = tab.value"
>
{{ tab.label }}<span v-if="tab.count"> ({{ tab.count }})</span>
</button>
</div>
<div class="content">
<div v-loading="notificationStore.loading" class="scroll">
<button
v-for="item in filteredItems"
:key="item.id"
type="button"
:class="['notification-item', { unread: !isRead(item) }]"
@click="handleNotificationClick(item)"
>
<span :class="['icon', `severity-${item.severity || 'info'}`]">
<i class="iconfont-sys">&#xe6c2;</i>
</span>
<span class="text">
<strong>{{ item.title }}</strong>
<small>{{ item.content }}</small>
<time>{{ item.created_at || '-' }}</time>
</span>
<span v-if="!isRead(item)" class="unread-dot" />
</button>
<div v-if="!filteredItems.length" class="empty-tips">
<i class="iconfont-sys">&#xe8d7;</i>
<p>暂无通知</p>
</div>
</div>
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
</div>
<div class="empty-tips" v-show="barActiveIndex === 2 && pendingList.length === 0">
<i class="iconfont-sys">&#xe8d7;</i>
<p>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p>
<div class="btn-wrapper">
<ElButton class="view-all" @click="handleViewAll">进入通知中心</ElButton>
</div>
</div>
<div class="btn-wrapper">
<el-button class="view-all" @click="handleViewAll" v-ripple>
{{ $t('notice.viewAll') }}
</el-button>
</div>
</div>
<div style="height: 100px"></div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { useI18n } from 'vue-i18n'
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { NotificationService } from '@/api/modules'
import type { NotificationCategory, NotificationItem } from '@/types/api'
import { useNotificationStore } from '@/store/modules/notification'
import { useAuth } from '@/composables/useAuth'
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
import { navigateNotificationTarget } from '@/utils/business/notificationNavigation'
const { t } = useI18n()
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const props = defineProps({
value: {
type: Boolean,
default: false
}
const router = useRouter()
const notificationStore = useNotificationStore()
const { hasAuth } = useAuth()
const activeCategory = ref<'all' | NotificationCategory>('all')
const visible = computed(() => props.modelValue)
const categoryMatches = (item: NotificationItem, category: string) => {
if (category === 'all') return true
if (category === 'system') return item.category === 'sync' || item.category === 'system'
if (category === 'expiry') return item.category === 'expiry' || item.category === 'expiring'
return item.category === category
}
const isRead = (item: NotificationItem) =>
item.read === true || item.is_read === true || item.read_status === 'read'
const filteredItems = computed(() =>
notificationStore.summary.items.filter((item) => categoryMatches(item, activeCategory.value))
)
const tabs = computed(() => {
const categories = notificationStore.summary.categories || {}
return [
{ value: 'all', label: '全部', count: notificationStore.unreadCount },
{ value: 'approval', label: '审批', count: categories.approval || 0 },
{ value: 'expiry', label: '临期', count: categories.expiry || categories.expiring || 0 },
{
value: 'system',
label: '同步/系统',
count: (categories.sync || 0) + (categories.system || 0)
}
]
})
const close = () => emit('update:modelValue', false)
const handleViewAll = () => {
close()
void router.push('/notifications')
}
const handleMarkAllRead = async () => {
try {
const response = await notificationStore.markAllRead()
if (response.code !== 0) ElMessage.error(response.msg || '全部已读失败')
} catch (error: any) {
ElMessage.error(error?.message || '全部已读失败')
}
}
const handleNotificationClick = async (item: NotificationItem) => {
if (!hasAuth(NOTIFICATION_PERMISSIONS.read)) {
ElMessage.warning('您没有标记通知已读的权限')
return
}
try {
const readResponse = await notificationStore.markRead(item.id)
if (readResponse.code !== 0) {
ElMessage.error(readResponse.msg || '标记已读失败')
return
}
const targetResponse = await NotificationService.getTarget(item.id)
if (targetResponse.code !== 0 || !targetResponse.data) {
ElMessage.info(item.content || '该通知暂无可跳转目标')
return
}
if (!navigateNotificationTarget(router, targetResponse.data)) {
ElMessage.info(targetResponse.data.message || item.content || '该通知暂无可跳转目标')
} else {
close()
}
} catch (error: any) {
ElMessage.error(error?.message || '处理通知失败')
}
}
watch(
() => props.value,
() => {
showNotice(props.value)
() => props.modelValue,
(open) => {
if (open) {
activeCategory.value = 'all'
void notificationStore.refreshSummary().catch((error) => {
ElMessage.error(error?.message || '获取通知失败')
})
}
}
)
const show = ref(false)
const visible = ref(false)
const barActiveIndex = ref(0)
const pendingList: any = []
const barList = ref([
{
name: computed(() => t('notice.bar[0]')),
num: 1
},
{
name: computed(() => t('notice.bar[1]')),
num: 1
},
{
name: computed(() => t('notice.bar[2]')),
num: 0
}
])
const noticeList = [
{
title: '新增国际化',
time: '2024-6-13 0:10',
type: 'notice'
},
{
title: '冷月呆呆给你发了一条消息',
time: '2024-4-21 8:05',
type: 'message'
},
{
title: '小肥猪关注了你',
time: '2020-3-17 21:12',
type: 'collection'
},
{
title: '新增使用文档',
time: '2024-02-14 0:20',
type: 'notice'
},
{
title: '小肥猪给你发了一封邮件',
time: '2024-1-20 0:15',
type: 'email'
},
{
title: '菜单mock本地真实数据',
time: '2024-1-17 22:06',
type: 'notice'
}
]
const msgList: any = [
{
title: '池不胖 关注了你',
time: '2021-2-26 23:50'
},
{
title: '唐不苦 关注了你',
time: '2021-2-21 8:05'
},
{
title: '中小鱼 关注了你',
time: '2020-1-17 21:12'
},
{
title: '何小荷 关注了你',
time: '2021-01-14 0:20'
},
{
title: '誶誶淰 关注了你',
time: '2020-12-20 0:15'
},
{
title: '冷月呆呆 关注了你',
time: '2020-12-17 22:06'
}
]
const changeBar = (index: number) => {
barActiveIndex.value = index
}
const getRandomColor = () => {
const index = Math.floor(Math.random() * AppConfig.systemMainColor.length)
return AppConfig.systemMainColor[index]
}
const noticeStyleMap = {
email: {
icon: '&#xe72e;',
iconColor: 'rgb(var(--art-warning))',
backgroundColor: 'rgb(var(--art-bg-warning))'
},
message: {
icon: '&#xe747;',
iconColor: 'rgb(var(--art-success))',
backgroundColor: 'rgb(var(--art-bg-success))'
},
collection: {
icon: '&#xe714;',
iconColor: 'rgb(var(--art-danger))',
backgroundColor: 'rgb(var(--art-bg-danger))'
},
user: {
icon: '&#xe608;',
iconColor: 'rgb(var(--art-info))',
backgroundColor: 'rgb(var(--art-bg-info))'
},
notice: {
icon: '&#xe6c2;',
iconColor: 'rgb(var(--art-primary))',
backgroundColor: 'rgb(var(--art-bg-primary))'
}
}
const getNoticeStyle = (type: string) => {
const defaultStyle = {
icon: '&#xe747;',
iconColor: '#FFFFFF',
backgroundColor: getRandomColor()
}
const style = noticeStyleMap[type as keyof typeof noticeStyleMap] || defaultStyle
return {
...style,
backgroundColor: style.backgroundColor
}
}
const showNotice = (open: boolean) => {
if (open) {
visible.value = open
setTimeout(() => {
show.value = open
}, 5)
} else {
show.value = open
setTimeout(() => {
visible.value = open
}, 350)
}
}
// 查看全部
const handleViewAll = () => {
switch (barActiveIndex.value) {
case 0:
handleNoticeAll()
break
case 1:
handleMsgAll()
break
case 2:
handlePendingAll()
break
}
}
const handleNoticeAll = () => {}
const handleMsgAll = () => {}
const handlePendingAll = () => {}
onMounted(() => {
void notificationStore.refreshUnreadCount().catch(() => undefined)
})
</script>
<style lang="scss" scoped>

View File

@@ -1,262 +1,179 @@
@use '@styles/variables.scss' as *;
@use '@styles/mixin.scss' as *;
.notice-overlay {
position: fixed;
inset: 0;
z-index: 2000;
}
.notice {
position: absolute;
top: 60px;
right: 20px;
width: 360px;
height: 500px;
overflow: hidden;
right: 18px;
width: 390px;
max-width: calc(100vw - 24px);
background: var(--art-main-bg-color);
border: 1px solid var(--art-border-color);
border-radius: calc(var(--custom-radius) / 2 + 6px) !important;
box-shadow:
0 8px 26px -4px hsl(0deg 0% 8% / 15%),
0 8px 9px -5px hsl(0deg 0% 8% / 6%);
transition: all 0.2s;
transform-origin: center top 0;
will-change: top, left;
border-radius: calc(var(--custom-radius) / 2 + 6px);
box-shadow: 0 8px 26px -4px hsl(0deg 0% 8% / 15%);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 15px;
margin-top: 15px;
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--art-border-color);
span {
font-size: 12px;
}
.text {
font-size: 16px;
font-weight: 500;
color: var(--art-gray-800);
}
.btn {
padding: 4px 6px;
cursor: pointer;
user-select: none;
border-radius: 6px;
&:hover {
background-color: var(--art-gray-200);
}
}
.text {
font-size: 16px;
font-weight: 600;
}
.bar {
box-sizing: border-box;
display: flex;
width: 100%;
height: 50px;
padding: 0 15px;
line-height: 50px;
border-bottom: 1px solid var(--art-border-color);
li {
height: 48px;
margin-right: 20px;
overflow: hidden;
font-size: 13px;
color: var(--art-gray-700);
cursor: pointer;
transition: color 0.3s;
@include userSelect;
&:last-of-type {
margin-right: 0;
}
&:hover {
color: var(--art-gray-900);
}
&.active {
color: var(--main-color) !important;
border-bottom: 2px solid var(--main-color);
}
}
.read-all {
padding: 4px 8px;
color: var(--el-color-primary);
cursor: pointer;
background: transparent;
border: 0;
}
}
.content {
width: 100%;
height: calc(100% - 95px);
.bar {
display: flex;
padding: 0 12px;
overflow-x: auto;
border-bottom: 1px solid var(--art-border-color);
.scroll {
height: calc(100% - 60px);
overflow-y: scroll;
button {
flex: 0 0 auto;
padding: 13px 8px 11px;
margin-right: 12px;
color: var(--art-gray-700);
cursor: pointer;
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
&::-webkit-scrollbar {
width: 5px !important;
}
.notice-list {
li {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 15px;
cursor: pointer;
&:hover {
background-color: var(--art-gray-100);
}
&:last-of-type {
border-bottom: 0;
}
.icon {
width: 36px;
height: 36px;
line-height: 36px;
text-align: center;
border-radius: 8px;
i {
font-size: 18px;
background: transparent !important;
}
}
.text {
width: calc(100% - 45px);
margin-left: 15px;
h4 {
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--art-gray-900);
}
p {
margin-top: 5px;
font-size: 12px;
color: var(--art-gray-500);
}
}
}
}
.user-list {
li {
box-sizing: border-box;
display: flex;
align-items: center;
padding: 15px;
cursor: pointer;
&:hover {
background-color: var(--art-gray-100);
}
&:last-of-type {
border-bottom: 0;
}
.avatar {
width: 36px;
height: 36px;
img {
width: 100%;
height: 100%;
border-radius: 8px;
}
}
.text {
width: calc(100% - 45px);
margin-left: 15px;
h4 {
font-size: 13px;
font-weight: 400;
line-height: 22px;
color: var(--art-gray-900);
}
p {
margin-top: 5px;
font-size: 12px;
color: var(--art-gray-500);
}
}
}
}
.base {
li {
box-sizing: border-box;
padding: 15px 20px;
&:last-of-type {
border-bottom: 0;
}
p {
font-size: 12px;
color: var(--art-gray-500);
}
}
}
.empty-tips {
position: relative;
top: 100px;
height: 100%;
color: var(--art-gray-500);
text-align: center;
background: transparent !important;
i {
font-size: 60px;
}
p {
margin-top: 15px;
font-size: 12px;
background: transparent !important;
}
}
}
.btn-wrapper {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 0 15px;
.view-all {
width: 100%;
margin-top: 12px;
}
&.active {
color: var(--el-color-primary);
border-bottom-color: var(--el-color-primary);
}
}
}
.dark {
.notice {
::-webkit-scrollbar-track {
background-color: var(--art-main-bg-color);
.content {
.scroll {
height: 390px;
overflow-y: auto;
}
.notification-item {
display: flex;
gap: 10px;
align-items: flex-start;
width: 100%;
padding: 14px 16px;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
&:hover {
background: var(--el-fill-color-light);
}
::-webkit-scrollbar-thumb {
background-color: #222 !important;
&.unread {
background: color-mix(in srgb, var(--el-color-primary) 5%, transparent);
}
.icon {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--el-color-info);
background: var(--el-color-info-light-9);
border-radius: 8px;
&.severity-warning {
color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}
&.severity-error {
color: var(--el-color-danger);
background: var(--el-color-danger-light-9);
}
&.severity-success {
color: var(--el-color-success);
background: var(--el-color-success-light-9);
}
}
.text {
display: flex;
flex: 1;
flex-direction: column;
gap: 4px;
min-width: 0;
strong,
small,
time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: var(--el-text-color-primary);
}
small,
time {
color: var(--el-text-color-secondary);
}
}
.unread-dot {
flex: 0 0 auto;
width: 7px;
height: 7px;
margin-top: 5px;
background: var(--el-color-danger);
border-radius: 50%;
}
}
.empty-tips {
padding: 70px 20px;
color: var(--el-text-color-secondary);
text-align: center;
i {
font-size: 42px;
}
}
.btn-wrapper {
padding: 12px 16px 16px;
border-top: 1px solid var(--art-border-color);
.view-all {
width: 100%;
}
}
}
@media only screen and (max-width: $device-phone) {
.notice {
top: 65px;
right: 0;
width: 100%;
height: 80vh;
top: 62px;
right: 12px;
left: 12px;
width: auto;
}
}

View File

@@ -0,0 +1,5 @@
export const NOTIFICATION_PERMISSIONS = {
page: 'notifications:view',
read: 'notifications:read',
readAll: 'notifications:read_all'
} as const

View File

@@ -1,6 +1,7 @@
import { RoutesAlias } from '../routesAlias'
import { AppRouteRecord } from '@/types/router'
import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
/**
* 菜单列表、异步路由
@@ -13,6 +14,28 @@ import { BULK_PURCHASE_PERMISSIONS } from '@/config/constants/bulkPurchase'
* 可以是 i18n 的 key也可以是字符串比如'用户列表'
*/
export const asyncRoutes: AppRouteRecord[] = [
// 站内通知
{
path: '/notifications',
name: 'Notifications',
component: RoutesAlias.Home,
meta: {
title: '通知中心'
},
children: [
{
path: '',
name: 'NotificationCenter',
component: RoutesAlias.Notifications,
meta: {
title: '通知中心',
permissions: [NOTIFICATION_PERMISSIONS.page],
keepAlive: true
}
}
]
},
// 仪表台
{
name: 'Dashboard',

View File

@@ -76,6 +76,9 @@ export enum RoutesAlias {
RefundManagement = '/finance/refund', // 退款管理
RefundDetail = '/finance/refund/detail', // 退款详情
// 站内通知
Notifications = '/notifications', // 通知中心
// 佣金管理
WithdrawalApproval = '/commission-management/withdrawal-approval', // 提现审批
MyCommission = '/commission-management/my-commission', // 我的佣金

View File

@@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { NotificationService } from '@/api/modules/notification'
import type { NotificationSummary } from '@/types/api'
const getCountValue = (data: number | { count?: number; unread_count?: number } | undefined) => {
if (typeof data === 'number') return Math.max(0, data)
return Math.max(0, data?.unread_count ?? data?.count ?? 0)
}
export const useNotificationStore = defineStore('notificationStore', () => {
const unreadCount = ref(0)
const summary = ref<NotificationSummary>({ items: [], categories: {}, total_unread: 0 })
const loading = ref(false)
const refreshUnreadCount = async () => {
const response = await NotificationService.getUnreadCount()
if (response.code === 0) {
unreadCount.value = getCountValue(response.data)
}
return unreadCount.value
}
const refreshSummary = async () => {
loading.value = true
try {
const response = await NotificationService.getUnreadSummary()
if (response.code === 0 && response.data) {
summary.value = {
...response.data,
items: (response.data.items || []).slice(0, 10)
}
if (response.data.total_unread !== undefined) {
unreadCount.value = Math.max(0, response.data.total_unread)
}
}
await refreshUnreadCount()
return summary.value
} finally {
loading.value = false
}
}
const markRead = async (id: number | string) => {
const response = await NotificationService.markRead(id)
if (response.code === 0) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
summary.value = {
...summary.value,
items: summary.value.items.map((item) =>
item.id === id ? { ...item, read: true, is_read: true, read_status: 'read' } : item
)
}
await refreshUnreadCount()
}
return response
}
const markAllRead = async () => {
const response = await NotificationService.markAllRead()
if (response.code === 0) {
unreadCount.value = 0
summary.value = {
...summary.value,
total_unread: 0,
categories: Object.fromEntries(
Object.keys(summary.value.categories || {}).map((key) => [key, 0])
),
items: summary.value.items.map((item) => ({
...item,
read: true,
is_read: true,
read_status: 'read'
}))
}
await refreshUnreadCount()
}
return response
}
return {
unreadCount,
summary,
loading,
refreshUnreadCount,
refreshSummary,
markRead,
markAllRead
}
})

View File

@@ -119,3 +119,6 @@ export * from './orderPackageInvalidateTask'
// 批量订购任务相关
export * from './bulkPurchase'
// 站内通知相关
export * from './notification'

View File

@@ -0,0 +1,51 @@
export type NotificationCategory = 'approval' | 'expiry' | 'sync' | 'system' | 'balance' | string
export type NotificationReadStatus = 'read' | 'unread' | string
export interface NotificationItem {
id: number | string
title: string
content: string
category?: NotificationCategory | null
type?: string | null
severity?: string | null
read?: boolean
is_read?: boolean
read_status?: NotificationReadStatus | null
created_at?: string | null
updated_at?: string | null
ref_type?: string | null
ref_id?: number | string | null
[key: string]: unknown
}
export interface NotificationQueryParams {
page?: number
page_size?: number
category?: string
type?: string
severity?: string
read_status?: string
}
export interface NotificationListResponse {
items: NotificationItem[]
total: number
page?: number
page_size?: number
size?: number
}
export interface NotificationSummary {
items: NotificationItem[]
categories?: Record<string, number>
total_unread?: number
}
export interface NotificationTarget {
available?: boolean
route_name?: string | null
route_path?: string | null
route_params?: Record<string, string | number> | null
query?: Record<string, string | number | boolean> | null
message?: string | null
}

View File

@@ -0,0 +1,31 @@
import type { Router } from 'vue-router'
import type { NotificationTarget } from '@/types/api'
export const navigateNotificationTarget = (router: Router, target?: NotificationTarget | null) => {
if (!target || target.available === false) return false
const query = target.query
? Object.fromEntries(Object.entries(target.query).map(([key, value]) => [key, String(value)]))
: undefined
if (target.route_name) {
if (!router.hasRoute(target.route_name)) return false
void router.push({
name: target.route_name,
params: target.route_params || undefined,
query
})
return true
}
const path = target.route_path || ''
if (!path.startsWith('/') || path.startsWith('//') || /^https?:\/\//i.test(path)) return false
const resolved = router.resolve({
path,
query
})
if (!resolved.matched.length || resolved.name === 'Exception404') return false
void router.push(resolved)
return true
}

View File

@@ -0,0 +1,277 @@
<template>
<ArtTableFullScreen>
<div class="notification-center-page" id="table-full-screen">
<ElCard shadow="never" class="art-table-card">
<template #header>
<div class="page-header">
<div>
<h2>通知中心</h2>
<p>查看余额预警临期提醒审批结果和系统告警</p>
</div>
<ElButton
v-if="hasAuth(NOTIFICATION_PERMISSIONS.readAll)"
:loading="markingAllRead"
@click="handleMarkAllRead"
>
全部已读
</ElButton>
</div>
</template>
<div class="filters">
<ElSelect v-model="filters.category" clearable placeholder="通知分类" @change="reload">
<ElOption label="余额预警" value="balance" />
<ElOption label="临期提醒" value="expiry" />
<ElOption label="审批" value="approval" />
<ElOption label="同步/系统" value="system" />
</ElSelect>
<ElSelect v-model="filters.type" clearable placeholder="通知类型" @change="reload">
<ElOption v-for="type in typeOptions" :key="type" :label="type" :value="type" />
</ElSelect>
<ElSelect v-model="filters.severity" clearable placeholder="严重级别" @change="reload">
<ElOption label="提示" value="info" />
<ElOption label="警告" value="warning" />
<ElOption label="严重" value="error" />
<ElOption label="成功" value="success" />
</ElSelect>
<ElSelect v-model="filters.read_status" clearable placeholder="已读状态" @change="reload">
<ElOption label="未读" value="unread" />
<ElOption label="已读" value="read" />
</ElSelect>
<ElButton @click="loadNotifications">刷新</ElButton>
</div>
<ElTable v-loading="loading" :data="notifications" row-key="id" class="notification-table">
<ElTableColumn label="状态" width="90">
<template #default="scope">
<span :class="['read-dot', { unread: !isRead(scope.row) }]" />
{{ isRead(scope.row) ? '已读' : '未读' }}
</template>
</ElTableColumn>
<ElTableColumn prop="title" label="通知标题" min-width="200" show-overflow-tooltip />
<ElTableColumn label="分类" width="120">
<template #default="scope">{{ getCategoryLabel(scope.row.category) }}</template>
</ElTableColumn>
<ElTableColumn label="严重级别" width="110">
<template #default="scope">
<ElTag :type="getSeverityType(scope.row.severity)">
{{ getSeverityLabel(scope.row.severity) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn prop="content" label="通知内容" min-width="300" show-overflow-tooltip />
<ElTableColumn prop="created_at" label="时间" width="180" />
<ElTableColumn label="操作" width="100" fixed="right">
<template #default="scope">
<ElButton
v-if="hasAuth(NOTIFICATION_PERMISSIONS.read)"
link
type="primary"
@click="handleNotificationClick(scope.row)"
>
查看
</ElButton>
<span v-else>-</span>
</template>
</ElTableColumn>
</ElTable>
<div class="pagination-wrapper">
<ElPagination
v-model:current-page="page"
v-model:page-size="pageSize"
layout="total, sizes, prev, pager, next"
:total="total"
@current-change="loadNotifications"
@size-change="reload"
/>
</div>
</ElCard>
</div>
</ArtTableFullScreen>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { NotificationService } from '@/api/modules'
import type { NotificationItem, NotificationQueryParams } from '@/types/api'
import { useNotificationStore } from '@/store/modules/notification'
import { useAuth } from '@/composables/useAuth'
import { NOTIFICATION_PERMISSIONS } from '@/config/constants/notification'
import { navigateNotificationTarget } from '@/utils/business/notificationNavigation'
defineOptions({ name: 'NotificationCenter' })
const router = useRouter()
const notificationStore = useNotificationStore()
const { hasAuth } = useAuth()
const notifications = ref<NotificationItem[]>([])
const loading = ref(false)
const markingAllRead = ref(false)
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const typeOptions = ref<string[]>([])
const filters = reactive<NotificationQueryParams>({
category: undefined,
type: undefined,
severity: undefined,
read_status: undefined
})
const isRead = (item: NotificationItem) =>
item.read === true || item.is_read === true || item.read_status === 'read'
const getCategoryLabel = (category?: string | null) =>
({
balance: '余额预警',
expiry: '临期提醒',
expiring: '临期提醒',
approval: '审批',
sync: '同步/系统',
system: '同步/系统'
})[category || ''] ||
category ||
'-'
const getSeverityLabel = (severity?: string | null) =>
({ info: '提示', warning: '警告', error: '严重', success: '成功' })[severity || ''] ||
severity ||
'-'
const getSeverityType = (severity?: string | null) => {
if (severity === 'error') return 'danger'
if (severity === 'warning') return 'warning'
if (severity === 'success') return 'success'
return 'info'
}
const loadNotifications = async () => {
loading.value = true
try {
const response = await NotificationService.getNotifications({
page: page.value,
page_size: pageSize.value,
...filters
})
if (response.code !== 0 || !response.data) {
ElMessage.error(response.msg || '获取通知列表失败')
return
}
notifications.value = response.data.items || []
total.value = response.data.total || 0
typeOptions.value = Array.from(
new Set(
notifications.value
.map((item) => item.type)
.filter((type): type is string => Boolean(type))
)
)
await notificationStore.refreshUnreadCount()
} catch (error: any) {
ElMessage.error(error?.message || '获取通知列表失败')
} finally {
loading.value = false
}
}
const reload = () => {
page.value = 1
void loadNotifications()
}
const handleMarkAllRead = async () => {
markingAllRead.value = true
try {
const response = await notificationStore.markAllRead()
if (response.code !== 0) ElMessage.error(response.msg || '全部已读失败')
else await loadNotifications()
} catch (error: any) {
ElMessage.error(error?.message || '全部已读失败')
} finally {
markingAllRead.value = false
}
}
const handleNotificationClick = async (item: NotificationItem) => {
try {
const readResponse = await notificationStore.markRead(item.id)
if (readResponse.code !== 0) {
ElMessage.error(readResponse.msg || '标记已读失败')
return
}
const targetResponse = await NotificationService.getTarget(item.id)
if (targetResponse.code !== 0 || !targetResponse.data) {
ElMessage.info(item.content || '该通知暂无可跳转目标')
await loadNotifications()
return
}
if (!navigateNotificationTarget(router, targetResponse.data)) {
ElMessage.info(targetResponse.data.message || item.content || '该通知暂无可跳转目标')
}
await loadNotifications()
} catch (error: any) {
ElMessage.error(error?.message || '处理通知失败')
}
}
onMounted(() => {
void loadNotifications()
})
</script>
<style scoped lang="scss">
.notification-center-page {
.page-header,
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.page-header {
justify-content: space-between;
h2 {
margin: 0;
font-size: 20px;
}
p {
margin: 8px 0 0;
color: var(--el-text-color-secondary);
}
}
.filters {
flex-wrap: wrap;
margin-bottom: 16px;
.el-select {
width: 160px;
}
}
.read-dot {
display: inline-block;
width: 8px;
height: 8px;
margin-right: 6px;
vertical-align: middle;
background: var(--el-border-color);
border-radius: 50%;
&.unread {
background: var(--el-color-danger);
}
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
}
</style>