From 1411afe6c282457c3d7f8a8b069a6c93c9e15b27 Mon Sep 17 00:00:00 2001
From: spring <2396852758@qq.com>
Date: 星期二, 06 一月 2026 13:28:09 +0800
Subject: [PATCH] 完成消息通知前端页面

---
 src/layout/components/NotificationCenter/index.vue |  331 +++++++++++++++++++++++++++++++++++++++++
 src/layout/components/Navbar.vue                   |   92 +++++++++++
 src/api/system/message.js                          |   44 +++++
 src/layout/components/index.js                     |    1 
 4 files changed, 468 insertions(+), 0 deletions(-)

diff --git a/src/api/system/message.js b/src/api/system/message.js
new file mode 100644
index 0000000..d2f3ac6
--- /dev/null
+++ b/src/api/system/message.js
@@ -0,0 +1,44 @@
+import request from "@/utils/request";
+
+// 鏌ヨ娑堟伅閫氱煡鍒楄〃
+export function listMessage(query) {
+  return request({
+    url: "/system/message/list",
+    method: "get",
+    params: query,
+  });
+}
+
+// 鏌ヨ鏈娑堟伅鏁伴噺
+export function getUnreadCount() {
+  return request({
+    url: "/system/message/unreadCount",
+    method: "get",
+  });
+}
+
+// 鏍囪娑堟伅涓哄凡璇�
+export function markAsRead(messageId) {
+  return request({
+    url: "/system/message/markAsRead",
+    method: "post",
+    data: { messageId },
+  });
+}
+
+// 涓�閿爣璁版墍鏈夋秷鎭负宸茶
+export function markAllAsRead() {
+  return request({
+    url: "/system/message/markAllAsRead",
+    method: "post",
+  });
+}
+
+// 纭娑堟伅
+export function confirmMessage(messageId) {
+  return request({
+    url: "/system/message/confirm",
+    method: "post",
+    data: { messageId },
+  });
+}
diff --git a/src/layout/components/Navbar.vue b/src/layout/components/Navbar.vue
index 1f0d385..5e73968 100644
--- a/src/layout/components/Navbar.vue
+++ b/src/layout/components/Navbar.vue
@@ -24,6 +24,29 @@
       </el-dropdown>
     </div>
     <div class="right-menu">
+      <!-- 娑堟伅閫氱煡 -->
+      <el-popover
+        v-model:visible="notificationVisible"
+        :width="500"
+        placement="bottom-end"
+        trigger="click"
+        :popper-options="{ modifiers: [{ name: 'offset', options: { offset: [0, 10] } }] }"
+        popper-class="notification-popover"
+      >
+        <template #reference>
+          <div class="notification-container right-menu-item hover-effect">
+            <el-badge :value="unreadCount" :hidden="unreadCount === 0" class="notification-badge">
+              <el-icon :size="20" style="cursor: pointer;">
+                <Bell />
+              </el-icon>
+            </el-badge>
+          </div>
+        </template>
+        <NotificationCenter
+          @unreadCountChange="handleUnreadCountChange"
+          ref="notificationCenterRef"
+        />
+      </el-popover>
       <div class="avatar-container">
         <el-dropdown @command="handleCommand" class="right-menu-item hover-effect" trigger="click">
           <div class="avatar-wrapper">
@@ -51,6 +74,7 @@
 
 <script setup>
 import { ElMessageBox } from 'element-plus'
+import { Bell } from '@element-plus/icons-vue'
 import Breadcrumb from '@/components/Breadcrumb'
 import TopNav from '@/components/TopNav'
 import Hamburger from '@/components/Hamburger'
@@ -59,6 +83,7 @@
 import HeaderSearch from '@/components/HeaderSearch'
 import RuoYiGit from '@/components/RuoYi/Git'
 import RuoYiDoc from '@/components/RuoYi/Doc'
+import NotificationCenter from './NotificationCenter/index.vue'
 import useAppStore from '@/store/modules/app'
 import useUserStore from '@/store/modules/user'
 import useSettingsStore from '@/store/modules/settings'
@@ -70,6 +95,9 @@
 const userStore = useUserStore()
 const settingsStore = useSettingsStore()
 const factoryList = ref([])
+const notificationVisible = ref(false)
+const notificationCenterRef = ref(null)
+const unreadCount = ref(0)
 function toggleSideBar() {
   appStore.toggleSideBar()
 }
@@ -147,6 +175,43 @@
 }
 
 getUserLoginFacotryList();
+
+// 娑堟伅閫氱煡鐩稿叧
+function handleUnreadCountChange(count) {
+  unreadCount.value = count
+}
+
+// 缁勪欢鎸傝浇鏃跺姞杞芥湭璇绘暟閲忓拰瀹氭椂鍒锋柊
+let unreadCountTimer = null
+onMounted(() => {
+  // 寤惰繜鍔犺浇锛岀‘淇濈粍浠跺凡娓叉煋
+  nextTick(() => {
+    if (notificationCenterRef.value) {
+      notificationCenterRef.value.loadUnreadCount()
+    }
+  })
+  // 瀹氭椂鍒锋柊鏈鏁伴噺锛堟瘡30绉掞級
+  unreadCountTimer = setInterval(() => {
+    if (notificationCenterRef.value) {
+      notificationCenterRef.value.loadUnreadCount()
+    }
+  }, 30000)
+})
+
+// 鐩戝惉 popover 鏄剧ず鐘舵�侊紝鎵撳紑鏃跺姞杞芥秷鎭垪琛�
+watch(notificationVisible, (val) => {
+  if (val && notificationCenterRef.value) {
+    nextTick(() => {
+      notificationCenterRef.value.loadMessages()
+    })
+  }
+})
+
+onUnmounted(() => {
+  if (unreadCountTimer) {
+    clearInterval(unreadCountTimer)
+  }
+})
 </script>
 
 <style lang='scss' scoped>
@@ -241,6 +306,19 @@
       }
     }
 
+    .notification-container {
+      margin-right: 20px;
+      display: flex;
+      align-items: center;
+      cursor: pointer;
+
+      .notification-badge {
+        :deep(.el-badge__content) {
+          border: none;
+        }
+      }
+    }
+
     .avatar-container {
       margin-right: 40px;
 
@@ -267,3 +345,17 @@
   }
 }
 </style>
+
+<style lang="scss">
+.notification-popover {
+  padding: 0 !important;
+  
+  .el-popover__title {
+    display: none;
+  }
+  
+  .el-popover__body {
+    padding: 0 !important;
+  }
+}
+</style>
diff --git a/src/layout/components/NotificationCenter/index.vue b/src/layout/components/NotificationCenter/index.vue
new file mode 100644
index 0000000..2864aae
--- /dev/null
+++ b/src/layout/components/NotificationCenter/index.vue
@@ -0,0 +1,331 @@
+<template>
+  <div class="notification-popover-content">
+    <div class="popover-header">
+      <span class="popover-title">娑堟伅閫氱煡</span>
+      <el-button type="primary" size="small" @click="handleMarkAllAsRead" :disabled="unreadCount === 0">
+        涓�閿凡璇�
+      </el-button>
+    </div>
+
+    <div class="notification-content">
+      <el-tabs v-model="activeTab" @tab-change="handleTabChange">
+        <el-tab-pane :label="`鏈(${unreadCount})`" name="unread">
+          <div v-if="unreadList.length === 0" class="empty-state">
+            <el-empty description="鏆傛棤鏈娑堟伅" />
+          </div>
+          <div v-else class="notification-list">
+            <div
+              v-for="item in unreadList"
+              :key="item.id"
+              class="notification-item"
+            >
+              <div class="notification-icon">
+                <el-icon :size="24" color="#67C23A">
+                  <Bell />
+                </el-icon>
+              </div>
+              <div class="notification-content-wrapper">
+                <div class="notification-title">{{ item.title }}</div>
+                <div class="notification-detail">{{ item.content }}</div>
+                <div class="notification-time">{{ item.createTime }}</div>
+              </div>
+              <div class="notification-action">
+                <el-button type="primary" size="small" @click="handleConfirm(item.id)">
+                  纭
+                </el-button>
+              </div>
+            </div>
+          </div>
+        </el-tab-pane>
+        <el-tab-pane label="宸茶" name="read">
+          <div v-if="readList.length === 0" class="empty-state">
+            <el-empty description="鏆傛棤宸茶娑堟伅" />
+          </div>
+          <div v-else class="notification-list">
+            <div
+              v-for="item in readList"
+              :key="item.id"
+              class="notification-item read"
+            >
+              <div class="notification-icon">
+                <el-icon :size="24" color="#909399">
+                  <Bell />
+                </el-icon>
+              </div>
+              <div class="notification-content-wrapper">
+                <div class="notification-title">{{ item.title }}</div>
+                <div class="notification-detail">{{ item.content }}</div>
+                <div class="notification-time">{{ item.createTime }}</div>
+              </div>
+            </div>
+          </div>
+        </el-tab-pane>
+      </el-tabs>
+
+      <!-- 鍒嗛〉 -->
+      <div class="pagination-wrapper" v-if="total > 0">
+        <el-pagination
+          v-model:current-page="pageNum"
+          v-model:page-size="pageSize"
+          :page-sizes="[10, 20, 50, 100]"
+          :total="total"
+          layout="prev, pager, next, sizes"
+          @size-change="handleSizeChange"
+          @current-change="handlePageChange"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { Bell } from '@element-plus/icons-vue'
+import { listMessage, markAsRead, markAllAsRead, confirmMessage, getUnreadCount } from '@/api/system/message'
+import { ElMessage } from 'element-plus'
+
+const emit = defineEmits(['unreadCountChange'])
+
+const activeTab = ref('unread')
+const unreadList = ref([])
+const readList = ref([])
+const unreadCount = ref(0)
+const total = ref(0)
+const pageNum = ref(1)
+const pageSize = ref(10)
+
+// 鍔犺浇娑堟伅鍒楄〃
+const loadMessages = async () => {
+  try {
+    const params = {
+      pageNum: pageNum.value,
+      pageSize: pageSize.value,
+      isRead: activeTab.value === 'read' ? 1 : 0
+    }
+    const res = await listMessage(params)
+    if (res.code === 200) {
+      if (activeTab.value === 'unread') {
+        unreadList.value = res.rows || []
+      } else {
+        readList.value = res.rows || []
+      }
+      total.value = res.total || 0
+    }
+  } catch (error) {
+    console.error('鍔犺浇娑堟伅鍒楄〃澶辫触:', error)
+  }
+}
+
+// 鍔犺浇鏈鏁伴噺
+const loadUnreadCount = async () => {
+  try {
+    const res = await getUnreadCount()
+    if (res.code === 200) {
+      unreadCount.value = res.data || 0
+      emit('unreadCountChange', unreadCount.value)
+    }
+  } catch (error) {
+    console.error('鍔犺浇鏈鏁伴噺澶辫触:', error)
+  }
+}
+
+// 鏍囩椤靛垏鎹�
+const handleTabChange = (tab) => {
+  pageNum.value = 1
+  loadMessages()
+}
+
+// 纭娑堟伅
+const handleConfirm = async (messageId) => {
+  try {
+    const res = await confirmMessage(messageId)
+    if (res.code === 200) {
+      ElMessage.success('纭鎴愬姛')
+      // 鏍囪涓哄凡璇�
+      await markAsRead(messageId)
+      // 閲嶆柊鍔犺浇鏁版嵁
+      loadMessages()
+      loadUnreadCount()
+    }
+  } catch (error) {
+    console.error('纭娑堟伅澶辫触:', error)
+    ElMessage.error('纭澶辫触')
+  }
+}
+
+// 涓�閿凡璇�
+const handleMarkAllAsRead = async () => {
+  try {
+    const res = await markAllAsRead()
+    if (res.code === 200) {
+      ElMessage.success('宸插叏閮ㄦ爣璁颁负宸茶')
+      loadMessages()
+      loadUnreadCount()
+    }
+  } catch (error) {
+    console.error('涓�閿凡璇诲け璐�:', error)
+    ElMessage.error('鎿嶄綔澶辫触')
+  }
+}
+
+// 鍒嗛〉澶у皬鏀瑰彉
+const handleSizeChange = (size) => {
+  pageSize.value = size
+  pageNum.value = 1
+  loadMessages()
+}
+
+// 椤电爜鏀瑰彉
+const handlePageChange = (page) => {
+  pageNum.value = page
+  loadMessages()
+}
+
+// 缁勪欢鎸傝浇鏃跺姞杞芥湭璇绘暟閲�
+onMounted(() => {
+  loadUnreadCount()
+})
+
+// 鐩戝惉鐖剁粍浠朵紶閫掔殑 visible 鐘舵�侊紙閫氳繃 watch 鍦� Navbar 涓鐞嗭級
+// 杩欓噷鍙礋璐f暟鎹姞杞斤紝涓嶆帶鍒舵樉绀�
+
+// 鏆撮湶鏂规硶渚涘閮ㄨ皟鐢�
+defineExpose({
+  loadUnreadCount,
+  loadMessages
+})
+</script>
+
+<style lang="scss" scoped>
+.notification-popover-content {
+  display: flex;
+  flex-direction: column;
+  width: 500px;
+  padding: 16px;
+}
+
+.popover-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+  margin-bottom: 16px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #f0f0f0;
+
+  .popover-title {
+    font-size: 18px;
+    font-weight: 500;
+    color: #303133;
+  }
+}
+
+.notification-content {
+  max-height: 60vh;
+  display: flex;
+  flex-direction: column;
+
+  :deep(.el-tabs) {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+
+    .el-tabs__header {
+      margin-bottom: 0;
+      flex-shrink: 0;
+      padding: 0;
+    }
+
+    .el-tabs__content {
+      flex: 1;
+      overflow-y: auto;
+      min-height: 0;
+      padding-top: 16px;
+    }
+
+    .el-tab-pane {
+      height: 100%;
+    }
+  }
+}
+
+.empty-state {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  min-height: 300px;
+  padding: 40px 0;
+}
+
+.notification-list {
+  .notification-item {
+    display: flex;
+    padding: 12px 0;
+    border-bottom: 1px solid #f0f0f0;
+    transition: background-color 0.3s;
+
+    &:hover {
+      background-color: #f5f7fa;
+    }
+
+    &.read {
+      opacity: 0.7;
+    }
+
+    .notification-icon {
+      flex-shrink: 0;
+      width: 40px;
+      height: 40px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      background-color: #f0f9ff;
+      border-radius: 50%;
+      margin-right: 12px;
+    }
+
+    .notification-content-wrapper {
+      flex: 1;
+      min-width: 0;
+
+      .notification-title {
+        font-size: 14px;
+        font-weight: 500;
+        color: #303133;
+        margin-bottom: 8px;
+      }
+
+      .notification-detail {
+        font-size: 13px;
+        color: #606266;
+        line-height: 1.5;
+        margin-bottom: 8px;
+        word-break: break-all;
+      }
+
+      .notification-time {
+        font-size: 12px;
+        color: #909399;
+      }
+    }
+
+    .notification-action {
+      flex-shrink: 0;
+      margin-left: 12px;
+      display: flex;
+      align-items: center;
+    }
+  }
+}
+
+.pagination-wrapper {
+  margin-top: 16px;
+  padding-top: 16px;
+  border-top: 1px solid #f0f0f0;
+  display: flex;
+  justify-content: center;
+  padding-left: 0;
+  padding-right: 0;
+}
+</style>
+
diff --git a/src/layout/components/index.js b/src/layout/components/index.js
index d1308ce..630b9fe 100644
--- a/src/layout/components/index.js
+++ b/src/layout/components/index.js
@@ -2,3 +2,4 @@
 export { default as Navbar } from './Navbar'
 export { default as Settings } from './Settings'
 export { default as TagsView } from './TagsView/index.vue'
+export { default as NotificationCenter } from './NotificationCenter/index.vue'

--
Gitblit v1.9.3