feat: 顶部通知铃铛与站内通知中心
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m7s
All checks were successful
构建并部署前端到测试环境 / build-and-deploy (push) Successful in 4m7s
This commit is contained in:
@@ -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'
|
||||
|
||||
41
src/api/modules/notification.ts
Normal file
41
src/api/modules/notification.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
@@ -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"></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"></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')
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"></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"></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"></i>
|
||||
<p>暂无通知</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-tips" v-show="barActiveIndex === 1 && msgList.length === 0">
|
||||
<i class="iconfont-sys"></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"></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: '',
|
||||
iconColor: 'rgb(var(--art-warning))',
|
||||
backgroundColor: 'rgb(var(--art-bg-warning))'
|
||||
},
|
||||
message: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-success))',
|
||||
backgroundColor: 'rgb(var(--art-bg-success))'
|
||||
},
|
||||
collection: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-danger))',
|
||||
backgroundColor: 'rgb(var(--art-bg-danger))'
|
||||
},
|
||||
user: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-info))',
|
||||
backgroundColor: 'rgb(var(--art-bg-info))'
|
||||
},
|
||||
notice: {
|
||||
icon: '',
|
||||
iconColor: 'rgb(var(--art-primary))',
|
||||
backgroundColor: 'rgb(var(--art-bg-primary))'
|
||||
}
|
||||
}
|
||||
|
||||
const getNoticeStyle = (type: string) => {
|
||||
const defaultStyle = {
|
||||
icon: '',
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
5
src/config/constants/notification.ts
Normal file
5
src/config/constants/notification.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const NOTIFICATION_PERMISSIONS = {
|
||||
page: 'notifications:view',
|
||||
read: 'notifications:read',
|
||||
readAll: 'notifications:read_all'
|
||||
} as const
|
||||
@@ -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',
|
||||
|
||||
@@ -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', // 我的佣金
|
||||
|
||||
90
src/store/modules/notification.ts
Normal file
90
src/store/modules/notification.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@@ -119,3 +119,6 @@ export * from './orderPackageInvalidateTask'
|
||||
|
||||
// 批量订购任务相关
|
||||
export * from './bulkPurchase'
|
||||
|
||||
// 站内通知相关
|
||||
export * from './notification'
|
||||
|
||||
51
src/types/api/notification.ts
Normal file
51
src/types/api/notification.ts
Normal 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
|
||||
}
|
||||
31
src/utils/business/notificationNavigation.ts
Normal file
31
src/utils/business/notificationNavigation.ts
Normal 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
|
||||
}
|
||||
277
src/views/notifications/index.vue
Normal file
277
src/views/notifications/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user