feat(msgcenter): /msg 换真实数据——服务端筛选+滚动分页、铃铛直跳、unreadCount 上提 store、WS 通知带应用名
- views/msg/index.vue:数据层整体替换(msgType 0/1/2 映射、isRead 1=已读、fmtTime 重写绝对时间);列表走 msgCenter/list 服务端筛选(msgType/appKey/isRead/keyword 防抖 300ms),滚动到底追加下一页,切视图/搜索重置第 1 页;左栏分类计数走 summary(旧版 UserMsg 缺字段降级显示 —),应用清单取 store 应用列表、计数同源 todos;点击卡片标已读、全部已读、去处理(showJump/jumpUrl)、审批直批(共享弹窗);失败态有重试;WS 新消息经 mittBus 自刷新 - components/Header.vue:铃铛直跳 /msg(移除 Poptip 浮层与 900 宽 Modal 及 columns/tableApi 死代码);unreadCount 上提 pinia store(角标与 /msg、LeftMenu 同源);WS Notice.info 标题带应用名(appKey 匹配 store 应用列表,缺失回退类型标签);壁纸/LOGO Modal 不动 - components/MsgApprovalModal.vue(新增):审批直批弹窗抽共享组件,逻辑照抄原 Header 内联实现(同意可选意见/拒绝必填校验/三种失败反馈) - components/LeftMenu.vue:「消息」入口(url=/msg)未读角标(读 store,红底白字胶囊) - store/index.ts:unreadCount + setUnreadCount/incUnread/decUnread - api/BaseApi.ts:getMsgCenterList 参数类型放宽(服务端筛选项);getMsgCenterSummary 返回类型补分类计数字段(可缺省) - assets/styles/header.scss:删除消息浮层死样式 .message-wrap 段
This commit is contained in:
+14
-4
@@ -98,21 +98,31 @@ class Base extends Abstract {
|
||||
// ===== 消息中心代理(§3.5:经后端签名代理 UserMsg,前端不直连)=====
|
||||
|
||||
/**
|
||||
* 消息中心分页列表({total, list[]};元素含改写后的绝对 jumpUrl)
|
||||
* 消息中心分页列表({total, list[]};元素含改写后的绝对 jumpUrl)。
|
||||
* 可选服务端筛选:msgType 0/1/2、appKey、isRead 0/1、keyword 标题/内容模糊。
|
||||
* @memberof Basic
|
||||
*/
|
||||
getMsgCenterList(params: PageParams) {
|
||||
getMsgCenterList(params: PageParams & { msgType?: number; appKey?: string; isRead?: number; keyword?: string }) {
|
||||
const url = path + "/msgCenter/list";
|
||||
return this.getRequest<PageData>({ url, params });
|
||||
}
|
||||
|
||||
/**
|
||||
* 铃铛摘要:未读数 + 各应用待办角标
|
||||
* 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount,
|
||||
* 旧版 UserMsg 无 summary 端点时这些字段缺省,前端按降级处理)
|
||||
* @memberof Basic
|
||||
*/
|
||||
getMsgCenterSummary() {
|
||||
const url = path + "/msgCenter/summary";
|
||||
return this.getRequest<{ unreadCount: number; todos: { appKey: string; count: number }[] }>({ url });
|
||||
return this.getRequest<{
|
||||
unreadCount: number;
|
||||
todos: { appKey: string; count: number }[];
|
||||
sysUnread?: number;
|
||||
bizUnread?: number;
|
||||
approvalUnread?: number;
|
||||
total?: number;
|
||||
readCount?: number;
|
||||
}>({ url });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -150,99 +150,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.message-wrap {
|
||||
h3.title {
|
||||
height: 50px;
|
||||
padding: 12px 16px;
|
||||
color: #272829;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
.msg-ul {
|
||||
width: 360px;
|
||||
background: #fff;
|
||||
padding-bottom: 0;
|
||||
border-radius: 10px;
|
||||
max-height: 240px;
|
||||
min-height: 100px;
|
||||
li {
|
||||
height: 46px;
|
||||
padding: 5px 18px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
line-height: 36px;
|
||||
border-bottom: 1px dotted #e8eaec;
|
||||
|
||||
h4 {
|
||||
width: calc(100% - 40px);
|
||||
float: left;
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
.content {
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.read {
|
||||
color: #999999;
|
||||
}
|
||||
a {
|
||||
float: right;
|
||||
height: 32px;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
.no-msg-box {
|
||||
display: flex;
|
||||
height: 100px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: none;
|
||||
}
|
||||
.app-name-box {
|
||||
border: none;
|
||||
color: #fff !important;
|
||||
background: #577afb;
|
||||
border-radius: 3px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.fix a img {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
}
|
||||
.footer {
|
||||
background-color: #f9f9f9;
|
||||
height: 54px;
|
||||
line-height: 54px;
|
||||
padding: 0 20px;
|
||||
|
||||
font-size: 15px;
|
||||
border-top: 1px solid #e8eaec;
|
||||
|
||||
a.link-all {
|
||||
color: #999999;
|
||||
font-weight: 550;
|
||||
}
|
||||
a.read-all {
|
||||
color: #5979ea;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.role-info {
|
||||
height: 22px;
|
||||
color: #6b8cf4 !important;
|
||||
|
||||
+34
-306
@@ -23,58 +23,14 @@
|
||||
</div>
|
||||
</Transition>
|
||||
</li>
|
||||
<!-- 铃铛直跳 /msg(消息对接 2026-09-30:移除 Poptip 浮层与 900 宽列表 Modal,
|
||||
未读数上提 store 与 /msg 页左栏同源;审批直批弹窗抽至 MsgApprovalModal) -->
|
||||
<li>
|
||||
<Poptip class="fl" popper-class="message-wrap reset-panel" :width="360" trigger="click"
|
||||
placement="bottom-end" v-model="isMsgShow" :transfer="true">
|
||||
<div class="icon-box" v-wave>
|
||||
<Badge :count="unreadCount" :offset="[2, 0]" overflow-count="99">
|
||||
<SvgIcon name="header-2" color="#333333" />
|
||||
</Badge>
|
||||
</div>
|
||||
<template #content>
|
||||
<h3 class="title">查看消息</h3>
|
||||
<vuescroll>
|
||||
<ul class="msg-ul">
|
||||
<li class="no-msg-box" v-if="userMsgList.length == 0">暂无消息</li>
|
||||
<template v-else v-for="(item, index) in userMsgList">
|
||||
<li class="fix">
|
||||
<h4 :class="{
|
||||
'text-ellipsis': true,
|
||||
read: item.isRead === 1,
|
||||
}">
|
||||
<span class="app-name-box">{{ item.appName || msgTypeLabel(item.msgType) }}</span>
|
||||
<span class="content" :title="item.content"
|
||||
@click="onClickMsg(item)">
|
||||
{{ item.title }}:{{ item.content }}
|
||||
</span>
|
||||
</h4>
|
||||
<span v-if="item.msgType === 2" class="approval-box">
|
||||
<a href="javascript:;" class="approval-link approve"
|
||||
@click="openApproval(item, 'approve')">同意</a>
|
||||
<a href="javascript:;" class="approval-link reject"
|
||||
@click="openApproval(item, 'reject')">拒绝</a>
|
||||
</span>
|
||||
<a v-if="item.showJump" href="javascript:;" @click="onClickMsg(item)"
|
||||
title="查看详情">
|
||||
<SvgIcon name="arrow-right" color="#bbbbbb" />
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</vuescroll>
|
||||
<div class="footer fix">
|
||||
<a href="javascript:;" class="fl link-all" @click="showMsgList">查看全部</a>
|
||||
<a href="javascript:;" class="fr read-all" v-if="userMsgList.length > 0"
|
||||
@click="readMsgAll(1)">
|
||||
全部已读
|
||||
</a>
|
||||
|
||||
<!-- <a class="tzql_r" @click="clearMsg" href="javascript:;" title="清空消息"
|
||||
><img src="image/msg-clear.png"
|
||||
/></a> -->
|
||||
</div>
|
||||
</template>
|
||||
</Poptip>
|
||||
<div class="icon-box" v-wave title="消息中心" @click="router.push('/msg')">
|
||||
<Badge :count="unreadCount" :offset="[2, 0]" overflow-count="99">
|
||||
<SvgIcon name="header-2" color="#333333" />
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<Poptip class="fl" popper-class="roleinfo-wrap reset-panel" :width="240" trigger="click"
|
||||
@@ -175,17 +131,6 @@
|
||||
</ul>
|
||||
</header>
|
||||
|
||||
<Modal v-model="isMsgListShow" width="900" :mask="true" title="消息" class-name="msg-modal" :transfer="false">
|
||||
<div class="fix mb-10">
|
||||
<Button type="primary" @click="resetMsgPage" size="small" class="fl ml-10 width-10" ghost>刷新</Button>
|
||||
<Button type="primary" @click="readMsgAll(2)" size="small" class="fl ml-10 width-10" ghost>全部已读</Button>
|
||||
</div>
|
||||
<TablePage :table-height="360" ref="refTablePage" page-type="small" :dataApi="tableApi" :pageParams="pageParams"
|
||||
:columns="columns" :auto-load="false" />
|
||||
<template #footer>
|
||||
<Button type="primary" ghost @click="isMsgListShow = false">关闭</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
<Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸"
|
||||
:transfer="false">
|
||||
<vuescroll>
|
||||
@@ -277,21 +222,8 @@
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 卡片直批弹窗(B3 §2):同意可选意见 / 拒绝意见必填;身份服务端取会话 -->
|
||||
<Modal v-model="approval.visible" width="460" :mask-closable="false"
|
||||
:title="approval.action === 'approve' ? '同意办理' : '拒绝办理'">
|
||||
<div class="approval-body">
|
||||
<p class="approval-title text-ellipsis">{{ approval.title || "确认办理该待办?" }}</p>
|
||||
<Input v-model="approval.opinion" type="textarea" :rows="3" maxlength="200" show-word-limit
|
||||
:placeholder="approval.action === 'approve' ? '审批意见(可选)' : '请输入拒绝意见(必填)'" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="approval.submitting" @click="submitApproval">
|
||||
{{ approval.action === "approve" ? "确认同意" : "确认拒绝" }}
|
||||
</Button>
|
||||
<Button @click="approval.visible = false">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
<!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
|
||||
<MsgApprovalModal ref="refMsgApproval" />
|
||||
|
||||
<MenuBarModal ref="refMenuBarModal" />
|
||||
<StatsBarModal ref="refStatsBarModal" />
|
||||
@@ -304,6 +236,7 @@ import SvgIcon from "@/components/SvgIcon.vue";
|
||||
import vuescroll from "vuescroll";
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, inject, watch } from "vue";
|
||||
import { useDefaultStore } from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { requireImg } from "@/utils/hook/useImg";
|
||||
import ContextMenu from "@/components/ContextMenu.vue";
|
||||
@@ -311,11 +244,10 @@ import MenuBarModal from "@/components/MenuBarModal.vue";
|
||||
import StatsBarModal from "@/components/StatsBarModal.vue";
|
||||
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
import TablePage from "@/components/TablePage.vue";
|
||||
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
|
||||
import { Notice } from "view-ui-plus";
|
||||
import { IframePageParmas, RoleInfo } from "@/types";
|
||||
import { isEqual, orderBy } from "lodash";
|
||||
import { PageParams } from "@/types/Basics";
|
||||
import UserSettingApi from "@/api/UserSettingApi";
|
||||
import mittBus from "@/utils/mitt";
|
||||
import MenuBarApi from "@/api/MenuBarApi";
|
||||
@@ -324,10 +256,13 @@ import Utils from "@/utils";
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useDefaultStore();
|
||||
const refTablePage: any = ref(null);
|
||||
const refMsgApproval = ref<any>();
|
||||
const { $conf } = useCurrentInstance();
|
||||
const baseUrl = computed(() => $conf.fileHost);
|
||||
|
||||
/** 未读消息数上提 pinia store(消息对接 2026-09-30):Header 角标与 /msg 页左栏、LeftMenu 同源 */
|
||||
const { unreadCount } = storeToRefs(store);
|
||||
|
||||
const refSearchInput = ref<any>();
|
||||
const refMenuBarModal = ref<any>();
|
||||
const refStatsBarModal = ref<any>();
|
||||
@@ -341,19 +276,14 @@ const logoUrl = computed(() => {
|
||||
const sysLogo = logoList.value.find(x => x.type == 0);
|
||||
return sysLogo ? sysLogo.url : "";
|
||||
});
|
||||
const isMsgShow = ref(false),
|
||||
isMsgListShow = ref(false),
|
||||
isUserInfoShow = ref(false),
|
||||
const isUserInfoShow = ref(false),
|
||||
isRoleInfoShow = ref(false),
|
||||
isWallpaperShow = ref(false),
|
||||
isLogoShow = ref(false),
|
||||
isSearching = ref(false),
|
||||
searchAppText = ref<string | null>(null),
|
||||
wallpaperList = ref<any[]>([]),
|
||||
logoList = ref<any[]>([]),
|
||||
userMsgList = ref<any[]>([]),
|
||||
/** 未读消息数(msgCenter/summary 拉取 + WS newMessage 实时累加) */
|
||||
unreadCount = ref(0);
|
||||
logoList = ref<any[]>([]);
|
||||
|
||||
// ===== 消息中心 WS 实时推送(UserMsg /ws/msg,一次性票据握手;断线 5s 重连换新票) =====
|
||||
let msgWs: WebSocket | null = null;
|
||||
@@ -364,7 +294,7 @@ let msgWsClosed = false;
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const summary = await BaseApi.getMsgCenterSummary();
|
||||
unreadCount.value = (summary && summary.unreadCount) || 0;
|
||||
store.setUnreadCount(summary && summary.unreadCount);
|
||||
} catch (e) {
|
||||
/* 摘要失败不打扰页面 */
|
||||
}
|
||||
@@ -415,12 +345,22 @@ async function connectMsgWs() {
|
||||
};
|
||||
}
|
||||
|
||||
/** WS 帧按 appKey 匹配应用名(store 应用列表);查无回退类型标签(缺失降级,不报错) */
|
||||
function frameAppName(frame: any) {
|
||||
const appKey = frame && frame.appKey;
|
||||
if (appKey) {
|
||||
const hit = store.getFlattenAppList.find((x: any) => x.appKey == appKey || x.appCode == appKey);
|
||||
if (hit && hit.name) return hit.name;
|
||||
}
|
||||
return msgTypeLabel(frame ? frame.msgType : null);
|
||||
}
|
||||
|
||||
function onNewMessage(frame: any) {
|
||||
unreadCount.value += 1;
|
||||
reloadMsg();
|
||||
if (isMsgListShow.value) resetMsgPage();
|
||||
store.incUnread();
|
||||
// /msg 页开着时自刷新(页内监听该事件重拉第一页与摘要)
|
||||
mittBus.emit("msgCenter:newMessage", frame);
|
||||
Notice.info({
|
||||
title: frame.title || "新消息",
|
||||
title: `【${frameAppName(frame)}】新消息`,
|
||||
desc: frame.content || "",
|
||||
duration: 5,
|
||||
});
|
||||
@@ -461,107 +401,8 @@ const userRole = computed(() => store.userRoleList);
|
||||
|
||||
const toggleSearch: any = inject("toggleSearch");
|
||||
|
||||
const pageParams: PageParams = reactive({ page: 1, count: 10 });
|
||||
const tableApi = (params: PageParams) => BaseApi.getMsgCenterList(params);
|
||||
const columns = ref<any[]>([
|
||||
{
|
||||
type: "index",
|
||||
title: "序号",
|
||||
width: 70,
|
||||
align: "center",
|
||||
},
|
||||
{
|
||||
title: "应用",
|
||||
width: 110,
|
||||
align: "center",
|
||||
render(h: any, params: any) {
|
||||
return h("span", params.row.appName || msgTypeLabel(params.row.msgType));
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "消息内容",
|
||||
render(h: any, params: any) {
|
||||
const row = params.row;
|
||||
return h("span", { attrs: { title: row.content } }, `${row.title || ""}:${row.content || ""}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 100,
|
||||
render(h: any, params: any) {
|
||||
return h("span", params.row.isRead === 1 ? "已读" : "未读");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "日期",
|
||||
align: "center",
|
||||
width: 200,
|
||||
key: "sendTime",
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 210,
|
||||
align: "center",
|
||||
render(h: any, params: any) {
|
||||
const row = params.row;
|
||||
const nodes: any[] = [];
|
||||
// 审批待办(msgType=2)直批入口:taskId 由服务端按 bizId 匹配定位;
|
||||
// 无权限/已处理由后端返回文案,直批按钮不受跳转权限影响
|
||||
if (row.msgType === 2 && row.msgId) {
|
||||
nodes.push(
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
attrs: { href: "javascript:;" },
|
||||
on: {
|
||||
click: () => openApproval(row, "approve"),
|
||||
},
|
||||
},
|
||||
"同意"
|
||||
),
|
||||
h("span", " "),
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
attrs: { href: "javascript:;" },
|
||||
on: {
|
||||
click: () => openApproval(row, "reject"),
|
||||
},
|
||||
},
|
||||
"拒绝"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (row.showJump) {
|
||||
if (nodes.length) nodes.push(h("span", " "));
|
||||
nodes.push(
|
||||
h(
|
||||
"a",
|
||||
{
|
||||
attrs: { href: "javascript:;" },
|
||||
on: {
|
||||
click: () => {
|
||||
if (row.isRead !== 1 && row.msgId) {
|
||||
BaseApi.readMsgCenter(row.msgId);
|
||||
row.isRead = 1;
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
}
|
||||
window.open(row.jumpUrl, "_blank");
|
||||
},
|
||||
},
|
||||
},
|
||||
"去处理"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!nodes.length) return h("span", "—");
|
||||
return h("span", nodes);
|
||||
},
|
||||
},
|
||||
]);
|
||||
onMounted(async () => {
|
||||
setTimeout(() => {
|
||||
reloadMsg();
|
||||
loadSummary();
|
||||
connectMsgWs();
|
||||
// isWallpaperShow.value = true;
|
||||
@@ -610,94 +451,11 @@ function onUserinfoClickRoles() {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function readMsgAll(type: 1 | 2) {
|
||||
await BaseApi.readMsgCenterAll();
|
||||
await reloadMsg();
|
||||
unreadCount.value = 0;
|
||||
if (type == 2) resetMsgPage();
|
||||
}
|
||||
|
||||
async function reloadMsg() {
|
||||
const result: any = await BaseApi.getMsgCenterList({ page: 1, count: 10 });
|
||||
userMsgList.value = result.list || [];
|
||||
}
|
||||
|
||||
/** 消息类型名(UserMsg msgType:0系统通知/1业务提醒/2审批待办) */
|
||||
function msgTypeLabel(msgType: number) {
|
||||
/** 消息类型名(UserMsg msgType:0系统通知/1业务提醒/2审批待办);WS 帧缺 appName 时的降级标签 */
|
||||
function msgTypeLabel(msgType: number | null) {
|
||||
return { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[msgType as 0 | 1 | 2] || "系统通知";
|
||||
}
|
||||
|
||||
/** 点击消息:未读先标读;配了 jumpUrl(如 flow.task 待办)新窗口打开落地页 */
|
||||
async function onClickMsg(item: any) {
|
||||
if (item.isRead !== 1 && item.msgId) {
|
||||
await BaseApi.readMsgCenter(item.msgId);
|
||||
item.isRead = 1;
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
}
|
||||
if (item.jumpUrl) window.open(item.jumpUrl, "_blank");
|
||||
}
|
||||
|
||||
// ===== 卡片直批(B3 §2):同意/拒绝待办;operatorId/schId/operatorName 服务端取会话 =====
|
||||
const approval = reactive({
|
||||
visible: false,
|
||||
msgId: 0,
|
||||
action: "approve" as "approve" | "reject",
|
||||
title: "",
|
||||
opinion: "",
|
||||
submitting: false,
|
||||
});
|
||||
|
||||
function openApproval(item: any, action: "approve" | "reject") {
|
||||
approval.msgId = item.msgId;
|
||||
approval.action = action;
|
||||
approval.title = item.title ? String(item.title) : "";
|
||||
approval.opinion = "";
|
||||
approval.visible = true;
|
||||
}
|
||||
|
||||
async function submitApproval() {
|
||||
if (approval.submitting) return;
|
||||
if (approval.action === "reject" && !approval.opinion.trim()) {
|
||||
Notice.warning({ title: "拒绝必须填写意见", duration: 3 });
|
||||
return;
|
||||
}
|
||||
approval.submitting = true;
|
||||
try {
|
||||
const res: any = await BaseApi.handleMsg(approval.msgId, approval.action, approval.opinion.trim());
|
||||
if (res && res.ok) {
|
||||
Notice.success({ title: "办理成功", duration: 3 });
|
||||
approval.visible = false;
|
||||
markRowRead(approval.msgId);
|
||||
} else {
|
||||
Notice.warning({ title: (res && res.message) || "办理失败", duration: 5 });
|
||||
}
|
||||
// 成功/失效/被他人处理等场景都同步刷新,让卡片状态跟上服务端
|
||||
reloadMsg();
|
||||
} catch (e) {
|
||||
// flow-center 不可达等:降级为提示,不阻塞消息卡片其余功能
|
||||
Notice.error({ title: "流程中心暂不可用,请稍后重试", duration: 5 });
|
||||
} finally {
|
||||
approval.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function markRowRead(msgId: number) {
|
||||
const row = userMsgList.value.find((m: any) => m.msgId === msgId);
|
||||
if (row && row.isRead !== 1) {
|
||||
row.isRead = 1;
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
}
|
||||
}
|
||||
|
||||
function resetMsgPage() {
|
||||
refTablePage.value.reloadData();
|
||||
}
|
||||
function showMsgList() {
|
||||
isMsgShow.value = false;
|
||||
router.push("/msg");
|
||||
refTablePage.value.reloadData();
|
||||
}
|
||||
|
||||
async function wallpaperSuccess(response: any, file: any, fileListt: any) {
|
||||
await getUserWallpaperList();
|
||||
}
|
||||
@@ -802,36 +560,6 @@ function logout() {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import url("@/assets/styles/header.scss");
|
||||
|
||||
::v-deep .ivu-table td {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
/* 卡片直批(B3):待办消息行内「同意/拒绝」入口 */
|
||||
.approval-box {
|
||||
margin-right: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.approval-link {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
color: #577afb;
|
||||
}
|
||||
|
||||
.approval-link.reject {
|
||||
color: #ed4014;
|
||||
}
|
||||
|
||||
.approval-body {
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.approval-title {
|
||||
margin-bottom: 10px;
|
||||
color: #515a6e;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<FetchSvg v-else size="24" :src="$conf.serviceHost + item.icon"
|
||||
:color="index == activeIndex ? '#fff' : '#ececec'" />
|
||||
<span>{{ item.name }}</span>
|
||||
<!-- 「消息」入口未读角标(读 store,与 Header 铃铛同源) -->
|
||||
<span v-if="item.url === '/msg' && store.unreadCount > 0" class="menu-badge">
|
||||
{{ store.unreadCount > 99 ? "99+" : store.unreadCount }}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
@@ -147,6 +151,7 @@ async function linkPage(item: any, index: number) {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
li {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
-webkit-transform: translateZ(0);
|
||||
@@ -212,6 +217,23 @@ async function linkPage(item: any, index: number) {
|
||||
margin-top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 「消息」入口未读角标:图标右上角红底白字胶囊(与 Header Badge 同源数据) */
|
||||
.menu-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: #f24822;
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.menu-arrow_box {
|
||||
position: relative;
|
||||
left: 62px;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<!-- 消息卡片直批弹窗(B3 §2,消息对接 2026-09-30 抽共享):同意可选意见 / 拒绝意见必填;
|
||||
身份(operatorId/schId/operatorName)服务端取会话。Header 铃铛域已无直批入口,
|
||||
本组件供 /msg 消息中心页使用,逻辑照抄原 Header 内联实现。 -->
|
||||
<Modal :model-value="visible" width="460" :mask-closable="false"
|
||||
:title="action === 'approve' ? '同意办理' : '拒绝办理'" @on-cancel="close">
|
||||
<div class="approval-body">
|
||||
<p class="approval-title text-ellipsis">{{ title || "确认办理该待办?" }}</p>
|
||||
<Input v-model="opinion" type="textarea" :rows="3" maxlength="200" show-word-limit
|
||||
:placeholder="action === 'approve' ? '审批意见(可选)' : '请输入拒绝意见(必填)'" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button type="primary" :loading="submitting" @click="submit">
|
||||
{{ action === "approve" ? "确认同意" : "确认拒绝" }}
|
||||
</Button>
|
||||
<Button @click="close">取消</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { Notice } from "view-ui-plus";
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
|
||||
/**
|
||||
* 审批直批弹窗(msgCenter/handle 代理)。
|
||||
* 父组件 open({msgId, title, action}) 打开;成功(后端 ok)后 emit("handled", msgId),
|
||||
* 失败/已被处理等场景只弹后端文案,不关弹窗(成功/失败/已被处理三种场景都有反馈)。
|
||||
*/
|
||||
const emit = defineEmits<{ (e: "handled", msgId: number): void }>();
|
||||
|
||||
const visible = ref(false);
|
||||
const msgId = ref(0);
|
||||
const title = ref("");
|
||||
const action = ref<"approve" | "reject">("approve");
|
||||
const opinion = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
function open(row: { msgId: number; title?: string }, act: "approve" | "reject") {
|
||||
msgId.value = row.msgId;
|
||||
action.value = act;
|
||||
title.value = row.title ? String(row.title) : "";
|
||||
opinion.value = "";
|
||||
submitting.value = false;
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return;
|
||||
if (action.value === "reject" && !opinion.value.trim()) {
|
||||
Notice.warning({ title: "拒绝必须填写意见", duration: 3 });
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res: any = await BaseApi.handleMsg(msgId.value, action.value, opinion.value.trim());
|
||||
if (res && res.ok) {
|
||||
Notice.success({ title: "办理成功", duration: 3 });
|
||||
visible.value = false;
|
||||
emit("handled", msgId.value);
|
||||
} else {
|
||||
Notice.warning({ title: (res && res.message) || "办理失败", duration: 5 });
|
||||
}
|
||||
} catch (e) {
|
||||
// flow-center 不可达等:降级为提示,不阻塞消息卡片其余功能
|
||||
Notice.error({ title: "流程中心暂不可用,请稍后重试", duration: 5 });
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.approval-body {
|
||||
.approval-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #2c3138;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -13,6 +13,8 @@ export interface IdefaultStore {
|
||||
curUserMenuList: Array<any>; //当前角色菜单
|
||||
curUserAppList: Array<any>; //当前角色所有应用
|
||||
curRoleUid: string | null; //当前角色uid
|
||||
/** 未读消息数(msgCenter/summary 拉取 + WS newMessage 实时累加;Header 角标与 /msg 页左栏同源) */
|
||||
unreadCount: number;
|
||||
// curUsedAppList: Array<any>; //当前角色常用应用
|
||||
}
|
||||
|
||||
@@ -50,12 +52,22 @@ export const useDefaultStore = defineStore("defaultStore", {
|
||||
curUserAppList: [],
|
||||
curUserMenuList: [],
|
||||
curRoleUid: null,
|
||||
unreadCount: 0,
|
||||
};
|
||||
},
|
||||
actions: {
|
||||
setCurRoleInfo(info: RoleInfo) {
|
||||
this.curRoleInfo = info;
|
||||
},
|
||||
setUnreadCount(count: number) {
|
||||
this.unreadCount = Math.max(0, count || 0);
|
||||
},
|
||||
incUnread() {
|
||||
this.unreadCount += 1;
|
||||
},
|
||||
decUnread() {
|
||||
this.unreadCount = Math.max(0, this.unreadCount - 1);
|
||||
},
|
||||
setCurUserSettingsInfo(info: UserSettingInfo) {
|
||||
this.curUserSettingInfo = info;
|
||||
},
|
||||
|
||||
+280
-111
@@ -23,18 +23,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模式二:按应用(平铺全部应用,首项「全部」+ 分隔线) -->
|
||||
<!-- 模式二:按应用(应用清单取 store 应用列表;计数取 summary todos 待办角标) -->
|
||||
<div v-show="mode === 'app'">
|
||||
<div class="side-title">全部应用</div>
|
||||
<div :class="['cat', { active: curApp === 'all' }]" @click="onClickApp('all')">
|
||||
<span class="dot dot-primary"></span>全部<span class="cnt">{{ msgs.length }}</span>
|
||||
<span class="dot dot-primary"></span>全部<span class="cnt">{{ summaryTotalHint }}</span>
|
||||
</div>
|
||||
<div class="side-sep"></div>
|
||||
<div
|
||||
v-for="a in appList"
|
||||
:key="a.name"
|
||||
:class="['cat', { active: curApp === a.name }]"
|
||||
@click="onClickApp(a.name)"
|
||||
:key="a.appKey"
|
||||
:class="['cat', { active: curApp === a.appKey }]"
|
||||
@click="onClickApp(a.appKey)"
|
||||
>
|
||||
<span class="dot dot-primary"></span>{{ a.name }}<span class="cnt">{{ a.cnt }}</span>
|
||||
</div>
|
||||
@@ -46,12 +46,12 @@
|
||||
<div class="msg-toolbar">
|
||||
<div class="tb-left">
|
||||
<h3>{{ curTitle }}</h3>
|
||||
<span class="sub"><i class="live"></i>未读 {{ unreadTotal }} 条</span>
|
||||
<span class="sub"><i class="live"></i>未读 {{ unreadCount }} 条</span>
|
||||
</div>
|
||||
<div class="tb-right">
|
||||
<input v-model="searchText" class="msg-search" placeholder="搜索消息内容…" />
|
||||
<input v-model="searchText" class="msg-search" placeholder="搜索消息内容…" @input="onSearchInput" />
|
||||
<button class="btn plain sm" @click="readAll">全部已读</button>
|
||||
<button class="btn ghost sm" @click="refresh">刷新</button>
|
||||
<button class="btn ghost sm" @click="reload">刷新</button>
|
||||
<div class="skin-switch">
|
||||
<span class="lb">消息样式</span>
|
||||
<div class="seg">
|
||||
@@ -62,82 +62,83 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg-body">
|
||||
<div class="msg-body" ref="refMsgBody" @scroll="onScroll">
|
||||
<div class="msg-list" :data-skin="skin">
|
||||
<div v-if="renderList.length === 0" class="empty">
|
||||
<div v-if="loadError && rows.length === 0" class="empty">
|
||||
<div class="ic">⚠️</div>
|
||||
<div>消息加载失败</div>
|
||||
<button class="btn ghost sm" @click="reload">重试</button>
|
||||
</div>
|
||||
<div v-else-if="rows.length === 0 && !loading" class="empty">
|
||||
<div class="ic">✅</div>
|
||||
<div>暂无消息</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, i) in renderList"
|
||||
:key="i"
|
||||
:class="['msg-card', m.unread ? ['unread', m.t === 2 ? 'm2' : m.t === 3 ? 'm3' : ''] : '']"
|
||||
v-for="m in rows"
|
||||
:key="m.msgId"
|
||||
:class="['msg-card', isUnread(m) ? ['unread', m.msgType === 1 ? 'mbiz' : m.msgType === 2 ? 'mappr' : ''] : '']"
|
||||
@click="onClickCard(m)"
|
||||
>
|
||||
<div class="row1">
|
||||
<div :class="['type-ic', m.t === 2 ? 'biz' : m.t === 3 ? 'appr' : 'sys']" :data-label="typeLabel(m.t)">
|
||||
{{ typeIcon(m.t) }}
|
||||
<div :class="['type-ic', m.msgType === 1 ? 'biz' : m.msgType === 2 ? 'appr' : 'sys']" :data-label="typeLabel(m.msgType)">
|
||||
{{ typeIcon(m.msgType) }}
|
||||
</div>
|
||||
<span class="app">{{ m.app }}</span>
|
||||
<span class="type-tag">{{ typeName(m.t) }}</span>
|
||||
<span class="app">{{ m.appName || typeAppName(m) }}</span>
|
||||
<span class="type-tag">{{ typeName(m.msgType) }}</span>
|
||||
<span class="unread-scope"><span class="unread-dot"></span></span>
|
||||
</div>
|
||||
<div class="row2"><span class="tt">{{ m.tt }}</span>{{ m.body }}</div>
|
||||
<div class="row2"><span class="tt">{{ m.title }}</span>{{ m.content }}</div>
|
||||
<div class="row3">
|
||||
<span class="time">{{ fmtTime(m.min) }}</span>
|
||||
<span class="time">{{ fmtTime(m.sendTime) }}</span>
|
||||
<div class="act">
|
||||
<template v-if="m.unread && m.approve">
|
||||
<button class="btn sm btn-ok" @click="onClickMsg(m)">同意</button>
|
||||
<button class="btn plain sm btn-no" @click="onClickMsg(m)">拒绝</button>
|
||||
<template v-if="isUnread(m) && m.msgType === 2 && m.msgId">
|
||||
<button class="btn sm btn-ok" @click.stop="openApproval(m, 'approve')">同意</button>
|
||||
<button class="btn plain sm btn-no" @click.stop="openApproval(m, 'reject')">拒绝</button>
|
||||
</template>
|
||||
<button class="btn ghost sm" @click="onClickMsg(m)">{{ m.t === 2 ? "去处理" : "查看详情" }}</button>
|
||||
<button v-if="m.showJump && m.jumpUrl" class="btn ghost sm" @click.stop="onJump(m)">去处理</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="loading-more">加载中…</div>
|
||||
<div v-else-if="rows.length > 0 && !hasMore" class="loading-more">没有更多了</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 审批直批弹窗(与 Header 共享组件;成功后卡片置已读并刷新) -->
|
||||
<MsgApprovalModal ref="refApproval" @handled="onApprovalHandled" />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
|
||||
import mittBus from "@/utils/mitt";
|
||||
import { useDefaultStore } from "@/store";
|
||||
|
||||
/**
|
||||
* 消息中心路由页(/msg)。
|
||||
* 本轮数据全部为 mock(照 design/03 原型 APP_MSGS),接口对接(readMsgCenter / msgList /
|
||||
* handleMsg / WS)属独立一轮,见方案文档 §6。
|
||||
* 消息中心路由页(/msg)。数据层对接 msgCenter 真实接口(消息对接 2026-09-30):
|
||||
* 列表服务端筛选 + 滚动分页、左栏计数走 summary、已读/全部已读/直批/去处理接真实动作。
|
||||
* 字段映射:msgType 0系统/1业务/2审批;isRead 1=已读;appName/jumpUrl/showJump 由后端聚合改写。
|
||||
*/
|
||||
|
||||
interface MockMsg {
|
||||
app: string;
|
||||
t: number; // 1 系统通知 / 2 业务提醒 / 3 审批待办
|
||||
tt: string;
|
||||
body: string;
|
||||
min: number; // 距现在的分钟数(mock 时间)
|
||||
unread: boolean;
|
||||
approve?: boolean;
|
||||
interface MsgRow {
|
||||
msgId: number;
|
||||
title: string;
|
||||
content: string;
|
||||
msgType: number; // 0 系统通知 / 1 业务提醒 / 2 审批待办
|
||||
appName?: string | null;
|
||||
appKey?: string | null;
|
||||
sendTime?: string; // yyyy-MM-dd HH:mm:ss
|
||||
isRead: number; // 1=已读
|
||||
jumpUrl?: string | null;
|
||||
showJump?: boolean;
|
||||
}
|
||||
|
||||
/** mock 消息池(与原型 APP_MSGS 一致),刷新时从它重新克隆 */
|
||||
const MOCK_MSGS: MockMsg[] = [
|
||||
{ app: "请假", t: 3, tt: "张老师的调课申请待你审批:", body: "因公出差,申请将周三 3-4 节《数据结构》调整至周五 5-6 节。", min: 3, unread: true, approve: true },
|
||||
{ app: "通知公告", t: 1, tt: "系统维护通知:", body: "平台将于今晚 23:00-24:00 进行版本升级,期间可能出现短暂无法访问。", min: 25, unread: true },
|
||||
{ app: "请假", t: 3, tt: "李老师的病假申请待审批:", body: "申请 9 月 30 日病假一天,请予审批。", min: 40, unread: true, approve: true },
|
||||
{ app: "公文申请", t: 2, tt: "公文待办:", body: "《关于秋季运动会的通知》待你签发。", min: 60, unread: true },
|
||||
{ app: "通知公告", t: 1, tt: "放假安排:", body: "国庆假期 10 月 1 日至 7 日,请各部门提前做好工作交接。", min: 120, unread: true },
|
||||
{ app: "教务管理", t: 2, tt: "成绩录入提醒:", body: "高二(3)班期中考试成绩尚未录入完毕,请于本周五前完成。", min: 180, unread: true },
|
||||
{ app: "公文申请", t: 2, tt: "公文待阅:", body: "《关于加强校园安全管理的通知》请查阅。", min: 240, unread: true },
|
||||
{ app: "用户首页", t: 1, tt: "门户栏目更新:", body: "「校园动态」栏目已上线,可在首页订阅。", min: 300, unread: true },
|
||||
{ app: "报修", t: 2, tt: "报修派单:", body: "教学楼 301 多媒体设备故障,已派单至你处。", min: 360, unread: true },
|
||||
{ app: "流程中心", t: 1, tt: "流程模板变更:", body: "「采购申请」流程新增一级审批,请留意。", min: 420, unread: true },
|
||||
{ app: "学校管理", t: 1, tt: "组织架构调整:", body: "教务处新增「教研组」二级部门。", min: 480, unread: true },
|
||||
{ app: "教师专业发展", t: 2, tt: "培训报名提醒:", body: "市级骨干教师培训报名明日截止。", min: 600, unread: true },
|
||||
{ app: "场地预约", t: 2, tt: "场地冲突提醒:", body: "你预约的报告厅与校务会时间冲突,请调整。", min: 720, unread: true },
|
||||
{ app: "请假", t: 3, tt: "已办结:", body: "王老师的年假申请已通过。", min: 1440, unread: false },
|
||||
{ app: "通知公告", t: 1, tt: "新功能上线:", body: "应用中心已支持按角色分组管理,可在「设置 → 应用管理」中配置。", min: 1500, unread: false },
|
||||
];
|
||||
|
||||
const msgs = ref<MockMsg[]>(MOCK_MSGS.map(m => ({ ...m })));
|
||||
const store = useDefaultStore();
|
||||
/** 未读数与 Header 铃铛 / LeftMenu 角标同源(store) */
|
||||
const { unreadCount } = storeToRefs(store);
|
||||
|
||||
/** 左卡模式:cat 按分类 / app 按应用 */
|
||||
const mode = ref<"cat" | "app">("cat");
|
||||
@@ -147,94 +148,253 @@ const searchText = ref("");
|
||||
/** 消息样式:stripe 彩色条带 / badge 徽标 */
|
||||
const skin = ref<"stripe" | "badge">("stripe");
|
||||
|
||||
const unreadTotal = computed(() => msgs.value.filter(m => m.unread).length);
|
||||
// ===== 列表(服务端筛选 + 滚动分页)=====
|
||||
const rows = ref<MsgRow[]>([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const PAGE_SIZE = 20;
|
||||
const loading = ref(false);
|
||||
const loadError = ref(false);
|
||||
const hasMore = computed(() => rows.value.length < total.value);
|
||||
const refMsgBody = ref<HTMLElement | null>(null);
|
||||
|
||||
/** 分类导航(计数实时随已读状态变化) */
|
||||
/** summary 左栏计数(旧版 UserMsg 无 summary 端点时分类字段缺省 → 显示 —) */
|
||||
const catCounts = ref<{ sysUnread?: number; bizUnread?: number; approvalUnread?: number; total?: number; readCount?: number }>({});
|
||||
const summaryTotalHint = computed(() => (catCounts.value.total == null ? "—" : String(catCounts.value.total)));
|
||||
|
||||
function isUnread(m: MsgRow) {
|
||||
return m.isRead !== 1;
|
||||
}
|
||||
|
||||
/** appName 缺失降级:按 appKey 匹配 store 应用列表,再退类型标签 */
|
||||
function typeAppName(m: MsgRow) {
|
||||
if (m.appKey) {
|
||||
const hit = store.getFlattenAppList.find((x: any) => x.appKey == m.appKey || x.appCode == m.appKey);
|
||||
if (hit && hit.name) return hit.name;
|
||||
}
|
||||
return typeName(m.msgType);
|
||||
}
|
||||
|
||||
/** 分类导航(计数走 summary:分类未读数;「已读」= 已读总数) */
|
||||
const catList = computed(() => [
|
||||
{ key: "all", name: "全部消息", color: "#5979ea", cnt: msgs.value.length },
|
||||
{ key: "sys", name: "系统通知", color: "#5979ea", cnt: msgs.value.filter(m => m.t === 1).length },
|
||||
{ key: "biz", name: "业务提醒", color: "#e9b954", cnt: msgs.value.filter(m => m.t === 2).length },
|
||||
{ key: "appr", name: "审批待办", color: "#e8684a", cnt: msgs.value.filter(m => m.t === 3).length },
|
||||
{ key: "read", name: "已读", color: "#c0c6ce", cnt: msgs.value.filter(m => !m.unread).length },
|
||||
{ key: "all", name: "全部消息", color: "#5979ea", cnt: catCounts.value.total == null ? "—" : catCounts.value.total },
|
||||
{ key: "sys", name: "系统通知", color: "#5979ea", cnt: catCounts.value.sysUnread == null ? "—" : catCounts.value.sysUnread },
|
||||
{ key: "biz", name: "业务提醒", color: "#e9b954", cnt: catCounts.value.bizUnread == null ? "—" : catCounts.value.bizUnread },
|
||||
{ key: "appr", name: "审批待办", color: "#e8684a", cnt: catCounts.value.approvalUnread == null ? "—" : catCounts.value.approvalUnread },
|
||||
{ key: "read", name: "已读", color: "#c0c6ce", cnt: catCounts.value.readCount == null ? "—" : catCounts.value.readCount },
|
||||
]);
|
||||
|
||||
/** 应用导航:按时间倒序首次出现的顺序平铺 */
|
||||
const appList = computed(() => {
|
||||
const sorted = [...msgs.value].sort((a, b) => a.min - b.min);
|
||||
const names: string[] = [];
|
||||
sorted.forEach(m => {
|
||||
if (!names.includes(m.app)) names.push(m.app);
|
||||
});
|
||||
return names.map(name => ({ name, cnt: msgs.value.filter(m => m.app === name).length }));
|
||||
});
|
||||
|
||||
/** 右卡列表:模式内过滤 + 搜索,统一按时间倒序(已读消息保留,供回查) */
|
||||
const renderList = computed(() => {
|
||||
let rows = [...msgs.value];
|
||||
if (mode.value === "cat") {
|
||||
if (curCat.value === "read") rows = rows.filter(m => !m.unread);
|
||||
else if (curCat.value !== "all") rows = rows.filter(m => m.t === Number(curCat.value === "sys" ? 1 : curCat.value === "biz" ? 2 : 3));
|
||||
} else {
|
||||
if (curApp.value !== "all") rows = rows.filter(m => m.app === curApp.value);
|
||||
}
|
||||
const kw = searchText.value.trim();
|
||||
if (kw) rows = rows.filter(m => (m.app + m.tt + m.body).includes(kw));
|
||||
return rows.sort((a, b) => a.min - b.min);
|
||||
});
|
||||
/** 应用导航:store 应用列表平铺;计数取 summary todos 待办角标(同源数据不新拉接口) */
|
||||
const todoCounts = ref<Record<string, number>>({});
|
||||
const appList = computed(() =>
|
||||
store.getFlattenAppList
|
||||
.filter((x: any) => !!x.appKey || !!x.appCode)
|
||||
.map((x: any) => ({ appKey: String(x.appKey ?? x.appCode), name: x.name, cnt: todoCounts.value[String(x.appKey ?? x.appCode)] || 0 }))
|
||||
);
|
||||
|
||||
/** 当前视图的列表标题 */
|
||||
const curTitle = computed(() => {
|
||||
if (mode.value === "cat") {
|
||||
const hit = catList.value.find(c => c.key === curCat.value);
|
||||
return hit ? hit.name : "全部消息";
|
||||
}
|
||||
return curApp.value === "all" ? "全部应用消息" : curApp.value;
|
||||
if (curApp.value === "all") return "全部应用消息";
|
||||
const hit = appList.value.find(a => a.appKey === curApp.value);
|
||||
return hit ? hit.name : "全部应用消息";
|
||||
});
|
||||
|
||||
/** 当前筛选条件 → 服务端参数(筛选语义归服务端,本地不过滤) */
|
||||
function currentParams(extra: { page: number }) {
|
||||
const params: { page: number; count: number; msgType?: number; appKey?: string; isRead?: number; keyword?: string } = {
|
||||
page: extra.page,
|
||||
count: PAGE_SIZE,
|
||||
};
|
||||
if (mode.value === "cat") {
|
||||
if (curCat.value === "sys") params.msgType = 0;
|
||||
else if (curCat.value === "biz") params.msgType = 1;
|
||||
else if (curCat.value === "appr") params.msgType = 2;
|
||||
else if (curCat.value === "read") params.isRead = 1;
|
||||
} else if (curApp.value !== "all") {
|
||||
params.appKey = curApp.value;
|
||||
}
|
||||
const kw = searchText.value.trim();
|
||||
if (kw) params.keyword = kw;
|
||||
return params;
|
||||
}
|
||||
|
||||
/** 拉列表:reset=true 重置第 1 页,false 追加下一页 */
|
||||
async function loadList(reset: boolean) {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
if (reset) {
|
||||
page.value = 1;
|
||||
loadError.value = false;
|
||||
}
|
||||
try {
|
||||
const result: any = await BaseApi.getMsgCenterList(currentParams({ page: page.value }));
|
||||
const list: MsgRow[] = (result && result.list) || [];
|
||||
total.value = (result && result.total) || 0;
|
||||
rows.value = reset ? list : rows.value.concat(list);
|
||||
if (reset && list.length === 0 && total.value > 0) {
|
||||
// 服务端筛选参数被旧版 UserMsg 忽略时 total 与 list 不匹配的兜底展示
|
||||
total.value = list.length;
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动到底自动加载下一页 */
|
||||
function onScroll() {
|
||||
const el = refMsgBody.value;
|
||||
if (!el || loading.value || !hasMore.value) return;
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) {
|
||||
page.value += 1;
|
||||
loadList(false);
|
||||
}
|
||||
}
|
||||
|
||||
/** 左栏计数一次拉齐(未读总数进 store 与铃铛同源) */
|
||||
async function loadSummary() {
|
||||
try {
|
||||
const s = await BaseApi.getMsgCenterSummary();
|
||||
if (!s) return;
|
||||
store.setUnreadCount(s.unreadCount);
|
||||
catCounts.value = {
|
||||
sysUnread: (s as any).sysUnread,
|
||||
bizUnread: (s as any).bizUnread,
|
||||
approvalUnread: (s as any).approvalUnread,
|
||||
total: (s as any).total,
|
||||
readCount: (s as any).readCount,
|
||||
};
|
||||
const map: Record<string, number> = {};
|
||||
(s.todos || []).forEach(t => (map[t.appKey] = t.count));
|
||||
todoCounts.value = map;
|
||||
} catch (e) {
|
||||
/* 摘要失败不打扰列表 */
|
||||
}
|
||||
}
|
||||
|
||||
function switchMode(m: "cat" | "app") {
|
||||
if (mode.value === m) return;
|
||||
mode.value = m;
|
||||
loadList(true);
|
||||
}
|
||||
|
||||
function onClickCat(key: string) {
|
||||
if (curCat.value === key) return;
|
||||
curCat.value = key;
|
||||
loadList(true);
|
||||
}
|
||||
function onClickApp(key: string) {
|
||||
if (curApp.value === key) return;
|
||||
curApp.value = key;
|
||||
loadList(true);
|
||||
}
|
||||
|
||||
/** mock:点击「同意/拒绝/查看详情」视为已读(真实 readMsgCenter 对接属下一轮) */
|
||||
function onClickMsg(m: MockMsg) {
|
||||
m.unread = false;
|
||||
/** 刷新:重置第 1 页 + 摘要 */
|
||||
async function reload() {
|
||||
await Promise.all([loadList(true), loadSummary()]);
|
||||
}
|
||||
/** mock:全部已读 */
|
||||
function readAll() {
|
||||
msgs.value.forEach(m => (m.unread = false));
|
||||
|
||||
/** 搜索防抖(300ms) */
|
||||
let searchTimer: any = null;
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => loadList(true), 300);
|
||||
}
|
||||
/** mock:刷新 = 恢复初始数据 */
|
||||
function refresh() {
|
||||
msgs.value = MOCK_MSGS.map(m => ({ ...m }));
|
||||
|
||||
/** 点击卡片:未读置已读(角标同步减);消息详情弹窗本轮不做 */
|
||||
async function onClickCard(m: MsgRow) {
|
||||
if (isUnread(m) && m.msgId) {
|
||||
try {
|
||||
await BaseApi.readMsgCenter(m.msgId);
|
||||
m.isRead = 1;
|
||||
store.decUnread();
|
||||
loadSummary();
|
||||
} catch (e) {
|
||||
/* 已读失败静默,不打断浏览 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 去处理:未读先标读,新窗口打开落地页 */
|
||||
async function onJump(m: MsgRow) {
|
||||
if (isUnread(m) && m.msgId) {
|
||||
BaseApi.readMsgCenter(m.msgId).catch(() => undefined);
|
||||
m.isRead = 1;
|
||||
store.decUnread();
|
||||
loadSummary();
|
||||
}
|
||||
if (m.jumpUrl) window.open(m.jumpUrl, "_blank");
|
||||
}
|
||||
|
||||
/** 全部已读:列表全部置灰 + 角标清零 + 计数刷新 */
|
||||
async function readAll() {
|
||||
try {
|
||||
await BaseApi.readMsgCenterAll();
|
||||
rows.value.forEach(m => (m.isRead = 1));
|
||||
store.setUnreadCount(0);
|
||||
loadSummary();
|
||||
} catch (e) {
|
||||
/* 失败保留现状 */
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 审批直批(共享弹窗 MsgApprovalModal)=====
|
||||
const refApproval = ref<any>();
|
||||
function openApproval(m: MsgRow, action: "approve" | "reject") {
|
||||
refApproval.value?.open({ msgId: m.msgId, title: m.title }, action);
|
||||
}
|
||||
/** 直批成功:该消息置已读(后端视角待办已消化)并刷新列表与计数 */
|
||||
function onApprovalHandled(msgId: number) {
|
||||
const row = rows.value.find(m => m.msgId === msgId);
|
||||
if (row && isUnread(row)) {
|
||||
row.isRead = 1;
|
||||
store.decUnread();
|
||||
}
|
||||
loadList(true);
|
||||
loadSummary();
|
||||
}
|
||||
|
||||
// ===== WS 联动:新消息到达(Header 广播)→ 刷新第一页与计数 =====
|
||||
function onWsNewMessage() {
|
||||
loadList(true);
|
||||
loadSummary();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList(true);
|
||||
loadSummary();
|
||||
mittBus.on("msgCenter:newMessage", onWsNewMessage);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
mittBus.off("msgCenter:newMessage", onWsNewMessage);
|
||||
clearTimeout(searchTimer);
|
||||
});
|
||||
|
||||
function typeName(t: number) {
|
||||
return t === 3 ? "审批待办" : t === 2 ? "业务提醒" : "系统通知";
|
||||
return t === 2 ? "审批待办" : t === 1 ? "业务提醒" : "系统通知";
|
||||
}
|
||||
function typeLabel(t: number) {
|
||||
return t === 3 ? "审批" : t === 2 ? "提醒" : "系统";
|
||||
return t === 2 ? "审批" : t === 1 ? "提醒" : "系统";
|
||||
}
|
||||
function typeIcon(t: number) {
|
||||
return t === 3 ? "⏳" : t === 2 ? "📋" : "🔔";
|
||||
return t === 2 ? "⏳" : t === 1 ? "📋" : "🔔";
|
||||
}
|
||||
/** 时间格式:刚刚 / N 分钟前 / N 小时前 / 昨天 HH:mm / MM-DD */
|
||||
function fmtTime(min: number) {
|
||||
if (min < 1) return "刚刚";
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
if (min < 1440) return `${Math.floor(min / 60)} 小时前`;
|
||||
if (min < 2880) {
|
||||
const hm = new Date(Date.now() - min * 60000);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `昨天 ${pad(hm.getHours())}:${pad(hm.getMinutes())}`;
|
||||
}
|
||||
const d = new Date(Date.now() - min * 60000);
|
||||
/** 时间格式:刚刚 / N 分钟前 / N 小时前 / 昨天 HH:mm / MM-DD(sendTime 绝对时间 yyyy-MM-dd HH:mm:ss) */
|
||||
function fmtTime(sendTime?: string) {
|
||||
if (!sendTime) return "";
|
||||
const d = new Date(String(sendTime).replace(" ", "T"));
|
||||
if (isNaN(d.getTime())) return sendTime;
|
||||
const diffMin = Math.floor((Date.now() - d.getTime()) / 60000);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
if (diffMin < 1) return "刚刚";
|
||||
if (diffMin < 60) return `${diffMin} 分钟前`;
|
||||
if (diffMin < 1440) return `${Math.floor(diffMin / 60)} 小时前`;
|
||||
if (diffMin < 2880) return `昨天 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
</script>
|
||||
@@ -424,6 +584,7 @@ function fmtTime(min: number) {
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-color: #dfe3ea;
|
||||
@@ -442,10 +603,10 @@ function fmtTime(min: number) {
|
||||
&.unread::before {
|
||||
background: #5979ea;
|
||||
}
|
||||
&.unread.m2::before {
|
||||
&.unread.mbiz::before {
|
||||
background: #e9b954;
|
||||
}
|
||||
&.unread.m3::before {
|
||||
&.unread.mappr::before {
|
||||
background: #e8684a;
|
||||
}
|
||||
.row1 {
|
||||
@@ -672,4 +833,12 @@ function fmtTime(min: number) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动分页脚标(加载中 / 没有更多) */
|
||||
.loading-more {
|
||||
text-align: center;
|
||||
padding: 10px 0 14px;
|
||||
font-size: 12px;
|
||||
color: #b3b3b3;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user