界面
This commit is contained in:
35
schoolNewsWeb/src/components/base/ChangeHome.vue
Normal file
35
schoolNewsWeb/src/components/base/ChangeHome.vue
Normal 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>
|
||||
394
schoolNewsWeb/src/components/base/Notice.vue
Normal file
394
schoolNewsWeb/src/components/base/Notice.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
// 添加搜索框样式
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user