Forráskód Böngészése

feat: add notification bell and list view in frontend

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wangkangyjy 2 hónapja
szülő
commit
789d9dc2b5

+ 8 - 0
frontend/src/api/index.js

@@ -132,3 +132,11 @@ export const dimensionApi = {
 export const systemApi = {
   getLogs: params => api.get('/system/logs', { params }).then(r => r.data)
 }
+
+// Notification API
+export const notificationApi = {
+  getUnreadCount: () => api.get('/notifications/unread-count').then(r => r.data),
+  getPage: (params) => api.get('/notifications', { params }).then(r => r.data),
+  markRead: (id) => api.put(`/notifications/${id}/read`),
+  markAllRead: () => api.put('/notifications/read-all')
+}

+ 94 - 4
frontend/src/components/MainLayout.vue

@@ -81,6 +81,36 @@
             </el-breadcrumb>
           </div>
           <div class="topbar-right">
+            <el-popover placement="bottom-end" :width="360" trigger="click">
+              <template #reference>
+                <el-badge :value="unreadCount" :hidden="unreadCount === 0" :max="99">
+                  <el-button text>
+                    <el-icon :size="20"><Bell /></el-icon>
+                  </el-button>
+                </el-badge>
+              </template>
+              <div class="notify-panel">
+                <div class="notify-header">
+                  <span>消息通知</span>
+                  <el-button text size="small" @click="markAllRead">全部已读</el-button>
+                </div>
+                <div class="notify-list" v-if="recentNotifications.length > 0">
+                  <div
+                    v-for="n in recentNotifications" :key="n.id"
+                    class="notify-item" :class="{ unread: n.isRead === 0 }"
+                    @click="handleNotifyClick(n)"
+                  >
+                    <div class="notify-title">{{ n.title }}</div>
+                    <div class="notify-content">{{ n.content }}</div>
+                    <div class="notify-time">{{ n.createdAtFriendly }}</div>
+                  </div>
+                </div>
+                <div v-else class="notify-empty">暂无通知</div>
+                <div class="notify-footer">
+                  <el-button text size="small" @click="goNotifications">查看全部</el-button>
+                </div>
+              </div>
+            </el-popover>
             <span class="user-info">{{ auth.user?.realName }} · {{ auth.user?.position || auth.user?.role === 'SUPER_ADMIN' ? '管理员' : '员工' }}</span>
             <el-button text type="danger" @click="handleLogout">退出</el-button>
           </div>
@@ -95,7 +125,8 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { notificationApi } from '../api'
 import { useRoute, useRouter } from 'vue-router'
 import { useAuthStore } from '../stores/auth'
 
@@ -104,10 +135,58 @@ const router = useRouter()
 const auth = useAuthStore()
 const isCollapse = ref(false)
 
+const unreadCount = ref(0)
+const recentNotifications = ref([])
+let pollTimer = null
+
+async function fetchUnreadCount() {
+  try {
+    const data = await notificationApi.getUnreadCount()
+    unreadCount.value = data.count
+  } catch {}
+}
+async function fetchRecent() {
+  try {
+    const data = await notificationApi.getPage({ page: 1, size: 20 })
+    recentNotifications.value = data.records || []
+  } catch {}
+}
+async function markAllRead() {
+  await notificationApi.markAllRead()
+  unreadCount.value = 0
+  await fetchRecent()
+}
+function handleNotifyClick(n) {
+  notificationApi.markRead(n.id)
+  setTimeout(async () => {
+    if (n.refType === 'objective' && n.refId) {
+      try {
+        const { okrApi } = await import('../api')
+        const detail = await okrApi.getDetail(n.refId)
+        router.push('/okr/my?periodId=' + detail.periodId)
+      } catch { router.push('/okr/my') }
+    } else if (n.refType === 'score') {
+      router.push('/scores/my')
+    } else if (n.refType === 'feedback') {
+      router.push('/feedback')
+    } else if (n.refType === 'period') {
+      router.push('/periods')
+    }
+  }, 100)
+}
+function goNotifications() {
+  router.push('/notifications')
+}
+
 onMounted(async () => {
-  if (!auth.user) {
-    await auth.fetchUser()
-  }
+  if (!auth.user) await auth.fetchUser()
+  fetchUnreadCount()
+  fetchRecent()
+  pollTimer = setInterval(fetchUnreadCount, 60000)
+})
+
+onUnmounted(() => {
+  if (pollTimer) clearInterval(pollTimer)
 })
 
 function handleLogout() {
@@ -164,4 +243,15 @@ function handleLogout() {
 .topbar-right { display: flex; align-items: center; gap: 12px; }
 .user-info { font-size: 13px; color: #666; }
 .main-content { background: #f5f7fa; padding: 20px 24px; min-height: calc(100vh - 48px); }
+
+.notify-panel { max-height: 400px; overflow-y: auto; }
+.notify-header { display: flex; justify-content: space-between; align-items: center; padding-bottom: 8px; border-bottom: 1px solid #eee; margin-bottom: 4px; font-weight: 600; }
+.notify-item { padding: 10px 4px; border-bottom: 1px solid #f5f5f5; cursor: pointer; border-radius: 4px; }
+.notify-item:hover { background: #f5f7fa; }
+.notify-item.unread { background: #f0f7ff; }
+.notify-title { font-size: 13px; font-weight: 500; }
+.notify-content { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.notify-time { font-size: 11px; color: #bbb; margin-top: 2px; }
+.notify-empty { padding: 20px; text-align: center; color: #999; font-size: 13px; }
+.notify-footer { padding-top: 8px; text-align: center; border-top: 1px solid #eee; margin-top: 4px; }
 </style>

+ 2 - 1
frontend/src/router/index.js

@@ -23,7 +23,8 @@ const routes = [
       { path: 'org', name: 'Org', component: () => import('../views/org/OrgView.vue'), meta: { title: '组织架构' } },
       { path: 'kpi/templates', name: 'KpiTemplates', component: () => import('../views/period/KpiTemplateView.vue'), meta: { title: '绩效模板' } },
       { path: 'system/dimensions', name: 'Dimensions', component: () => import('../views/system/DimensionConfigView.vue'), meta: { title: '考核维度' } },
-      { path: 'system/logs', name: 'SystemLogs', component: () => import('../views/system/LogView.vue'), meta: { title: '操作日志' } }
+      { path: 'system/logs', name: 'SystemLogs', component: () => import('../views/system/LogView.vue'), meta: { title: '操作日志' } },
+      { path: 'notifications', name: 'Notifications', component: () => import('../views/notification/NotificationView.vue'), meta: { title: '消息通知' } }
     ]
   }
 ]

+ 76 - 0
frontend/src/views/notification/NotificationView.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="notification-page">
+    <el-card>
+      <template #header>
+        <div class="card-header">
+          <span>消息通知</span>
+          <el-button text type="primary" @click="markAllRead">全部已读</el-button>
+        </div>
+      </template>
+      <el-table :data="notifications" stripe v-loading="loading">
+        <el-table-column label="状态" width="70">
+          <template #default="{ row }">
+            <el-tag size="small" :type="row.isRead === 0 ? 'danger' : 'info'">
+              {{ row.isRead === 0 ? '未读' : '已读' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="title" label="标题" min-width="180" />
+        <el-table-column prop="content" label="内容" min-width="240" show-overflow-tooltip />
+        <el-table-column label="类型" width="120">
+          <template #default="{ row }">{{ typeLabel(row.type) }}</template>
+        </el-table-column>
+        <el-table-column prop="createdAtFriendly" label="时间" width="100" />
+      </el-table>
+      <div style="margin-top:16px; text-align:center">
+        <el-pagination
+          v-model:current-page="page"
+          :total="total"
+          :page-size="20"
+          layout="prev, pager, next"
+          @current-change="loadData"
+        />
+      </div>
+    </el-card>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from 'vue'
+import { notificationApi } from '../../api'
+
+const notifications = ref([])
+const loading = ref(false)
+const page = ref(1)
+const total = ref(0)
+
+function typeLabel(type) {
+  const map = {
+    OKR_REVIEW: 'OKR审核', OKR_REVIEWED: '审核结果',
+    SCORE_CONFIRM: '评分确认', SCORE_CONFIRMED: '确认通知',
+    PERIOD_DEADLINE: '截止提醒', KR_LAGGING: '进度滞后',
+    FEEDBACK_NOTIFY: '面谈通知'
+  }
+  return map[type] || type
+}
+async function loadData() {
+  loading.value = true
+  try {
+    const data = await notificationApi.getPage({ page: page.value, size: 20 })
+    notifications.value = data.records
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+async function markAllRead() {
+  await notificationApi.markAllRead()
+  await loadData()
+}
+onMounted(loadData)
+</script>
+
+<style scoped>
+.notification-page { max-width: 960px; margin: 0 auto; }
+.card-header { display: flex; justify-content: space-between; align-items: center; }
+</style>