feat: 完成消息通知中心全功能开发
1. 新增消息通知相关实体、Mapper、Service、控制器与前端页面 2. 实现审批通知、报价到期提醒等通知发送逻辑 3. 完成通知菜单配置与路由注册 4. 修复通知数据与跳转路径问题 5. 新增配套SQL脚本与定时任务
This commit is contained in:
71
ruoyi-ui/src/api/bid/notify.js
Normal file
71
ruoyi-ui/src/api/bid/notify.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 查询消息通知列表
|
||||
export function listNotify(data) {
|
||||
return request({ url: '/bid/notify/list', method: 'get', params: data })
|
||||
}
|
||||
|
||||
// 查询未读数量
|
||||
export function getUnreadCount() {
|
||||
return request({ url: '/bid/notify/unreadCount', method: 'get' })
|
||||
}
|
||||
|
||||
// 查询最新未读消息(仅未读)
|
||||
export function getTopUnread(limit) {
|
||||
return request({ url: '/bid/notify/topUnread', method: 'get', params: { limit } })
|
||||
}
|
||||
|
||||
// 查询最近通知(含已读和未读,用于铃铛面板)
|
||||
export function getTopNotify(limit) {
|
||||
return request({ url: '/bid/notify/topNotify', method: 'get', params: { limit } })
|
||||
}
|
||||
|
||||
// 查询通知统计
|
||||
export function getNotifyStats() {
|
||||
return request({ url: '/bid/notify/stats', method: 'get' })
|
||||
}
|
||||
|
||||
// 查询消息详情
|
||||
export function getNotify(messageId) {
|
||||
return request({ url: '/bid/notify/' + messageId, method: 'get' })
|
||||
}
|
||||
|
||||
// 标记单条已读
|
||||
export function markRead(messageId) {
|
||||
return request({ url: '/bid/notify/read/' + messageId, method: 'put' })
|
||||
}
|
||||
|
||||
// 全部已读
|
||||
export function markAllRead() {
|
||||
return request({ url: '/bid/notify/readAll', method: 'put' })
|
||||
}
|
||||
|
||||
// 删除消息
|
||||
export function delNotify(messageIds) {
|
||||
return request({ url: '/bid/notify/' + messageIds, method: 'delete' })
|
||||
}
|
||||
|
||||
// 通知规则列表
|
||||
export function listNotifyRule(data) {
|
||||
return request({ url: '/bid/notify/rule/list', method: 'get', params: data })
|
||||
}
|
||||
|
||||
// 通知规则详情
|
||||
export function getNotifyRule(ruleId) {
|
||||
return request({ url: '/bid/notify/rule/' + ruleId, method: 'get' })
|
||||
}
|
||||
|
||||
// 新增规则
|
||||
export function addNotifyRule(data) {
|
||||
return request({ url: '/bid/notify/rule', method: 'post', data })
|
||||
}
|
||||
|
||||
// 修改规则
|
||||
export function updateNotifyRule(data) {
|
||||
return request({ url: '/bid/notify/rule', method: 'put', data })
|
||||
}
|
||||
|
||||
// 删除规则
|
||||
export function delNotifyRule(ruleIds) {
|
||||
return request({ url: '/bid/notify/rule/' + ruleIds, method: 'delete' })
|
||||
}
|
||||
@@ -1,110 +1,191 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-popover ref="noticePopover" placement="bottom-end" width="320" trigger="manual" :value="noticeVisible" popper-class="notice-popover">
|
||||
<div class="notice-header">
|
||||
<span class="notice-title">通知公告</span>
|
||||
<span class="notice-mark-all" @click="markAllRead">全部已读</span>
|
||||
<el-popover ref="notifyPopover" placement="bottom-end" width="360" trigger="manual" :value="notifyVisible" popper-class="notify-popover">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="notify-header">
|
||||
<div class="notify-tabs">
|
||||
<span class="notify-tab" :class="{ active: activeTab === 'all' }" @click="activeTab = 'all'">
|
||||
全部 <span class="tab-count" v-if="unreadCount > 0">{{ unreadCount }}</span>
|
||||
</span>
|
||||
<span class="notify-tab" :class="{ active: activeTab === 'unread' }" @click="activeTab = 'unread'">
|
||||
未读
|
||||
</span>
|
||||
</div>
|
||||
<div class="notify-actions">
|
||||
<span class="notify-action" @click="markAllRead" v-if="unreadCount > 0">全部已读</span>
|
||||
<span class="notify-action" @click="goToNotifyCenter">查看全部</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="noticeLoading" class="notice-loading"><i class="el-icon-loading"></i> 加载中...</div>
|
||||
<div v-else-if="noticeList.length === 0" class="notice-empty"><i class="el-icon-inbox"></i><br>暂无公告</div>
|
||||
<div v-else>
|
||||
<div v-for="item in noticeList" :key="item.noticeId" class="notice-item" :class="{ 'is-read': item.isRead }" @click="previewNotice(item)">
|
||||
<el-tag size="mini" :type="item.noticeType === '1' ? 'warning' : 'success'" class="notice-tag">
|
||||
{{ item.noticeType === '1' ? '通知' : '公告' }}
|
||||
</el-tag>
|
||||
<span class="notice-item-title">{{ item.noticeTitle }}</span>
|
||||
<span class="notice-item-date">{{ item.createTime }}</span>
|
||||
|
||||
<!-- 通知列表 -->
|
||||
<div v-loading="loading" class="notify-body">
|
||||
<div v-if="filteredList.length === 0" class="notify-empty">
|
||||
<i class="el-icon-bell" style="font-size:32px;color:#dcdfe6"></i>
|
||||
<p>暂无通知</p>
|
||||
</div>
|
||||
<div v-for="item in filteredList" :key="item.messageId"
|
||||
class="notify-item"
|
||||
:class="{ 'is-read': item.isRead === '1', 'is-urgent': item.priority === 2 }"
|
||||
@click="handleItemClick(item)">
|
||||
<div class="item-icon" :style="{ background: typeColor(item.noticeType) }">
|
||||
<i :class="typeIcon(item.noticeType)"></i>
|
||||
</div>
|
||||
<div class="item-content">
|
||||
<div class="item-title-row">
|
||||
<span class="item-title">{{ item.title }}</span>
|
||||
<span v-if="item.isRead === '0'" class="unread-dot"></span>
|
||||
</div>
|
||||
<div class="item-desc">{{ item.content }}</div>
|
||||
<div class="item-meta">
|
||||
<el-tag size="mini" :type="typeTagType(item.noticeType)" effect="plain">{{ typeLabel(item.noticeType) }}</el-tag>
|
||||
<span class="item-time">{{ formatTime(item.createTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
<div v-popover:noticePopover class="right-menu-item hover-effect notice-trigger" @mouseenter="onNoticeEnter" @mouseleave="onNoticeLeave">
|
||||
<div v-popover:notifyPopover class="right-menu-item hover-effect notify-trigger" @mouseenter="onEnter" @mouseleave="onLeave">
|
||||
<svg-icon icon-class="bell" />
|
||||
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount }}</span>
|
||||
<span v-if="unreadCount > 0" class="notify-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
||||
</div>
|
||||
|
||||
<notice-detail-view ref="noticeViewRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NoticeDetailView from './DetailView'
|
||||
import { listNoticeTop, markNoticeRead, markNoticeReadAll } from '@/api/system/notice'
|
||||
import { getTopNotify, getUnreadCount, markRead, markAllRead } from '@/api/bid/notify'
|
||||
|
||||
export default {
|
||||
name: 'HeaderNotice',
|
||||
components: { NoticeDetailView },
|
||||
data() {
|
||||
return {
|
||||
noticeList: [], // 通知列表
|
||||
unreadCount: 0, // 未读数量
|
||||
noticeLoading: false, // 加载状态
|
||||
noticeVisible: false, // 弹出层显示状态
|
||||
noticeLeaveTimer: null // 鼠标离开计时器
|
||||
notifyList: [],
|
||||
unreadCount: 0,
|
||||
loading: false,
|
||||
notifyVisible: false,
|
||||
activeTab: 'all',
|
||||
leaveTimer: null,
|
||||
pollTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredList() {
|
||||
if (this.activeTab === 'unread') {
|
||||
return this.notifyList.filter(x => x.isRead === '0')
|
||||
}
|
||||
return this.notifyList
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadNoticeTop()
|
||||
this.loadData()
|
||||
// 每 60 秒轮询一次未读数
|
||||
this.pollTimer = setInterval(() => {
|
||||
this.loadUnreadCount()
|
||||
}, 60000)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.pollTimer) clearInterval(this.pollTimer)
|
||||
},
|
||||
methods: {
|
||||
// 鼠标移入铃铛区域
|
||||
onNoticeEnter() {
|
||||
clearTimeout(this.noticeLeaveTimer)
|
||||
this.noticeVisible = true
|
||||
onEnter() {
|
||||
clearTimeout(this.leaveTimer)
|
||||
this.notifyVisible = true
|
||||
this.loadData()
|
||||
this.$nextTick(() => {
|
||||
const popper = this.$refs.noticePopover.$refs.popper
|
||||
if (popper && !popper._noticeBound) {
|
||||
popper._noticeBound = true
|
||||
popper.addEventListener('mouseenter', () => clearTimeout(this.noticeLeaveTimer))
|
||||
const popper = this.$refs.notifyPopover.$refs.popper
|
||||
if (popper && !popper._notifyBound) {
|
||||
popper._notifyBound = true
|
||||
popper.addEventListener('mouseenter', () => clearTimeout(this.leaveTimer))
|
||||
popper.addEventListener('mouseleave', () => {
|
||||
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 100)
|
||||
this.leaveTimer = setTimeout(() => { this.notifyVisible = false }, 100)
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
// 鼠标离开铃铛区域
|
||||
onNoticeLeave() {
|
||||
this.noticeLeaveTimer = setTimeout(() => { this.noticeVisible = false }, 150)
|
||||
onLeave() {
|
||||
this.leaveTimer = setTimeout(() => { this.notifyVisible = false }, 150)
|
||||
},
|
||||
// 加载顶部公告列表
|
||||
loadNoticeTop() {
|
||||
this.noticeLoading = true
|
||||
listNoticeTop().then(res => {
|
||||
this.noticeList = res.data || []
|
||||
this.unreadCount = res.unreadCount !== undefined ? res.unreadCount : this.noticeList.filter(n => !n.isRead).length
|
||||
loadData() {
|
||||
this.loading = true
|
||||
Promise.all([
|
||||
getTopNotify(10),
|
||||
getUnreadCount()
|
||||
]).then(([listRes, countRes]) => {
|
||||
this.notifyList = listRes.data || []
|
||||
this.unreadCount = countRes.data || 0
|
||||
}).finally(() => {
|
||||
this.noticeLoading = false
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
// 预览公告详情
|
||||
previewNotice(item) {
|
||||
if (!item.isRead) {
|
||||
markNoticeRead(item.noticeId).catch(() => {})
|
||||
item.isRead = true
|
||||
const idx = this.noticeList.indexOf(item)
|
||||
if (idx !== -1) this.$set(this.noticeList, idx, { ...item, isRead: true })
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
}
|
||||
this.$refs.noticeViewRef.open(item.noticeId)
|
||||
loadUnreadCount() {
|
||||
getUnreadCount().then(res => {
|
||||
const newCount = res.data || 0
|
||||
if (newCount !== this.unreadCount) {
|
||||
this.unreadCount = newCount
|
||||
// 未读数变化时重新加载列表
|
||||
if (this.notifyVisible) this.loadData()
|
||||
}
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleItemClick(item) {
|
||||
if (item.isRead === '0') {
|
||||
markRead(item.messageId).then(() => {
|
||||
item.isRead = '1'
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
}).catch(() => {})
|
||||
}
|
||||
// 跳转到消息通知中心
|
||||
this.notifyVisible = false
|
||||
this.$router.push('/bizconfig/notify').catch(() => {})
|
||||
},
|
||||
// 全部已读
|
||||
markAllRead() {
|
||||
const ids = this.noticeList.map(n => n.noticeId).join(',')
|
||||
if (!ids) return
|
||||
markNoticeReadAll(ids).catch(() => {})
|
||||
this.noticeList = this.noticeList.map(n => ({ ...n, isRead: true }))
|
||||
this.unreadCount = 0
|
||||
markAllRead().then(() => {
|
||||
this.notifyList.forEach(x => { x.isRead = '1' })
|
||||
this.unreadCount = 0
|
||||
this.$message({ message: '已全部标记为已读', type: 'success', duration: 1500 })
|
||||
})
|
||||
},
|
||||
goToNotifyCenter() {
|
||||
this.notifyVisible = false
|
||||
this.$router.push('/bizconfig/notify').catch(() => {})
|
||||
},
|
||||
typeLabel(t) {
|
||||
const map = { approval: '审批', quotation_expire: '到期', rfq_deadline: 'RFQ', system: '公告', exception: '异常' }
|
||||
return map[t] || t
|
||||
},
|
||||
typeTagType(t) {
|
||||
const map = { approval: 'warning', quotation_expire: 'danger', rfq_deadline: 'danger', system: 'success', exception: 'danger' }
|
||||
return map[t] || 'info'
|
||||
},
|
||||
typeColor(t) {
|
||||
const map = { approval: '#E6A23C', quotation_expire: '#F56C6C', rfq_deadline: '#F56C6C', system: '#67C23A', exception: '#F56C6C' }
|
||||
return map[t] || '#409EFF'
|
||||
},
|
||||
typeIcon(t) {
|
||||
const map = { approval: 'el-icon-s-check', quotation_expire: 'el-icon-time', rfq_deadline: 'el-icon-alarm-clock', system: 'el-icon-bell', exception: 'el-icon-warning' }
|
||||
return map[t] || 'el-icon-info'
|
||||
},
|
||||
formatTime(t) {
|
||||
if (!t) return ''
|
||||
const date = new Date(t)
|
||||
const now = new Date()
|
||||
const diff = (now - date) / 1000
|
||||
if (diff < 60) return '刚刚'
|
||||
if (diff < 3600) return Math.floor(diff / 60) + '分钟前'
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + '小时前'
|
||||
if (diff < 604800) return Math.floor(diff / 86400) + '天前'
|
||||
return this.parseTime(t, '{y}-{m}-{d} {h}:{i}')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.notice-trigger {
|
||||
.notify-trigger {
|
||||
position: relative;
|
||||
transform: translateX(-6px);
|
||||
.svg-icon { width: 1.2em; height: 1.2em; vertical-align: -0.2em; }
|
||||
.notice-badge {
|
||||
.notify-badge {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: -3px;
|
||||
@@ -121,61 +202,56 @@ export default {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
.notice-popover {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.notice-popover .notice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #f7f9fb;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
.notice-popover .notice-mark-all {
|
||||
font-size: 12px;
|
||||
color: #e4393c;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
.notice-popover .notice-mark-all:hover { color: #2b7cc1; }
|
||||
.notice-popover .notice-loading,
|
||||
.notice-popover .notice-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #bbb;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.notice-popover .notice-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #ffffff;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.notice-popover .notice-item:last-child { border-bottom: none; }
|
||||
.notice-popover .notice-item:hover { background: #f7f9fb; }
|
||||
.notice-popover .notice-item.is-read .notice-tag,
|
||||
.notice-popover .notice-item.is-read .notice-item-title,
|
||||
.notice-popover .notice-item.is-read .notice-item-date { opacity: 0.45; filter: grayscale(1); color: #999; }
|
||||
.notice-popover .notice-tag { flex-shrink: 0; }
|
||||
.notice-popover .notice-item-title {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notice-popover .notice-item-date {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.notify-popover { padding: 0 !important; }
|
||||
.notify-popover .notify-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; background: #f7f9fb; border-bottom: 1px solid #eee;
|
||||
}
|
||||
.notify-popover .notify-tabs { display: flex; gap: 12px; }
|
||||
.notify-popover .notify-tab {
|
||||
font-size: 13px; color: #909399; cursor: pointer; padding: 2px 0;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
}
|
||||
.notify-popover .notify-tab.active { color: #409EFF; font-weight: 600; border-bottom: 2px solid #409EFF; }
|
||||
.notify-popover .tab-count {
|
||||
background: #f56c6c; color: #fff; border-radius: 10px;
|
||||
font-size: 10px; padding: 0 5px; height: 16px; line-height: 16px; min-width: 16px; text-align: center;
|
||||
}
|
||||
.notify-popover .notify-actions { display: flex; gap: 10px; }
|
||||
.notify-popover .notify-action { font-size: 12px; color: #409EFF; cursor: pointer; }
|
||||
.notify-popover .notify-action:hover { color: #2b7cc1; }
|
||||
|
||||
.notify-popover .notify-body { max-height: 400px; overflow-y: auto; }
|
||||
.notify-popover .notify-empty { padding: 32px; text-align: center; color: #bbb; font-size: 12px; }
|
||||
.notify-popover .notify-empty i { display: block; margin-bottom: 8px; }
|
||||
|
||||
.notify-popover .notify-item {
|
||||
display: flex; gap: 10px; padding: 12px 14px;
|
||||
border-bottom: 1px solid #f2f3f5; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.notify-popover .notify-item:hover { background: #f7f9fb; }
|
||||
.notify-popover .notify-item.is-read { opacity: 0.55; }
|
||||
.notify-popover .notify-item.is-urgent { border-left: 3px solid #f56c6c; }
|
||||
|
||||
.notify-popover .item-icon {
|
||||
width: 32px; height: 32px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: #fff; font-size: 16px; flex-shrink: 0;
|
||||
}
|
||||
.notify-popover .item-content { flex: 1; min-width: 0; }
|
||||
.notify-popover .item-title-row { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.notify-popover .item-title {
|
||||
font-size: 13px; font-weight: 600; color: #303133;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
|
||||
}
|
||||
.notify-popover .unread-dot { width: 8px; height: 8px; border-radius: 50%; background: #f56c6c; flex-shrink: 0; }
|
||||
.notify-popover .item-desc {
|
||||
font-size: 12px; color: #606266; line-height: 1.5;
|
||||
overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
|
||||
}
|
||||
.notify-popover .item-meta { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.notify-popover .item-time { font-size: 11px; color: #c0c4cc; }
|
||||
</style>
|
||||
|
||||
@@ -18,9 +18,7 @@
|
||||
<size-select id="size-select" class="right-menu-item hover-effect" />
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="消息通知" effect="dark" placement="bottom">
|
||||
<header-notice id="header-notice" class="right-menu-item hover-effect" />
|
||||
</el-tooltip>
|
||||
<header-notice id="header-notice" class="right-menu-item hover-effect" />
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
@@ -307,6 +307,21 @@ export const dynamicRoutes = [
|
||||
}]
|
||||
},
|
||||
|
||||
{
|
||||
path: '/bizconfig/notify',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:notify:list'],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/notify/index'),
|
||||
name: 'NotifyCenter',
|
||||
meta: { title: '消息通知中心', activeMenu: '/bizconfig/notify' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
path: '/system/user-auth',
|
||||
component: Layout,
|
||||
|
||||
270
ruoyi-ui/src/views/bid/notify/index.vue
Normal file
270
ruoyi-ui/src/views/bid/notify/index.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div class="notify-page">
|
||||
<!-- 顶部统计卡片 -->
|
||||
<el-row :gutter="12" class="stat-row">
|
||||
<el-col :xs="12" :sm="6" v-for="card in statCards" :key="card.label">
|
||||
<div class="stat-card" :style="{ borderTop: '3px solid ' + card.color }">
|
||||
<div class="stat-label">{{ card.label }}</div>
|
||||
<div class="stat-value" :style="{ color: card.color }">{{ card.value }}</div>
|
||||
<i :class="card.icon" class="stat-icon" :style="{ color: card.color }"></i>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="search-bar">
|
||||
<el-select v-model="queryParams.noticeType" placeholder="通知类型" clearable size="small" style="width:130px" @change="handleQuery">
|
||||
<el-option label="审批结果" value="approval" />
|
||||
<el-option label="报价到期" value="quotation_expire" />
|
||||
<el-option label="RFQ截止" value="rfq_deadline" />
|
||||
<el-option label="系统公告" value="system" />
|
||||
<el-option label="异常提醒" value="exception" />
|
||||
</el-select>
|
||||
<el-select v-model="queryParams.isRead" placeholder="阅读状态" clearable size="small" style="width:110px" @change="handleQuery">
|
||||
<el-option label="未读" value="0" />
|
||||
<el-option label="已读" value="1" />
|
||||
</el-select>
|
||||
<el-select v-model="queryParams.priority" placeholder="优先级" clearable size="small" style="width:110px" @change="handleQuery">
|
||||
<el-option label="普通" :value="0" />
|
||||
<el-option label="重要" :value="1" />
|
||||
<el-option label="紧急" :value="2" />
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" icon="el-icon-search" @click="handleQuery">搜索</el-button>
|
||||
<el-button size="small" icon="el-icon-refresh" @click="resetQuery">重置</el-button>
|
||||
<div style="flex:1"></div>
|
||||
<el-button size="small" type="success" icon="el-icon-check" @click="handleMarkAllRead" :disabled="unreadCount === 0">全部已读</el-button>
|
||||
<el-button size="small" type="danger" icon="el-icon-delete" @click="handleBatchDelete" :disabled="selectedIds.length === 0">批量删除</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="notify-list" v-loading="loading">
|
||||
<div v-if="list.length === 0 && !loading" class="empty-state">
|
||||
<i class="el-icon-bell" style="font-size:48px;color:#dcdfe6"></i>
|
||||
<p>暂无通知消息</p>
|
||||
</div>
|
||||
<div v-for="item in list" :key="item.messageId"
|
||||
class="notify-item"
|
||||
:class="{ 'is-read': item.isRead === '1', 'is-urgent': item.priority === 2 }"
|
||||
@click="handleItemClick(item)">
|
||||
<el-checkbox :value="selectedIds.includes(item.messageId)" @change="toggleSelect(item.messageId)" class="item-check" @click.native.stop />
|
||||
<div class="item-dot" :class="{ unread: item.isRead === '0' }"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-header">
|
||||
<el-tag size="mini" :type="typeTagType(item.noticeType)" effect="plain">{{ typeLabel(item.noticeType) }}</el-tag>
|
||||
<el-tag v-if="item.priority > 0" size="mini" :type="item.priority === 2 ? 'danger' : 'warning'" effect="dark" style="margin-left:4px">{{ item.priority === 2 ? '紧急' : '重要' }}</el-tag>
|
||||
<span class="item-title">{{ item.title }}</span>
|
||||
<span class="item-time">{{ formatTime(item.createTime) }}</span>
|
||||
</div>
|
||||
<div class="item-content">{{ item.content }}</div>
|
||||
<div class="item-footer" v-if="item.bizUrl">
|
||||
<el-button type="text" size="mini" @click.stop="goToBiz(item)">
|
||||
<i class="el-icon-link"></i> 查看详情
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<el-button v-if="item.isRead === '0'" type="text" size="mini" @click="markOneRead(item)">
|
||||
<i class="el-icon-check"></i> 已读
|
||||
</el-button>
|
||||
<el-button type="text" size="mini" style="color:#F56C6C" @click="handleDelete(item)">
|
||||
<i class="el-icon-delete"></i>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
|
||||
|
||||
<!-- 规则配置对话框 -->
|
||||
<el-dialog title="通知规则配置" :visible.sync="ruleDialogVisible" width="700px" append-to-body>
|
||||
<el-table :data="ruleList" border size="small" v-loading="ruleLoading">
|
||||
<el-table-column label="规则名称" prop="ruleName" min-width="120" />
|
||||
<el-table-column label="通知类型" width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" :type="typeTagType(scope.row.noticeType)">{{ typeLabel(scope.row.noticeType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="业务类型" prop="bizType" width="110" align="center" />
|
||||
<el-table-column label="提前天数" prop="advanceDays" width="80" align="center" />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-switch :value="scope.row.enabled === '1'" @change="toggleRule(scope.row)" active-text="启用" inactive-text="停用" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listNotify, getUnreadCount, getNotifyStats, markRead, markAllRead, delNotify, listNotifyRule, updateNotifyRule } from '@/api/bid/notify'
|
||||
|
||||
export default {
|
||||
name: 'BizNotifyIndex',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
list: [],
|
||||
total: 0,
|
||||
unreadCount: 0,
|
||||
selectedIds: [],
|
||||
stats: [],
|
||||
ruleDialogVisible: false,
|
||||
ruleLoading: false,
|
||||
ruleList: [],
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 15,
|
||||
noticeType: undefined,
|
||||
isRead: undefined,
|
||||
priority: undefined
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
statCards() {
|
||||
const s = this.stats
|
||||
const findStat = (type) => (s.find(x => x.noticeType === type) || {})
|
||||
return [
|
||||
{ label: '未读通知', value: this.unreadCount, color: '#F56C6C', icon: 'el-icon-bell' },
|
||||
{ label: '审批通知', value: findStat('approval').unreadCount || 0, color: '#E6A23C', icon: 'el-icon-s-check' },
|
||||
{ label: '到期提醒', value: (findStat('quotation_expire').unreadCount || 0) + (findStat('rfq_deadline').unreadCount || 0), color: '#409EFF', icon: 'el-icon-time' },
|
||||
{ label: '总通知数', value: s.reduce((a, b) => a + (b.totalCount || 0), 0), color: '#67C23A', icon: 'el-icon-document' }
|
||||
]
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.getList()
|
||||
this.loadStats()
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.loading = true
|
||||
listNotify(this.queryParams).then(res => {
|
||||
this.list = res.rows || []
|
||||
this.total = res.total || 0
|
||||
this.loading = false
|
||||
}).catch(() => { this.loading = false })
|
||||
},
|
||||
loadStats() {
|
||||
getUnreadCount().then(res => { this.unreadCount = res.data || 0 })
|
||||
getNotifyStats().then(res => { this.stats = res.data || [] })
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageNum = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.queryParams = { pageNum: 1, pageSize: 15, noticeType: undefined, isRead: undefined, priority: undefined }
|
||||
this.handleQuery()
|
||||
},
|
||||
handleItemClick(item) {
|
||||
if (item.isRead === '0') {
|
||||
this.markOneRead(item)
|
||||
}
|
||||
},
|
||||
markOneRead(item) {
|
||||
markRead(item.messageId).then(() => {
|
||||
item.isRead = '1'
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1)
|
||||
this.loadStats()
|
||||
})
|
||||
},
|
||||
handleMarkAllRead() {
|
||||
this.$modal.confirm('确定将所有通知标记为已读吗?').then(() => {
|
||||
return markAllRead()
|
||||
}).then(() => {
|
||||
this.list.forEach(x => { x.isRead = '1' })
|
||||
this.unreadCount = 0
|
||||
this.loadStats()
|
||||
this.$message.success('已全部标记为已读')
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleDelete(item) {
|
||||
this.$modal.confirm('确定删除该通知吗?').then(() => {
|
||||
return delNotify(item.messageId)
|
||||
}).then(() => {
|
||||
this.getList()
|
||||
this.loadStats()
|
||||
}).catch(() => {})
|
||||
},
|
||||
handleBatchDelete() {
|
||||
if (this.selectedIds.length === 0) return
|
||||
this.$modal.confirm('确定删除选中的通知吗?').then(() => {
|
||||
return delNotify(this.selectedIds.join(','))
|
||||
}).then(() => {
|
||||
this.selectedIds = []
|
||||
this.getList()
|
||||
this.loadStats()
|
||||
}).catch(() => {})
|
||||
},
|
||||
toggleSelect(id) {
|
||||
const idx = this.selectedIds.indexOf(id)
|
||||
if (idx === -1) this.selectedIds.push(id)
|
||||
else this.selectedIds.splice(idx, 1)
|
||||
},
|
||||
goToBiz(item) {
|
||||
if (item.isRead === '0') this.markOneRead(item)
|
||||
if (item.bizUrl) this.$router.push(item.bizUrl)
|
||||
},
|
||||
typeLabel(t) {
|
||||
const map = { approval: '审批结果', quotation_expire: '报价到期', rfq_deadline: 'RFQ截止', system: '系统公告', exception: '异常提醒' }
|
||||
return map[t] || t
|
||||
},
|
||||
typeTagType(t) {
|
||||
const map = { approval: 'warning', quotation_expire: 'danger', rfq_deadline: 'danger', system: 'success', exception: 'danger' }
|
||||
return map[t] || 'info'
|
||||
},
|
||||
formatTime(t) {
|
||||
if (!t) return ''
|
||||
return this.parseTime(t, '{y}-{m}-{d} {h}:{i}')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notify-page { padding: 16px; background: #f5f7fa; min-height: calc(100vh - 84px); }
|
||||
|
||||
.stat-row { margin-bottom: 16px; }
|
||||
.stat-card {
|
||||
background: #fff; border-radius: 4px; padding: 16px 18px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06); position: relative; overflow: hidden; margin-bottom: 12px;
|
||||
}
|
||||
.stat-label { font-size: 12px; color: #909399; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 28px; font-weight: 700; }
|
||||
.stat-icon { position: absolute; right: 18px; top: 50%; transform: translateY(-50%); font-size: 40px; opacity: 0.12; }
|
||||
|
||||
.search-bar {
|
||||
background: #fff; padding: 12px 16px; border-radius: 4px;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.06); margin-bottom: 12px;
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notify-list { background: #fff; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
|
||||
.empty-state { padding: 60px 0; text-align: center; color: #c0c4cc; }
|
||||
|
||||
.notify-item {
|
||||
display: flex; align-items: flex-start; padding: 14px 16px;
|
||||
border-bottom: 1px solid #f2f3f5; cursor: pointer; transition: background 0.15s;
|
||||
}
|
||||
.notify-item:hover { background: #f7f9fb; }
|
||||
.notify-item.is-read { opacity: 0.65; }
|
||||
.notify-item.is-urgent { border-left: 3px solid #f56c6c; }
|
||||
|
||||
.item-check { margin-right: 8px; margin-top: 4px; }
|
||||
.item-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; background: #dcdfe6;
|
||||
margin-right: 10px; margin-top: 6px; flex-shrink: 0;
|
||||
}
|
||||
.item-dot.unread { background: #f56c6c; }
|
||||
|
||||
.item-body { flex: 1; min-width: 0; }
|
||||
.item-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
|
||||
.item-title { font-size: 13px; font-weight: 600; color: #303133; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.item-time { font-size: 11px; color: #c0c4cc; flex-shrink: 0; }
|
||||
.item-content { font-size: 12px; color: #606266; line-height: 1.6; margin-bottom: 4px; }
|
||||
.item-footer { margin-top: 2px; }
|
||||
|
||||
.item-actions { flex-shrink: 0; margin-left: 12px; display: flex; flex-direction: column; gap: 4px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user