This commit is contained in:
2025-11-14 12:03:02 +08:00
parent e20a7755f8
commit 46003a646e
21 changed files with 887 additions and 499 deletions

View File

@@ -0,0 +1,35 @@
<template>
<div class="change-home" v-if="isAdmin">
<el-button type="primary" @click="changeHome">
<span v-if="home">前往用户页</span>
<span v-else>前往管理页</span>
</el-button>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
const isAdmin = ref<boolean>(false);
const home = ref<boolean>(false);
const router = useRouter();
const routes = router.getRoutes();
function hasAdmin(){
return routes.some((route: any) => route.path.startsWith('/admin'));
}
function changeHome(){
if(home.value){
router.push('/home');
}else{
router.push('/admin/overview');
}
home.value = !home.value;
}
onMounted(() => {
isAdmin.value = hasAdmin();
home.value = router.currentRoute.value.path.startsWith('/admin');
});
</script>
<style scoped lang="scss"></style>

View File

@@ -0,0 +1,394 @@
<template>
<div ref="noticeRef" class="notice" @click="toggleDropdown" @mouseenter="openDropdown" @mouseleave="closeDropdown"
v-click-outside="forceCloseDropdown">
<div class="notice-trigger">
<img src="@/assets/imgs/notice.svg" alt="notice" />
<span v-if="unreadCount > 0" class="notice-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</div>
<!-- 下拉通知列表 -->
<Teleport to="body">
<div class="notice-dropdown" :class="{ 'show': dropdownVisible }" :style="dropdownStyle"
v-if="dropdownVisible" @mouseenter="openDropdown" @mouseleave="closeDropdown">
<div class="notice-header">
<h3>通知</h3>
<span class="notice-count" v-if="unreadCount > 0">{{ unreadCount }}条未读</span>
</div>
<div class="notice-list" v-loading="loading">
<div v-if="noticeList.length === 0 && !loading" class="notice-empty">
暂无通知
</div>
<div v-for="item in noticeList" :key="item.messageID" class="notice-item" :class="{ 'unread': !item.isRead }"
@click="handleNoticeClick(item)">
<div class="notice-content">
<div class="notice-title">{{ item.title }}</div>
<div class="notice-time">{{ formatTime(item.actualSendTime || item.createTime) }}</div>
</div>
</div>
</div>
<div class="notice-footer" v-if="noticeList.length > 0">
<span class="view-all" @click="viewAll">查看全部</span>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
import { messageApi } from '@/apis/message';
import type { MessageUserVO, PageParam } from '@/types';
// 状态
const dropdownVisible = ref(false);
const noticeRef = ref<HTMLElement | null>(null);
const dropdownPosition = ref({ top: 0, left: 0 });
const closeTimer = ref<number | null>(null);
const loading = ref(false);
const lastLoadTime = ref(0); // 上次加载时间戳
const hasLoadedOnce = ref(false); // 是否已经加载过一次
const router = useRouter();
// 通知列表
const noticeList = ref<MessageUserVO[]>([]);
// 未读数量
const unreadCount = ref(0);
// 下拉菜单样式
const dropdownStyle = computed(() => {
return {
position: 'fixed' as const,
top: `${dropdownPosition.value.top}px`,
left: `${dropdownPosition.value.left}px`,
width: '320px',
};
});
// 加载消息列表
async function loadMessages(force = false) {
// 如果正在加载,直接返回
if (loading.value) return;
// 如果不是强制刷新且已经加载过且距离上次加载时间少于2秒则不重复加载
const now = Date.now();
if (!force && hasLoadedOnce.value && (now - lastLoadTime.value) < 2000) {
return;
}
loading.value = true;
lastLoadTime.value = now;
try {
const pageParam: PageParam = {
pageNumber: 1,
pageSize: 10,
orderBy: 'actualSendTime',
orderDirection: 'desc'
};
const result = await messageApi.getMyMessages(pageParam);
if (result.success) {
noticeList.value = result.dataList || [];
hasLoadedOnce.value = true;
} else {
ElMessage.error(result.message || '加载失败');
}
} catch (error) {
console.error('加载消息列表失败:', error);
ElMessage.error('加载失败');
} finally {
loading.value = false;
}
}
// 加载未读数量
async function loadUnreadCount() {
try {
const result = await messageApi.getUnreadCount();
if (result.success) {
unreadCount.value = result.data || 0;
}
} catch (error) {
console.error('加载未读数量失败:', error);
}
}
// 方法
function openDropdown() {
if (closeTimer.value) {
clearTimeout(closeTimer.value);
closeTimer.value = null;
}
// 如果已经打开,不重复处理(避免鼠标移动时重复触发)
if (dropdownVisible.value) {
return;
}
dropdownVisible.value = true;
// 计算下拉菜单位置(居中显示)
if (noticeRef.value) {
const rect = noticeRef.value.getBoundingClientRect();
const dropdownWidth = 320;
dropdownPosition.value = {
top: rect.bottom + 4,
left: rect.left + rect.width / 2 - dropdownWidth / 2, // 居中
};
}
// 每次打开下拉菜单时都加载数据(因为 hasLoadedOnce 在关闭时会被重置)
loadMessages();
loadUnreadCount();
}
function toggleDropdown() {
if (dropdownVisible.value) {
forceCloseDropdown();
} else {
openDropdown();
}
}
function closeDropdown() {
closeTimer.value = setTimeout(() => {
dropdownVisible.value = false;
closeTimer.value = null;
// 关闭时重置加载标志,下次打开时会重新加载
hasLoadedOnce.value = false;
}, 200);
}
function forceCloseDropdown() {
if (closeTimer.value) {
clearTimeout(closeTimer.value);
closeTimer.value = null;
}
dropdownVisible.value = false;
// 关闭时重置加载标志,下次打开时会重新加载
hasLoadedOnce.value = false;
}
async function handleNoticeClick(item: MessageUserVO) {
// 如果未读,标记为已读
if (!item.isRead && item.messageID) {
try {
await messageApi.markAsRead(item.messageID);
item.isRead = true;
// 更新未读数量
if (unreadCount.value > 0) {
unreadCount.value--;
}
} catch (error) {
console.error('标记已读失败:', error);
}
}
// 跳转到消息详情页
if (item.messageID) {
router.push(`/user/message/detail/${item.messageID}`);
}
forceCloseDropdown();
}
function viewAll() {
router.push('/user/message');
forceCloseDropdown();
}
function formatTime(time?: string | number): string {
if (!time) return '';
const date = new Date(time);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
}
// 点击外部关闭指令
const vClickOutside = {
mounted(el: any, binding: any) {
el._clickOutside = (event: Event) => {
if (!(el === event.target || el.contains(event.target as Node))) {
binding.value();
}
};
document.addEventListener('click', el._clickOutside);
},
unmounted(el: any) {
document.removeEventListener('click', el._clickOutside);
delete el._clickOutside;
}
};
onMounted(() => {
loadUnreadCount();
});
</script>
<style scoped lang="scss">
.notice {
position: relative;
cursor: pointer;
user-select: none;
}
.notice-trigger {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: 4px;
transition: background-color 0.2s ease;
&:hover {
background-color: #f5f5f5;
}
img {
width: 20px;
height: 20px;
}
.notice-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
background: #ff4d4f;
color: white;
border-radius: 8px;
font-size: 11px;
line-height: 16px;
text-align: center;
font-weight: 500;
}
}
.notice-dropdown {
background: white;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
border: 1px solid #e8e8e8;
z-index: 10000;
overflow: hidden;
opacity: 0;
transform: scaleY(0);
transform-origin: top center;
transition: opacity 0.2s ease, transform 0.2s ease;
max-height: 400px;
display: flex;
flex-direction: column;
&.show {
opacity: 1;
transform: scaleY(1);
}
}
.notice-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #333;
}
.notice-count {
font-size: 12px;
color: #ff4d4f;
}
}
.notice-list {
flex: 1;
overflow-y: auto;
max-height: 300px;
}
.notice-empty {
padding: 40px 20px;
text-align: center;
color: #999;
font-size: 14px;
}
.notice-item {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background-color: #f5f5f5;
}
&.unread {
background-color: #f0f7ff;
.notice-title {
font-weight: 600;
}
}
&:last-child {
border-bottom: none;
}
}
.notice-content {
.notice-title {
font-size: 14px;
color: #333;
line-height: 1.5;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
.notice-time {
font-size: 12px;
color: #999;
}
}
.notice-footer {
padding: 8px 16px;
border-top: 1px solid #e8e8e8;
text-align: center;
.view-all {
font-size: 14px;
color: #C62828;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
</style>

View File

@@ -48,6 +48,8 @@
<div class="nav-right">
<!-- 搜索框 -->
<Search @search="handleSearch" />
<ChangeHome />
<Notice />
<UserDropdown :user="userInfo" @logout="handleLogout" />
</div>
</div>
@@ -61,8 +63,7 @@ import { useStore } from 'vuex';
import type { SysMenu } from '@/types';
import { MenuType } from '@/types/enums';
// @ts-ignore - Vue 3.5 组件导入兼容性
import {UserDropdown, Search} from '@/components/base';
import {UserDropdown, Search, Notice, ChangeHome} from '@/components/base';
const router = useRouter();
const route = useRoute();
const store = useStore();
@@ -462,7 +463,7 @@ function handleLogout() {
margin-left: auto;
display: flex;
flex-shrink: 0; /* 防止右侧区域被压缩 */
gap: 20px;
// gap: 20px;
align-items: center;
// 添加搜索框样式

View File

@@ -9,4 +9,6 @@ export { default as UserDropdown } from './UserDropdown.vue';
export { default as Search } from './Search.vue';
export { default as CenterHead } from './CenterHead.vue';
export { default as GenericSelector } from './GenericSelector.vue';
export { default as TreeNode } from './TreeNode.vue';
export { default as TreeNode } from './TreeNode.vue';
export { default as Notice } from './Notice.vue';
export { default as ChangeHome } from './ChangeHome.vue';