优化站内信模块

This commit is contained in:
2026-06-17 23:07:39 +08:00
parent d3886e2475
commit 22e0e75c35
25 changed files with 2538 additions and 39 deletions
+2
View File
@@ -17,6 +17,7 @@ declare module 'vue' {
ElAside: typeof import('element-plus/es')['ElAside']
ElAvatar: typeof import('element-plus/es')['ElAvatar']
ElBacktop: typeof import('element-plus/es')['ElBacktop']
ElBadge: typeof import('element-plus/es')['ElBadge']
ElButton: typeof import('element-plus/es')['ElButton']
ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
ElCard: typeof import('element-plus/es')['ElCard']
@@ -66,6 +67,7 @@ declare module 'vue' {
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
+27
View File
@@ -0,0 +1,27 @@
import request from "@/utils/request";
/** 获取我的消息列表 */
export function getMySiteReminders(params) {
return request({
url: "/backend/sitereminder/myList",
method: "get",
params,
});
}
/** 标记消息为已读 */
export function readSiteReminder(id) {
return request({
url: "/backend/sitereminder/read",
method: "post",
data: { id },
});
}
/** 一键全部已读 */
export function readAllSiteReminders() {
return request({
url: "/backend/sitereminder/readall",
method: "post",
});
}
+269 -12
View File
@@ -24,18 +24,48 @@
:title="currentTheme === 'dark' ? '切换到亮色模式' : '切换到暗色模式'" />
<!-- 消息中心 -->
<el-dropdown trigger="click">
<el-dropdown trigger="click" @visible-change="handleDropdownVisibleChange">
<span class="el-dropdown-link" style="cursor: pointer;">
<el-button circle class="message-btn" title="消息中心">
<el-icon>
<Bell />
</el-icon>
</el-button>
<el-badge :value="unreadCount" :max="99" :hidden="unreadCount === 0" class="message-badge">
<el-button circle class="message-btn" title="消息中心">
<el-icon>
<Bell />
</el-icon>
</el-button>
</el-badge>
</span>
<template #dropdown>
<el-dropdown-menu class="message-menu" style="width: 260px;">
<el-dropdown-item disabled>暂无新消息</el-dropdown-item>
</el-dropdown-menu>
<div class="message-dropdown-container">
<div class="message-dropdown-header">
<span class="title">站内通知 ({{ unreadCount }}条未读)</span>
<el-link type="primary" :underline="false" class="mark-all-btn" v-if="unreadCount > 0" @click="handleMarkAllRead">全部已读</el-link>
</div>
<el-scrollbar max-height="300px">
<div v-if="loadingMessages" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<div v-else-if="messages.length === 0" class="empty-state">
<el-icon><Message /></el-icon>
<span>暂无新消息</span>
</div>
<div v-else class="message-list">
<div
v-for="item in messages"
:key="item.id"
class="message-item"
:class="{ unread: item.is_read === 0 }"
@click="handleMessageClick(item)"
>
<div class="message-item-header">
<span class="message-title">{{ item.title }}</span>
<span class="message-time">{{ formatTime(item.create_time) }}</span>
</div>
<div class="message-item-brief">{{ item.content }}</div>
</div>
</div>
</el-scrollbar>
</div>
</template>
</el-dropdown>
@@ -65,6 +95,12 @@
</div>
<div class="message-center">
<!-- 详情对话框 -->
<MessageDetailDialog
v-model="detailVisible"
:reminder="currentReminder"
@read-success="handleReadSuccess"
/>
</div>
</template>
@@ -74,8 +110,10 @@ import { useRouter, useRoute } from "vue-router";
import { useAllDataStore, useMenuStore, useTabsStore } from "@/stores";
import { useAuthStore } from "@/stores/auth";
import { logout } from "@/api/login";
import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled } from '@element-plus/icons-vue';
import { User, SwitchButton, Sunny, Moon, Refresh, Bell, HomeFilled, Loading, Message } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { getMySiteReminders, readAllSiteReminders } from "@/api/sitereminder";
import MessageDetailDialog from "./MessageDetailDialog.vue";
const router = useRouter();
const route = useRoute();
@@ -130,7 +168,86 @@ async function refreshCache() {
}
}
onMounted(loadMenu);
// 站内信消息中心逻辑
const unreadCount = ref(0);
const messages = ref<any[]>([]);
const loadingMessages = ref(false);
const detailVisible = ref(false);
const currentReminder = ref<any>({});
const formatTime = (timeStr: any) => {
if (!timeStr) return "-";
const date = new Date(timeStr);
return date.toLocaleString();
};
const fetchUnreadCount = async () => {
if (!authStore.token) return;
try {
const res = await getMySiteReminders({ page: 1, pageSize: 1, isRead: 0 });
if (res.code === 200) {
unreadCount.value = res.data.total || 0;
}
} catch (err) {
console.error("fetchUnreadCount failed", err);
}
};
const fetchMessages = async () => {
if (!authStore.token) return;
loadingMessages.value = true;
try {
const res = await getMySiteReminders({ page: 1, pageSize: 5 });
if (res.code === 200) {
messages.value = res.data.list || [];
}
} catch (err) {
console.error("fetchMessages failed", err);
} finally {
loadingMessages.value = false;
}
};
const handleDropdownVisibleChange = (visible: boolean) => {
if (visible) {
fetchMessages();
fetchUnreadCount();
}
};
const handleMessageClick = (item: any) => {
currentReminder.value = item;
detailVisible.value = true;
};
const handleReadSuccess = (id: number) => {
const msg = messages.value.find(m => m.id === id);
if (msg && msg.is_read === 0) {
msg.is_read = 1;
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
fetchUnreadCount();
};
const handleMarkAllRead = async () => {
try {
const res = await readAllSiteReminders();
if (res.code === 200) {
ElMessage.success("全部已读标记成功");
unreadCount.value = 0;
messages.value.forEach(m => m.is_read = 1);
}
} catch (err) {
console.error(err);
}
};
const handleMessagesChanged = () => {
fetchUnreadCount();
fetchMessages();
};
let timer: any = null;
// 根据菜单列表和当前路径计算出的面包屑导航
const breadcrumbs = computed(() => {
@@ -300,7 +417,8 @@ const themeIcon = computed(() => isDark.value ? Sunny : Moon);
let mediaQuery: MediaQueryList | null = null;
let handleChange: ((e: MediaQueryListEvent) => void) | null = null;
onMounted(() => {
onMounted(async () => {
await loadMenu();
initTheme();
// 监听系统主题变化
@@ -313,6 +431,12 @@ onMounted(() => {
}
};
mediaQuery.addEventListener('change', handleChange);
if (authStore.token) {
fetchUnreadCount();
timer = setInterval(fetchUnreadCount, 60000);
window.addEventListener('site-messages-changed', handleMessagesChanged);
}
});
// 组件卸载时清理
@@ -320,6 +444,10 @@ onUnmounted(() => {
if (mediaQuery && handleChange) {
mediaQuery.removeEventListener('change', handleChange);
}
if (timer) {
clearInterval(timer);
}
window.removeEventListener('site-messages-changed', handleMessagesChanged);
});
</script>
@@ -487,4 +615,133 @@ onUnmounted(() => {
:deep(.el-button) {
margin-left: 0 !important;
}
/* 消息中心下拉列表样式 */
.message-badge {
display: inline-flex;
align-items: center;
justify-content: center;
}
.message-dropdown-container {
width: 320px;
background-color: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
box-shadow: var(--el-box-shadow-light);
overflow: hidden;
display: flex;
flex-direction: column;
}
.message-dropdown-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
background-color: var(--el-fill-color-blank);
.title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.mark-all-btn {
font-size: 12px;
}
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 30px 0;
color: var(--el-text-color-secondary);
font-size: 13px;
gap: 8px;
.el-icon {
font-size: 20px;
}
}
.message-list {
display: flex;
flex-direction: column;
}
.message-item {
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.2s;
border-bottom: 1px solid var(--el-border-color-extra-light);
text-align: left; /* Ensure it is left-aligned */
&:hover {
background-color: var(--el-fill-color-light);
}
&.unread {
background-color: var(--el-color-primary-light-9);
&:hover {
background-color: var(--el-color-primary-light-8);
}
.message-title {
font-weight: 600;
color: var(--el-text-color-primary);
position: relative;
padding-left: 10px;
&::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--el-color-danger);
}
}
}
}
.message-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4px;
gap: 8px;
.message-title {
font-size: 13px;
color: var(--el-text-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
text-align: left;
}
.message-time {
font-size: 11px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
}
.message-item-brief {
font-size: 12px;
color: var(--el-text-color-regular);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
</style>
@@ -0,0 +1,118 @@
<template>
<el-dialog
v-model="visible"
title="消息详情"
width="600px"
destroy-on-close
align-center
class="reminder-detail-dialog"
>
<div class="reminder-detail-container" v-loading="loading">
<h3 class="reminder-title">{{ reminder.title }}</h3>
<div class="reminder-meta">
<span class="meta-item">
<el-icon><User /></el-icon>
发送者{{ getSenderName(reminder) }}
</span>
<span class="meta-item">
<el-icon><Calendar /></el-icon>
时间{{ formatTime(reminder.create_time) }}
</span>
</div>
<el-divider />
<div class="reminder-content">{{ reminder.content }}</div>
</div>
<template #footer>
<el-button @click="visible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { User, Calendar } from "@element-plus/icons-vue";
import { readSiteReminder } from "@/api/sitereminder";
const props = defineProps({
modelValue: Boolean,
reminder: {
type: Object,
default: () => ({})
},
viewOnly: {
type: Boolean,
default: false
}
});
const emit = defineEmits(["update:modelValue", "read-success"]);
const visible = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val)
});
const loading = ref(false);
const getSenderName = (item: any) => {
if (!item.sender_type) return "-";
if (item.sender_type === "system") return "系统";
if (item.sender_type === "platform") return `平台管理员 (ID: ${item.sender_id})`;
if (item.sender_type === "tenant") return `租户用户 (ID: ${item.sender_id})`;
return item.sender_type;
};
const formatTime = (timeStr: any) => {
if (!timeStr) return "-";
const date = new Date(timeStr);
return date.toLocaleString();
};
watch(() => props.modelValue, async (val) => {
if (val && props.reminder && !props.viewOnly && props.reminder.is_read === 0) {
loading.value = true;
try {
const res = await readSiteReminder(props.reminder.id);
if (res.code === 200) {
emit("read-success", props.reminder.id);
}
} catch (err) {
console.error(err);
} finally {
loading.value = false;
}
}
});
</script>
<style scoped>
.reminder-detail-container {
padding: 10px 20px;
}
.reminder-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 12px 0;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.reminder-meta {
display: flex;
gap: 20px;
color: var(--el-text-color-secondary);
font-size: 13px;
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
}
.reminder-content {
font-size: 15px;
line-height: 1.6;
color: var(--el-text-color-regular);
white-space: pre-wrap;
word-break: break-all;
min-height: 120px;
}
</style>