明树Git Lab

Commit 9bb75707 authored by zhanghan's avatar zhanghan

消息模块完成

parent d2c0f73b
Pipeline #111532 passed with stage
in 20 seconds
...@@ -82,12 +82,14 @@ ...@@ -82,12 +82,14 @@
import { computed, ref, onMounted, getCurrentInstance, watch } from "vue"; import { computed, ref, onMounted, getCurrentInstance, watch } from "vue";
import { useRouter, useRoute } from "vue-router"; import { useRouter, useRoute } from "vue-router";
import { useUserStore } from "@/stores/user.js"; import { useUserStore } from "@/stores/user.js";
import { useMessageStore } from "@/stores/message.js";
import windowConfig from "@/window"; import windowConfig from "@/window";
import LeftMenu from "./leftMenu.vue"; import LeftMenu from "./leftMenu.vue";
import axios from "axios"; import axios from "axios";
import { Bell, Avatar, ArrowUp, ArrowDown } from "@element-plus/icons-vue"; // 补充导入图标 import { Bell, Avatar, ArrowUp, ArrowDown } from "@element-plus/icons-vue";
const userStore = useUserStore(); const userStore = useUserStore();
const messageStore = useMessageStore();
const proxyRef = ref(null); const proxyRef = ref(null);
const { proxy } = getCurrentInstance(); const { proxy } = getCurrentInstance();
const excludeTabs = ["/homePage"]; const excludeTabs = ["/homePage"];
...@@ -223,14 +225,9 @@ const getResourceData = () => { ...@@ -223,14 +225,9 @@ const getResourceData = () => {
}; };
// 获取未读消息数量 // 获取未读消息数量
let messageCount = ref(0); const messageCount = computed(() => messageStore.messageCount);
const getMessageCount = () => { const getMessageCount = () => {
axios messageStore.fetchMessageCount();
.post(windowConfig.baseUrl + "/api/message/getMesCount", {})
.then((res) => {
messageCount.value = res.data.count || 0; // 增加默认值
})
.catch((err) => console.error("获取消息数量失败:", err)); // 增加错误处理
}; };
// 跳转消息列表页 // 跳转消息列表页
......
...@@ -26,7 +26,7 @@ const routes = [ ...@@ -26,7 +26,7 @@ const routes = [
name: "message", name: "message",
title: "消息中心", title: "消息中心",
meta: { title: "消息中心" }, meta: { title: "消息中心" },
component: () => import("@/views/systemManage/message.vue"), component: () => import("@/views/systemManage/newMessage.vue"),
}, },
{ {
path: "/homePage", path: "/homePage",
......
import { defineStore } from "pinia";
import axios from "axios";
import windowConfig from "@/window";
export const useMessageStore = defineStore("message", {
state: () => ({
messageCount: 0,
}),
actions: {
fetchMessageCount() {
axios
.post(windowConfig.baseUrl + "/api/message/getMesCount", {})
.then((res) => {
this.messageCount = res.data.count || 0;
})
.catch(() => {});
},
},
});
<template>
<div class="manage-container">
<div class="manage-wrap message-page">
<!-- 分类卡片 -->
<div class="message-cards" v-loading="loading">
<div
v-for="card in categoryCards"
:key="card.key"
class="message-card"
:class="{ 'is-active': activeKey === card.key }"
:style="{ '--card-color': card.color, '--card-bg': card.bg }"
@click="activeKey = card.key"
>
<div class="card-icon-wrap">
<el-icon :size="26"><component :is="card.icon" /></el-icon>
</div>
<div class="card-info">
<div class="card-name">{{ card.name }}</div>
<div class="card-nums">
<span class="card-total">{{ card.total }}</span>
<span class="card-divider">/</span>
<span class="card-unread" v-if="card.unread > 0"
>{{ card.unread }} 条未读</span
>
<span class="card-read" v-else>全部已读</span>
</div>
</div>
<div class="card-badge" v-if="card.unread > 0">{{ card.unread }}</div>
</div>
</div>
<!-- 下方左右两栏 -->
<div class="message-panels">
<!-- 左侧:最近消息时间线 -->
<div class="panel panel-recent">
<div class="panel-header">
<span class="panel-title">最近消息</span>
</div>
<div class="panel-body">
<div v-if="recentMessages.length === 0" class="panel-empty">
暂无消息
</div>
<div
v-for="(msg, idx) in recentMessages"
:key="msg.id"
class="recent-item"
@click="handlePreview(msg)"
>
<div class="recent-dot-line">
<span
class="recent-dot"
:class="{ 'dot-unread': !msg.isRead }"
:style="{ '--dot-color': getTypeColor(msg.type) }"
></span>
<span
class="recent-line"
v-if="idx < recentMessages.length - 1"
></span>
</div>
<div class="recent-content">
<div class="recent-top">
<span
class="recent-title"
:class="{ 'is-unread': !msg.isRead }"
>{{ msg.title }}</span
>
<el-tag
:type="getTypeTagColor(msg.type)"
size="small"
effect="light"
disable-transitions
>
{{ getTypeName(msg.type) }}
</el-tag>
</div>
<div class="recent-bottom">
<span class="recent-time">{{
relativeTime(msg.createdAt)
}}</span>
<el-tag
v-if="!msg.isRead"
type="danger"
size="small"
effect="plain"
disable-transitions
>未读</el-tag
>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧:分类列表 -->
<div class="panel panel-list">
<div class="panel-header">
<span class="panel-title">{{ currentCard.name }}</span>
<span class="panel-count">{{ filteredMessages.length }}</span>
</div>
<div class="panel-body panel-body-table">
<el-table
:data="filteredMessages"
style="width: 100%"
:header-cell-style="{
background: '#fafafa',
color: '#333',
fontWeight: 600,
}"
empty-text="暂无消息"
:row-class-name="tableRowClass"
@row-click="handlePreview"
class="message-table"
>
<el-table-column
label="标题"
min-width="280px"
show-overflow-tooltip
>
<template #default="{ row }">
<div class="msg-title-cell">
<span class="unread-dot" v-if="!row.isRead"></span>
<span class="read-dot" v-else></span>
<span
class="msg-title-text"
:class="{ 'is-unread': !row.isRead }"
>{{ row.title }}</span
>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="140" align="center">
<template #default="{ row }">
<el-tag
:type="getTypeTagColor(row.type)"
size="small"
effect="light"
disable-transitions
>
{{ getTypeName(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag
:type="row.isRead ? 'info' : 'danger'"
size="small"
effect="plain"
disable-transitions
>
{{ row.isRead ? "已读" : "未读" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="220" align="center">
<template #default="{ row }">{{
formatTime(row.createdAt)
}}</template>
</el-table-column>
<el-table-column
label="操作"
width="120"
align="center"
fixed="right"
>
<template #default="{ row }">
<el-button
link
type="primary"
size="small"
@click.stop="handlePreview(row)"
>查看</el-button
>
<el-button
link
type="primary"
size="small"
@click.stop="handleDelete(row)"
>删除</el-button
>
</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
<!-- 消息详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
width="560"
align-center
:close-on-click-modal="false"
@close="closeDetail"
>
<template #header>
<div class="msg-dialog-header">
<el-tag
:type="getTypeTagColor(messageInfo.type)"
size="small"
effect="light"
disable-transitions
>
{{ getTypeName(messageInfo.type) }}
</el-tag>
<span class="msg-dialog-time">{{
formatTime(messageInfo.createdAt)
}}</span>
</div>
</template>
<div class="msg-dialog-body">
<h3 class="msg-dialog-title">{{ messageInfo.title }}</h3>
<div class="msg-dialog-content">{{ messageInfo.content }}</div>
</div>
<template #footer>
<el-button @click="closeDetail">关闭</el-button>
<el-button type="primary" @click="toProjectPage">去处理</el-button>
</template>
</el-dialog>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, getCurrentInstance, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Message, Bell, Edit, Stamp } from "@element-plus/icons-vue";
import { useRouter } from "vue-router";
import { useMessageStore } from "@/stores/message.js";
const router = useRouter();
const { proxy } = getCurrentInstance();
const messageStore = useMessageStore();
const loading = ref(false);
const allMessages = ref([]);
const activeKey = ref("all");
const getTypeName = (type) => {
if (type === 2) return "项目初步审核";
if (type === 3) return "项目终审";
return "系统消息";
};
const getTypeTagColor = (type) => {
if (type === 2) return "warning";
if (type === 3) return "danger";
return "success";
};
const getTypeColor = (type) => {
if (type === 2) return "#e6a23c";
if (type === 3) return "#f56c6c";
return "#67c23a";
};
const formatTime = (time) => {
if (!time) return "";
return proxy.moment(time).format("YYYY-MM-DD HH:mm:ss");
};
const relativeTime = (time) => {
if (!time) return "";
const m = proxy.moment(time);
const now = proxy.moment();
const diffMin = now.diff(m, "minutes");
if (diffMin < 1) return "刚刚";
if (diffMin < 60) return diffMin + " 分钟前";
const diffHour = now.diff(m, "hours");
if (diffHour < 24) return diffHour + " 小时前";
const diffDay = now.diff(m, "days");
if (diffDay < 7) return diffDay + " 天前";
return m.format("MM-DD HH:mm");
};
// 分类定义
const categoryCards = computed(() => {
const msgs = allMessages.value;
const sys = msgs.filter((m) => m.type !== 2 && m.type !== 3);
const chubu = msgs.filter((m) => m.type === 2);
const zhongshen = msgs.filter((m) => m.type === 3);
return [
{
key: "all",
name: "全部消息",
icon: Message,
color: "#409eff",
bg: "rgba(64,158,255,0.06)",
total: msgs.length,
unread: msgs.filter((m) => !m.isRead).length,
},
{
key: "system",
name: "系统消息",
icon: Bell,
color: "#67c23a",
bg: "rgba(103,194,58,0.06)",
total: sys.length,
unread: sys.filter((m) => !m.isRead).length,
},
{
key: "chubu",
name: "项目初步审核",
icon: Edit,
color: "#e6a23c",
bg: "rgba(230,162,60,0.06)",
total: chubu.length,
unread: chubu.filter((m) => !m.isRead).length,
},
{
key: "zhongshen",
name: "项目终审",
icon: Stamp,
color: "#f56c6c",
bg: "rgba(245,108,108,0.06)",
total: zhongshen.length,
unread: zhongshen.filter((m) => !m.isRead).length,
},
];
});
const currentCard = computed(() => {
return (
categoryCards.value.find((c) => c.key === activeKey.value) ||
categoryCards.value[0]
);
});
const filteredMessages = computed(() => {
const msgs = allMessages.value;
if (activeKey.value === "all") return msgs;
if (activeKey.value === "system")
return msgs.filter((m) => m.type !== 2 && m.type !== 3);
if (activeKey.value === "chubu") return msgs.filter((m) => m.type === 2);
if (activeKey.value === "zhongshen") return msgs.filter((m) => m.type === 3);
return msgs;
});
const recentMessages = computed(() => {
return [...filteredMessages.value]
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
.slice(0, 10);
});
const tableRowClass = ({ row }) => {
return row.isRead ? "row-read" : "row-unread";
};
// 数据加载
const loadAllMessages = () => {
loading.value = true;
proxy.$post({
url: "/api/message/getUserMessages",
data: { page: 1, pageSize: 9999 },
callback: (data) => {
allMessages.value = data.rows || [];
loading.value = false;
},
error: () => {
loading.value = false;
ElMessage.error("加载数据失败");
},
});
};
onMounted(() => {
loadAllMessages();
});
// 消息详情
const detailDialogVisible = ref(false);
const messageInfo = ref({});
const handlePreview = (row) => {
proxy.$post({
url: "/api/message/getMessageInfo",
data: { id: row.id },
callback: (data) => {
detailDialogVisible.value = true;
messageInfo.value = data;
if (!row.isRead) {
row.isRead = true;
messageStore.fetchMessageCount();
}
},
error: () => {
ElMessage.error("加载数据失败");
},
});
};
const closeDetail = () => {
messageInfo.value = {};
detailDialogVisible.value = false;
};
const toProjectPage = () => {
router.push({
name: "addProject",
query: { projectId: messageInfo.value.projectId, isPreview: true },
});
};
const handleDelete = (row) => {
ElMessageBox.confirm("确定删除该条消息?", "提示", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
}).then(() => {
proxy.$post({
url: "/api/message/deleteMessage",
data: { id: row.id },
callback: () => {
ElMessage.success("删除成功");
loadAllMessages();
messageStore.fetchMessageCount();
},
error: () => {
ElMessage.error("删除失败");
},
});
});
};
</script>
<style lang="less" scoped>
.message-page {
padding: 12px;
overflow-y: auto;
margin-bottom: 18px;
}
.message-page-header {
margin-bottom: 16px;
}
.message-page-title {
font-size: 18px;
font-weight: 600;
color: #1d2129;
}
/* ========== 分类卡片 ========== */
.message-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 16px;
}
.message-card {
position: relative;
background: #fff;
border-radius: 8px;
padding: 18px 16px;
display: flex;
align-items: center;
gap: 14px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
border: 2px solid transparent;
user-select: none;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&.is-active {
border-color: var(--card-color);
background: var(--card-bg);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
}
}
.card-icon-wrap {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
background: var(--card-color);
color: #fff;
flex-shrink: 0;
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
font-size: 14px;
font-weight: 600;
color: #1d2129;
margin-bottom: 4px;
}
.card-nums {
font-size: 12px;
color: #86909c;
display: flex;
align-items: center;
gap: 4px;
}
.card-divider {
color: #dcdfe6;
}
.card-unread {
color: #f56c6c;
font-weight: 500;
}
.card-read {
color: #67c23a;
}
.card-badge {
position: absolute;
top: 8px;
right: 10px;
min-width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
padding: 0 5px;
border-radius: 9px;
background: #f56c6c;
color: #fff;
font-size: 11px;
font-weight: 500;
}
/* ========== 下方两栏 ========== */
.message-panels {
display: grid;
grid-template-columns: 340px 1fr;
gap: 14px;
flex: 1;
min-height: 0;
}
.panel {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
min-height: 0;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px 12px;
border-bottom: 1px solid #f2f3f5;
flex-shrink: 0;
}
.panel-title {
font-size: 15px;
font-weight: 600;
color: #1d2129;
}
.panel-count {
font-size: 13px;
color: #86909c;
}
.panel-body {
flex: 1;
overflow-y: auto;
padding: 10px 18px 16px;
}
.panel-body-table {
padding: 0;
}
.panel-empty {
text-align: center;
color: #c0c4cc;
font-size: 14px;
padding: 48px 0;
}
/* 左侧时间线 */
.recent-item {
display: flex;
gap: 12px;
cursor: pointer;
&:hover .recent-title {
color: #409eff;
}
}
.recent-dot-line {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 6px;
width: 12px;
flex-shrink: 0;
}
.recent-dot {
width: 10px;
height: 10px;
min-height: 10px;
border-radius: 50%;
background: var(--dot-color, #dcdfe6);
flex-shrink: 0;
opacity: 0.45;
&.dot-unread {
opacity: 1;
box-shadow: 0 0 0 3px rgba(245, 108, 108, 0.15);
}
}
.recent-line {
width: 1px;
flex: 1;
background: #e8e8e8;
margin: 4px 0;
}
.recent-content {
flex: 1;
min-width: 0;
padding-bottom: 12px;
border-bottom: 1px solid #f7f7f7;
}
.recent-item:last-child .recent-content {
border-bottom: none;
padding-bottom: 0;
}
.recent-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.recent-title {
flex: 1;
min-width: 0;
font-size: 13px;
color: #4e5969;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s;
&.is-unread {
font-weight: 600;
color: #1d2129;
}
}
.recent-bottom {
display: flex;
align-items: center;
gap: 8px;
}
.recent-time {
font-size: 12px;
color: #c0c4cc;
}
/* 右侧表格 */
.message-table {
flex: 1;
}
:deep(.message-table) {
.el-table__row {
cursor: pointer;
transition: background 0.15s;
}
.row-unread td {
background: #f0f7ff !important;
}
.el-table__row:hover > td {
background: #ecf5ff !important;
}
}
.msg-title-cell {
display: flex;
align-items: center;
gap: 8px;
}
.unread-dot {
width: 8px;
height: 8px;
min-width: 8px;
border-radius: 50%;
background: #f56c6c;
}
.read-dot {
width: 8px;
height: 8px;
min-width: 8px;
border-radius: 50%;
background: #dcdfe6;
}
.msg-title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.is-unread {
font-weight: 600;
color: #1d2129;
}
/* ========== 详情弹窗 ========== */
.msg-dialog-header {
display: flex;
align-items: center;
gap: 12px;
}
.msg-dialog-time {
font-size: 13px;
color: #86909c;
}
.msg-dialog-body {
padding: 4px 0 12px;
}
.msg-dialog-title {
font-size: 17px;
font-weight: 600;
color: #1d2129;
margin: 0 0 16px;
line-height: 1.5;
}
.msg-dialog-content {
font-size: 14px;
color: #4e5969;
line-height: 1.8;
background: #f7f8fa;
border-radius: 6px;
padding: 14px 16px;
}
</style>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment