feat(notice): 添加公告详情查看功能并优化通知面板界面
- 在后端控制器中新增公开接口获取公告详情,支持状态检查和已读标记 - 在前端API模块中添加获取公共公告详情的方法 - 更新通知面板组件导入新的公共公告API方法 - 重构头部通知组件实现内联查看详情模式,移除独立详情弹窗 - 优化通知面板UI界面,调整布局样式和交互体验 - 将原有的Navbar中的通知弹窗替换为新的HeaderNotice组件 - 移除旧的通知相关代码和样式,精简组件结构
This commit is contained in:
@@ -102,6 +102,30 @@ public class SysNoticeController extends BaseController {
|
||||
return success(list);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取公告/通知详情(公开接口,普通用户可用)
|
||||
* 仅返回已发布且状态正常的公告
|
||||
*/
|
||||
@GetMapping("/public/{noticeId}")
|
||||
public AjaxResult getPublicNotice(@PathVariable Long noticeId) {
|
||||
SysNotice notice = noticeService.selectNoticeById(noticeId);
|
||||
if (notice == null) {
|
||||
return error("公告不存在");
|
||||
}
|
||||
// 只允许查看已发布且状态正常的公告
|
||||
if (!"1".equals(notice.getPublishStatus()) || !"0".equals(notice.getStatus())) {
|
||||
return error("该公告未发布或已关闭");
|
||||
}
|
||||
// 标注当前用户是否已读
|
||||
LoginUser loginUser = getLoginUser();
|
||||
if (loginUser != null) {
|
||||
List<Long> readIds = noticeReadService.selectReadNoticeIdsByUserId(loginUser.getUser().getUserId());
|
||||
notice.setIsRead(readIds.contains(noticeId));
|
||||
}
|
||||
return success(notice);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户未读公告/通知数量(公开接口)
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,15 @@ export function getUserNotices() {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// 获取公告/通知详情(公开接口,普通用户可用)
|
||||
export function getPublicNotice(noticeId) {
|
||||
return request({
|
||||
url: '/system/notice/public/' + noticeId,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 查询公告详细
|
||||
export function getNotice(noticeId) {
|
||||
return request({
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, defineEmits } from 'vue'
|
||||
import { getUserNotices, markAsRead, markAllAsRead as markAllReadApi } from '@/api/system/notice'
|
||||
import { getUserNotices, getPublicNotice, markAsRead, markAllAsRead as markAllReadApi } from '@/api/system/notice'
|
||||
import { Bell, Warning, InfoFilled, ArrowRight, CircleCheck } from '@element-plus/icons-vue'
|
||||
|
||||
const emit = defineEmits(['updateUnreadCount'])
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { getUserNotices, markAsRead, markAllAsRead as markAllReadApi } from '@/api/system/notice'
|
||||
import { getUserNotices, getPublicNotice, markAsRead, markAllAsRead as markAllReadApi } from '@/api/system/notice'
|
||||
import { Bell, Warning, InfoFilled, ArrowRight, CircleCheck } from '@element-plus/icons-vue'
|
||||
|
||||
// 监听全局触发公告弹窗事件
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { getNotice } from '@/api/system/notice'
|
||||
import { getPublicNotice } from '@/api/system/notice'
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -91,7 +91,7 @@ function open(payload) {
|
||||
}
|
||||
loading.value = true
|
||||
detail.value = null
|
||||
getNotice(id).then(res => {
|
||||
getPublicNotice(id).then(res => {
|
||||
detail.value = res.data
|
||||
}).catch(() => {
|
||||
detail.value = null
|
||||
|
||||
@@ -1,360 +1,285 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-popover
|
||||
ref="noticePopover"
|
||||
placement="bottom-end"
|
||||
:width="360"
|
||||
:width="380"
|
||||
trigger="click"
|
||||
v-model:visible="noticeVisible"
|
||||
popper-class="notice-popover"
|
||||
:show-arrow="false"
|
||||
:offset="8"
|
||||
>
|
||||
<template #default>
|
||||
<div class="notice-popover-content">
|
||||
<!-- 头部 -->
|
||||
<div class="notice-header">
|
||||
<span class="notice-header-title">消息中心</span>
|
||||
<el-badge :value="noticeStore.unreadCount" :hidden="noticeStore.unreadCount === 0" :max="99">
|
||||
<span />
|
||||
</el-badge>
|
||||
<span class="notice-header-action" @click="handleReadAll">全部已读</span>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="activeTab" class="notice-tabs">
|
||||
<el-tab-pane name="all">
|
||||
<template #label>
|
||||
全部
|
||||
<el-badge v-if="allUnread > 0" :value="allUnread" :max="99" class="tab-badge" />
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="notice">
|
||||
<template #label>
|
||||
通知
|
||||
<el-badge v-if="noticeUnread > 0" :value="noticeUnread" :max="99" class="tab-badge" />
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="announce">
|
||||
<template #label>
|
||||
公告
|
||||
<el-badge v-if="announceUnread > 0" :value="announceUnread" :max="99" class="tab-badge" />
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 列表 -->
|
||||
<el-scrollbar max-height="350px" class="notice-scroll">
|
||||
<div v-if="filteredList.length === 0" class="notice-empty">
|
||||
<el-empty description="暂无消息" :image-size="60" />
|
||||
</div>
|
||||
<div
|
||||
v-for="item in filteredList"
|
||||
:key="item.noticeId"
|
||||
class="notice-item"
|
||||
:class="{ 'is-read': isRead(item) }"
|
||||
@click="handleRead(item)"
|
||||
>
|
||||
<div class="notice-item-icon">
|
||||
<el-icon v-if="item.noticeType === '1'" class="icon-notice"><Bell /></el-icon>
|
||||
<el-icon v-else class="icon-announce"><Notification /></el-icon>
|
||||
</div>
|
||||
<div class="notice-item-body">
|
||||
<div class="notice-item-title">
|
||||
<span>{{ item.noticeTitle }}</span>
|
||||
<el-tag v-if="!isRead(item)" type="danger" size="small" effect="dark" class="unread-tag">未读</el-tag>
|
||||
</div>
|
||||
<div class="notice-item-time">{{ formatTime(item.createTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div class="notice-footer" @click="openNoticeCenter">
|
||||
<span>查看全部</span>
|
||||
<el-icon><ArrowRight /></el-icon>
|
||||
</div>
|
||||
<!-- 列表视图 -->
|
||||
<div v-if="!activeNotice" class="notice-popover-content">
|
||||
<div class="notice-header">
|
||||
<span class="notice-header-title">消息中心</span>
|
||||
<span class="notice-header-action" @click="handleReadAll">全部已读</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-tabs v-model="activeTab" class="notice-tabs">
|
||||
<el-tab-pane name="all">
|
||||
<template #label>全部<el-badge v-if="noticeStore.unreadCount > 0" :value="noticeStore.unreadCount" :max="99" class="tab-badge" /></template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="notice">
|
||||
<template #label>通知<el-badge v-if="noticeUnread > 0" :value="noticeUnread" :max="99" class="tab-badge" /></template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="announce">
|
||||
<template #label>公告<el-badge v-if="announceUnread > 0" :value="announceUnread" :max="99" class="tab-badge" /></template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-scrollbar max-height="380px" class="notice-scroll">
|
||||
<div v-if="filteredList.length === 0" class="notice-empty">
|
||||
<el-empty description="暂无消息" :image-size="60" />
|
||||
</div>
|
||||
<div
|
||||
v-for="item in filteredList"
|
||||
:key="item.noticeId"
|
||||
class="notice-item"
|
||||
:class="{ 'is-read': isRead(item) }"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<div class="notice-item-icon">
|
||||
<el-icon v-if="item.noticeType === '1'" class="icon-notice"><Bell /></el-icon>
|
||||
<el-icon v-else class="icon-announce"><Notification /></el-icon>
|
||||
</div>
|
||||
<div class="notice-item-body">
|
||||
<div class="notice-item-title">
|
||||
<span>{{ item.noticeTitle }}</span>
|
||||
<el-tag v-if="!isRead(item)" type="danger" size="small" effect="dark" class="unread-tag">未读</el-tag>
|
||||
</div>
|
||||
<div class="notice-item-desc" v-if="item.noticeContent">{{ stripHtml(item.noticeContent) }}</div>
|
||||
<div class="notice-item-time">{{ formatTime(item.createTime) }}</div>
|
||||
</div>
|
||||
<el-icon class="notice-item-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<!-- 详情视图 -->
|
||||
<div v-else class="notice-detail-content">
|
||||
<div class="detail-header">
|
||||
<div class="detail-back" @click="closeDetail">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span>返回</span>
|
||||
</div>
|
||||
<el-tag v-if="!isRead(activeNotice)" type="danger" size="small" effect="dark">未读</el-tag>
|
||||
</div>
|
||||
<div class="detail-type-bar">
|
||||
<span v-if="activeNotice.noticeType === '1'" class="detail-type-tag type-notify">
|
||||
<el-icon><Bell /></el-icon> 通知
|
||||
</span>
|
||||
<span v-else class="detail-type-tag type-announce">
|
||||
<el-icon><Notification /></el-icon> 公告
|
||||
</span>
|
||||
<span class="detail-priority" :class="'priority-' + (activeNotice.priority || '2')">
|
||||
{{ priorityText(activeNotice.priority) }}
|
||||
</span>
|
||||
</div>
|
||||
<h3 class="detail-title">{{ activeNotice.noticeTitle }}</h3>
|
||||
<div class="detail-meta">
|
||||
<span><el-icon><User /></el-icon> {{ activeNotice.createBy || '系统' }}</span>
|
||||
<span><el-icon><Clock /></el-icon> {{ activeNotice.createTime }}</span>
|
||||
</div>
|
||||
<el-scrollbar max-height="320px" class="detail-body-scroll">
|
||||
<div class="detail-body" v-html="activeNotice.noticeContent" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
|
||||
<template #reference>
|
||||
<div class="left-action-item">
|
||||
<div class="notice-trigger-icon">
|
||||
<el-badge :value="noticeStore.unreadCount" :hidden="noticeStore.unreadCount === 0" :max="99">
|
||||
<el-icon :size="18"><Bell /></el-icon>
|
||||
</el-badge>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<DetailView ref="detailViewRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DetailView from './DetailView.vue'
|
||||
import useNoticeStore from '@/store/modules/notice'
|
||||
import { Bell, Notification, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { getPublicNotice } from '@/api/system/notice'
|
||||
import { Bell, Notification, ArrowRight, ArrowLeft, User, Clock } from '@element-plus/icons-vue'
|
||||
|
||||
const noticeStore = useNoticeStore()
|
||||
const noticeVisible = ref(false)
|
||||
const activeTab = ref('all')
|
||||
const detailViewRef = ref(null)
|
||||
const activeNotice = ref(null)
|
||||
|
||||
// 过滤后的列表
|
||||
const filteredList = computed(() => {
|
||||
const list = noticeStore.noticeList
|
||||
if (activeTab.value === 'notice') {
|
||||
return list.filter(n => n.noticeType === '1')
|
||||
}
|
||||
if (activeTab.value === 'announce') {
|
||||
return list.filter(n => n.noticeType === '2')
|
||||
}
|
||||
if (activeTab.value === 'notice') return list.filter(n => n.noticeType === '1')
|
||||
if (activeTab.value === 'announce') return list.filter(n => n.noticeType === '2')
|
||||
return list
|
||||
})
|
||||
|
||||
// 各类型未读数
|
||||
const allUnread = computed(() => noticeStore.unreadCount)
|
||||
const noticeUnread = computed(() => {
|
||||
return noticeStore.noticeList.filter(n => n.noticeType === '1' && !isRead(n)).length
|
||||
})
|
||||
const announceUnread = computed(() => {
|
||||
return noticeStore.noticeList.filter(n => n.noticeType === '2' && !isRead(n)).length
|
||||
})
|
||||
const noticeUnread = computed(() => noticeStore.noticeList.filter(n => n.noticeType === '1' && !isRead(n)).length)
|
||||
const announceUnread = computed(() => noticeStore.noticeList.filter(n => n.noticeType === '2' && !isRead(n)).length)
|
||||
|
||||
function isRead(item) {
|
||||
return item.isRead || item.readFlag === '1' || noticeStore.readIds.has(item.noticeId)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(time) {
|
||||
if (!time) return ''
|
||||
const d = new Date(time)
|
||||
const now = new Date()
|
||||
const diff = now - d
|
||||
const d = new Date(time), now = new Date(), diff = now - d
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
|
||||
if (diff < 604800000) return Math.floor(diff / 86400000) + '天前'
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
// 点击单条 → 标记已读 + 打开详情
|
||||
function handleRead(item) {
|
||||
if (!isRead(item)) {
|
||||
noticeStore.markRead(item.noticeId)
|
||||
}
|
||||
detailViewRef.value?.open(item)
|
||||
function stripHtml(html) {
|
||||
if (!html) return ''
|
||||
return html.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim().substring(0, 80)
|
||||
}
|
||||
|
||||
function priorityText(p) {
|
||||
return { '1': '高优先级', '2': '中优先级', '3': '低优先级' }[p] || '中优先级'
|
||||
}
|
||||
|
||||
function openDetail(item) {
|
||||
if (!isRead(item)) noticeStore.markRead(item.noticeId)
|
||||
// 先用列表数据展示,再请求完整内容
|
||||
activeNotice.value = { ...item }
|
||||
getPublicNotice(item.noticeId).then(res => {
|
||||
if (res.data) activeNotice.value = res.data
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function closeDetail() {
|
||||
activeNotice.value = null
|
||||
}
|
||||
|
||||
// 全部已读
|
||||
function handleReadAll() {
|
||||
noticeStore.markAllRead()
|
||||
}
|
||||
|
||||
// 查看全部 → 关闭弹窗,跳转公告页面
|
||||
function openNoticeCenter() {
|
||||
noticeVisible.value = false
|
||||
// 可跳转到公告管理页面或打开全屏面板
|
||||
}
|
||||
|
||||
// 定时刷新未读数
|
||||
let timer = null
|
||||
onMounted(() => {
|
||||
noticeStore.fetchNotices()
|
||||
timer = setInterval(() => {
|
||||
noticeStore.fetchUnreadCount()
|
||||
}, 60000) // 每分钟刷新一次
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(timer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.left-action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
height: 50px;
|
||||
font-size: 16px;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f6f6f6;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.notice-popover) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* 列表视图 */
|
||||
.notice-popover-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.notice-header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.notice-header-action {
|
||||
font-size: 12px;
|
||||
color: #3B82F6;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #2563EB;
|
||||
}
|
||||
}
|
||||
.notice-header-title { font-size: 15px; font-weight: 600; color: #1a1a1a; }
|
||||
.notice-header-action { font-size: 12px; color: #3B82F6; cursor: pointer; &:hover { color: #2563EB; } }
|
||||
}
|
||||
|
||||
.notice-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
margin-left: 4px;
|
||||
|
||||
:deep(.el-badge__content) {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
:deep(.el-tabs__header) { margin: 0; padding: 0 16px; }
|
||||
:deep(.el-tabs__nav-wrap::after) { display: none; }
|
||||
:deep(.el-tabs__item) { padding: 0 12px; height: 40px; line-height: 40px; font-size: 13px; }
|
||||
.tab-badge { margin-left: 4px; :deep(.el-badge__content) { font-size: 10px; } }
|
||||
}
|
||||
|
||||
.notice-scroll {
|
||||
max-height: 350px;
|
||||
}
|
||||
|
||||
.notice-empty {
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.notice-empty { padding: 30px 0; }
|
||||
.notice-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
transition: background 0.15s;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
|
||||
&:hover {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.is-read {
|
||||
opacity: 0.55;
|
||||
|
||||
.notice-item-title span {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover { background: #f7f8fa; }
|
||||
&:last-child { border-bottom: none; }
|
||||
&.is-read { opacity: 0.5; }
|
||||
.notice-item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
|
||||
.icon-notice {
|
||||
background: #E0F2FE;
|
||||
color: #0284C7;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.icon-announce {
|
||||
background: #ECFDF5;
|
||||
color: #059669;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
}
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
.icon-notice { background: #E0F2FE; color: #0284C7; width: 34px; height: 34px; border-radius: 50%; font-size: 15px; }
|
||||
.icon-announce { background: #ECFDF5; color: #059669; width: 34px; height: 34px; border-radius: 50%; font-size: 15px; }
|
||||
}
|
||||
|
||||
.notice-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notice-item-body { flex: 1; min-width: 0; }
|
||||
.notice-item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.unread-tag {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.notice-item-time {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
display: flex; align-items: center; gap: 6px; margin-bottom: 3px;
|
||||
span { flex: 1; font-size: 13px; font-weight: 500; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.unread-tag { flex-shrink: 0; }
|
||||
}
|
||||
.notice-item-desc { font-size: 12px; color: #999; line-height: 1.4; margin-bottom: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.notice-item-time { font-size: 11px; color: #bbb; }
|
||||
.notice-item-arrow { flex-shrink: 0; color: #ccc; font-size: 12px; margin-top: 10px; }
|
||||
}
|
||||
|
||||
.notice-footer {
|
||||
/* 详情视图 */
|
||||
.notice-detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
.detail-back {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 13px; color: #3B82F6; cursor: pointer;
|
||||
&:hover { color: #2563EB; }
|
||||
.el-icon { font-size: 14px; }
|
||||
}
|
||||
}
|
||||
.detail-type-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 12px 16px 0;
|
||||
.detail-type-tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 2px 10px; border-radius: 3px; font-size: 12px; font-weight: 500;
|
||||
&.type-notify { background: #E0F2FE; color: #0284C7; }
|
||||
&.type-announce { background: #ECFDF5; color: #059669; }
|
||||
}
|
||||
.detail-priority {
|
||||
font-size: 12px; padding: 2px 8px; border-radius: 3px;
|
||||
&.priority-1 { background: #FEF2F2; color: #DC2626; }
|
||||
&.priority-2 { background: #FFFBEB; color: #D97706; }
|
||||
&.priority-3 { background: #F3F4F6; color: #6B7280; }
|
||||
}
|
||||
}
|
||||
.detail-title {
|
||||
font-size: 16px; font-weight: 600; color: #1a1a1a;
|
||||
margin: 10px 16px 0; line-height: 1.5;
|
||||
}
|
||||
.detail-meta {
|
||||
display: flex; gap: 16px; padding: 8px 16px 12px;
|
||||
font-size: 12px; color: #999;
|
||||
span { display: flex; align-items: center; gap: 4px; }
|
||||
.el-icon { font-size: 12px; }
|
||||
}
|
||||
.detail-body-scroll {
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.detail-body {
|
||||
padding: 16px;
|
||||
font-size: 14px; line-height: 1.8; color: #333;
|
||||
:deep(img) { max-width: 100%; border-radius: 4px; margin: 8px 0; }
|
||||
:deep(p) { margin: 8px 0; }
|
||||
:deep(a) { color: #3B82F6; }
|
||||
}
|
||||
/* 触发器图标 */
|
||||
.notice-trigger-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
font-size: 13px;
|
||||
color: #3B82F6;
|
||||
height: 50px;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
color: #606266;
|
||||
transition: background 0.3s;
|
||||
&:hover {
|
||||
background: #f7f8fa;
|
||||
background-color: #f6f6f6;
|
||||
color: #3B82F6;
|
||||
}
|
||||
.el-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,63 +20,7 @@
|
||||
/>
|
||||
</template>
|
||||
<!-- 消息通知 -->
|
||||
<el-popover
|
||||
placement="bottom-end"
|
||||
:width="360"
|
||||
trigger="click"
|
||||
v-model:visible="noticeVisible"
|
||||
popper-class="notice-popover"
|
||||
:show-arrow="false"
|
||||
>
|
||||
<div class="notice-popover-content">
|
||||
<div class="notice-header">
|
||||
<span class="notice-header-title">消息中心</span>
|
||||
<span class="notice-header-action" @click="handleNoticeReadAll">全部已读</span>
|
||||
</div>
|
||||
<el-tabs v-model="noticeTab" class="notice-tabs">
|
||||
<el-tab-pane name="all">
|
||||
<template #label>全部<el-badge v-if="noticeStore.unreadCount > 0" :value="noticeStore.unreadCount" :max="99" class="tab-badge" /></template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="notice">
|
||||
<template #label>通知<el-badge v-if="noticeUnread > 0" :value="noticeUnread" :max="99" class="tab-badge" /></template>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="announce">
|
||||
<template #label>公告<el-badge v-if="announceUnread > 0" :value="announceUnread" :max="99" class="tab-badge" /></template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<el-scrollbar max-height="350px">
|
||||
<div v-if="noticeFilteredList.length === 0" class="notice-empty">
|
||||
<el-empty description="暂无消息" :image-size="60" />
|
||||
</div>
|
||||
<div
|
||||
v-for="item in noticeFilteredList"
|
||||
:key="item.noticeId"
|
||||
class="notice-item"
|
||||
:class="{ 'is-read': noticeIsRead(item) }"
|
||||
@click="handleNoticeRead(item)"
|
||||
>
|
||||
<div class="notice-item-icon">
|
||||
<el-icon v-if="item.noticeType === '1'" class="icon-notice"><Bell /></el-icon>
|
||||
<el-icon v-else class="icon-announce"><Notification /></el-icon>
|
||||
</div>
|
||||
<div class="notice-item-body">
|
||||
<div class="notice-item-title">
|
||||
<span>{{ item.noticeTitle }}</span>
|
||||
<el-tag v-if="!noticeIsRead(item)" type="danger" size="small" effect="dark" class="unread-tag">未读</el-tag>
|
||||
</div>
|
||||
<div class="notice-item-time">{{ noticeFormatTime(item.createTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<template #reference>
|
||||
<div class="left-action-item">
|
||||
<el-badge :value="noticeStore.unreadCount" :hidden="noticeStore.unreadCount === 0" :max="99">
|
||||
<el-icon><Notification /></el-icon>
|
||||
</el-badge>
|
||||
</div>
|
||||
</template>
|
||||
</el-popover>
|
||||
<HeaderNotice />
|
||||
<!-- 帮助中心按钮 -->
|
||||
<el-tooltip
|
||||
content="帮助中心"
|
||||
@@ -211,7 +155,6 @@
|
||||
</el-dialog>
|
||||
|
||||
|
||||
<DetailView ref="detailViewRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -220,9 +163,7 @@ import {ElMessageBox} from 'element-plus';
|
||||
import {Fold, Expand} from '@element-plus/icons-vue';
|
||||
import {Help, Setting} from "@element-plus/icons-vue";
|
||||
import HeaderSearch from '@/components/HeaderSearch/index.vue';
|
||||
import DetailView from '@/layout/components/HeaderNotice/DetailView.vue'
|
||||
import useNoticeStore from '@/store/modules/notice'
|
||||
import { Notification } from '@element-plus/icons-vue'
|
||||
import HeaderNotice from '@/layout/components/HeaderNotice/index.vue';
|
||||
import useAppStore from '@/store/modules/app';
|
||||
import useUserStore from '@/store/modules/user';
|
||||
import useLockStore from '@/store/modules/lock';
|
||||
@@ -313,40 +254,10 @@ function goToHelpCenter() {
|
||||
}
|
||||
|
||||
|
||||
const noticeStore = useNoticeStore()
|
||||
const noticeVisible = ref(false)
|
||||
const noticeTab = ref('all')
|
||||
const detailViewRef = ref(null)
|
||||
|
||||
const noticeFilteredList = computed(() => {
|
||||
const list = noticeStore.noticeList
|
||||
if (noticeTab.value === 'notice') return list.filter(n => n.noticeType === '1')
|
||||
if (noticeTab.value === 'announce') return list.filter(n => n.noticeType === '2')
|
||||
return list
|
||||
})
|
||||
const noticeUnread = computed(() => noticeStore.noticeList.filter(n => n.noticeType === '1' && !noticeIsRead(n)).length)
|
||||
const announceUnread = computed(() => noticeStore.noticeList.filter(n => n.noticeType === '2' && !noticeIsRead(n)).length)
|
||||
|
||||
function noticeIsRead(item) {
|
||||
return item.isRead || item.readFlag === '1' || noticeStore.readIds.has(item.noticeId)
|
||||
}
|
||||
function noticeFormatTime(time) {
|
||||
if (!time) return ''
|
||||
const d = new Date(time), now = new Date(), diff = now - d
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
|
||||
if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
|
||||
if (diff < 604800000) return Math.floor(diff / 86400000) + '天前'
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`
|
||||
}
|
||||
function handleNoticeRead(item) {
|
||||
if (!noticeIsRead(item)) noticeStore.markRead(item.noticeId)
|
||||
detailViewRef.value?.open(item)
|
||||
}
|
||||
function handleNoticeReadAll() {
|
||||
noticeStore.markAllRead()
|
||||
}
|
||||
|
||||
const emits = defineEmits(['setLayout']);
|
||||
function setLayout() {
|
||||
@@ -355,7 +266,6 @@ function setLayout() {
|
||||
|
||||
onMounted(() => {
|
||||
loadOrgList();
|
||||
noticeStore.fetchNotices();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -647,117 +557,3 @@ onMounted(() => {
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* 通知弹窗样式 */
|
||||
.notice-popover {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.notice-popover-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.notice-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
.notice-header-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.notice-header-action {
|
||||
font-size: 12px;
|
||||
color: #3B82F6;
|
||||
cursor: pointer;
|
||||
&:hover { color: #2563EB; }
|
||||
}
|
||||
}
|
||||
.notice-tabs {
|
||||
.el-tabs__header {
|
||||
margin: 0;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.el-tabs__nav-wrap::after {
|
||||
display: none;
|
||||
}
|
||||
.el-tabs__item {
|
||||
padding: 0 12px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.tab-badge {
|
||||
margin-left: 4px;
|
||||
.el-badge__content { font-size: 10px; }
|
||||
}
|
||||
}
|
||||
.notice-empty {
|
||||
padding: 30px 0;
|
||||
}
|
||||
.notice-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
&:hover { background: #f7f8fa; }
|
||||
&:last-child { border-bottom: none; }
|
||||
&.is-read {
|
||||
opacity: 0.55;
|
||||
.notice-item-title span { color: #999; }
|
||||
}
|
||||
.notice-item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 2px;
|
||||
.icon-notice {
|
||||
background: #E0F2FE;
|
||||
color: #0284C7;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
}
|
||||
.icon-announce {
|
||||
background: #ECFDF5;
|
||||
color: #059669;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
.notice-item-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notice-item-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
span {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.unread-tag { flex-shrink: 0; }
|
||||
}
|
||||
.notice-item-time {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user