|
|
@@ -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>
|