Files
UserhomeFront/src/components/Header.vue
T
chenxing 0f7101983e 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 段
2026-09-30 12:37:30 +08:00

608 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<header :class="headerClass">
<img v-if="!!logoUrl" :src="baseUrl + '/image/default-logo.png'" v-real-img="logoUrl" class="logo-box" alt="" />
<ul class="operate-container fix">
<!-- <li>
<Button @click="settingsMenu">test</Button>
</li> -->
<li v-if="isWidgetIconShow" :class="{ active: isWidgetShow }">
<div class="icon-box" v-wave @click="toggleWidgetShow">
<SvgIcon name="header-4" color="#333333" />
</div>
</li>
<li v-if="isSearchIconShow">
<Transition name="fade1" :duration="{ enter: 1000, leave: 0 }">
<div v-show="!isSearching" @click="isSearching = true" class="icon-box" v-wave>
<SvgIcon name="header-1" color="#333333" />
</div>
</Transition>
<Transition name="search" :duration="{ enter: 300, leave: 300 }">
<div v-show="isSearching" class="search-box">
<input ref="refSearchInput" v-model="searchAppText" type="text" placeholder="请输入应用名称搜索" />
<SvgIcon @click="isSearching = false" class="icon" name="header-1" size="20" color="#c4c4c4" />
</div>
</Transition>
</li>
<!-- 铃铛直跳 /msg(消息对接 2026-09-30:移除 Poptip 浮层与 900 宽列表 Modal,
未读数上提 store 与 /msg 页左栏同源;审批直批弹窗抽至 MsgApprovalModal) -->
<li>
<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"
placement="bottom-end" v-model="isRoleInfoShow" :transfer="true">
<div class="icon-box" v-wave>
<SvgIcon name="header-3" color="#333333" />
</div>
<template #content>
<h3 class="title">
<SvgIcon name="switch-role" color="#272829" />
<span class="ml-8">切换身份</span>
</h3>
<vuescroll>
<ul class="roles-ul">
<template v-for="(item, index) in userRole">
<li :class="{ acitve: getIsCheckRoles(item) }" @click="changeRole(item)">
<SvgIcon v-if="getIsCheckRoles(item)" color="#45B972" name="role-checked" />
<SvgIcon v-else name="role-check" color="#dcdee0" />
<div class="user-wrap">
<div class="user-box fix">
<span v-if="item.roleId != 3" class="fl f16 mr-10">
{{ store.userInfo.name }}
</span>
<span v-else class="fl f16 mr-10">
{{ item.childName }}
</span>
<span v-if="!!item.roleName" class="role-info">
{{ item.roleName }}
</span>
</div>
<div class="schname-box">
{{ item.schName }}
</div>
</div>
</li>
</template>
</ul>
</vuescroll>
</template>
</Poptip>
</li>
<li>
<Poptip class="fl" popper-class="reset-panel" :width="220" trigger="click" placement="bottom-end"
v-model="isUserInfoShow" :transfer="true">
<div class="icon-box" v-wave>
<SvgIcon name="header-5" color="#333333" />
</div>
<template #content>
<ul class="userinfo-ul">
<!-- <li class="header border" @click="onUserinfoClickRoles">
<span class="name">{{ store.userInfo?.name }}</span>
<span v-if="!!store.curRoleInfo?.roleName" class="role-info mr-10">
{{ store.curRoleInfo?.roleName }}
</span>
<Icon :size="18" color="#333" type="ios-arrow-forward" />
</li> -->
<li class="border" @click="showUserInfo">
<img :src="requireImg('userinfo/icon1.png')" alt="" />
<span>个人信息</span>
</li>
<li v-if="!!store.getIsAdmin" @click="linkSysSetting">
<img :src="requireImg('userinfo/icon5.png')" alt="" />
<span>系统设置</span>
</li>
<li v-if="!!store.getIsAdmin" @click="linkBackendManage">
<SvgIcon name="admin-backend" size="20" color="#323233" class="backend-manage-icon" />
<span>后台管理</span>
</li>
<li v-if="!!store.getIsAdmin" @click="linkLogoSetting">
<img :src="requireImg('userinfo/icon3.png')" alt="" />
<span>设置LOGO</span>
</li>
<li @click="settingsMenu" v-if="!!store.getIsAdmin">
<img :src="requireImg('userinfo/icon6.png')" alt="" />
<span>边栏设置</span>
</li>
<li @click="settingsStats" v-if="!!store.getIsAdmin">
<img :src="requireImg('userinfo/icon7.png')" alt="" />
<span>统计设置</span>
</li>
<!--<li @click="settingsTeacherCopilot" v-if="!!store.getIsAdmin">
<img :src="requireImg('userinfo/icon8.png')" alt="" />
<span>教师助手设置</span>
</li>-->
<li @click="settingsWallpaper" class="border">
<img :src="requireImg('userinfo/icon2.png')" alt="" />
<span>设置壁纸</span>
</li>
<li @click="logout">
<img :src="requireImg('userinfo/icon4.png')" alt="" />
<span>退出登陆</span>
</li>
</ul>
</template>
</Poptip>
</li>
</ul>
</header>
<Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸"
:transfer="false">
<vuescroll>
<div class="wallpaper-wrap">
<ul class="wallpaper-ul">
<template v-for="(item, index) in wallpaperList">
<li :class="(index + 1) % 6 == 0 ? '' : 'mr-16'" @click="onClickWallpaper(item.url)">
<div class="check-box">
<SvgIcon size="16" v-if="getIsWallpaper(item.url)" color="#45B972"
name="role-checked" />
<SvgIcon size="16" v-else name="role-check" color="#dcdee0" />
</div>
<label v-if="item.type == 0">系统</label>
<label v-if="item.type == 1">校</label>
<ContextMenu v-if="item.isEdit" @select="onWallpaperSelect" :menu="[
{
label: '删除',
id: item.id,
},
]">
<img :src="item.url" alt="" />
</ContextMenu>
<template v-else>
<img :src="item.url" alt="" />
</template>
<div class="mask-box">{{ index + 1 }}</div>
</li>
</template>
<li>
<Upload class="add-wrap tc" :on-success="wallpaperSuccess" :show-upload-list="false"
:action="$conf.serviceHost + '/api/wallpaperUpload'"
accept="image/jpeg, image/jpg, image/png" :with-credentials="true"
:headers="uploadHeaders">
<div class="add-box">
<SvgIcon name="upload-add" color="#939393" style="width: 24px; height: 24px" />
<p>点击上传图片</p>
</div>
</Upload>
</li>
</ul>
</div>
</vuescroll>
<template #footer>
<Button type="primary" ghost @click="isWallpaperShow = false">关闭</Button>
</template>
</Modal>
<Modal v-model="isLogoShow" class-name="logo-modal" width="720" :mask="false" title="设置LOGO" :transfer="false">
<vuescroll>
<div class="wallpaper-wrap">
<ul class="wallpaper-ul">
<template v-for="(item, index) in logoList">
<li :class="(index + 1) % 4 == 0 ? '' : 'mr-16'">
<label v-if="item.type == 0">系统</label>
<label v-else>校</label>
<ContextMenu v-if="item.isEdit" @select="onLogoSelect" :menu="[
{
label: '删除',
id: item.id,
},
]">
<img :src="item.url" alt="" />
</ContextMenu>
<template v-else>
<img :src="item.url" alt="" />
</template>
</li>
</template>
<li>
<Upload class="add-wrap tc" :on-success="logoSuccess" :show-upload-list="false"
:action="$conf.serviceHost + '/api/logoUpload'"
accept="image/jpeg, image/jpg, image/png, image/gif, image/webp"
:with-credentials="true" :headers="uploadHeaders">
<div class="add-box">
<SvgIcon name="upload-add" color="#939393" style="width: 24px; height: 24px" />
<p>点击上传图片</p>
</div>
</Upload>
</li>
</ul>
</div>
</vuescroll>
<template #footer>
<Button type="primary" ghost @click="isLogoShow = false">关闭</Button>
</template>
</Modal>
<!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
<MsgApprovalModal ref="refMsgApproval" />
<MenuBarModal ref="refMenuBarModal" />
<StatsBarModal ref="refStatsBarModal" />
<TeacherCopilotModal ref="refTeacherCopilotModal" />
</template>
<script lang="ts" setup>
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
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";
import MenuBarModal from "@/components/MenuBarModal.vue";
import StatsBarModal from "@/components/StatsBarModal.vue";
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
import BaseApi from "@/api/BaseApi";
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
import { Notice } from "view-ui-plus";
import { IframePageParmas, RoleInfo } from "@/types";
import { isEqual, orderBy } from "lodash";
import UserSettingApi from "@/api/UserSettingApi";
import mittBus from "@/utils/mitt";
import MenuBarApi from "@/api/MenuBarApi";
import Utils from "@/utils";
const route = useRoute();
const router = useRouter();
const store = useDefaultStore();
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>();
const refTeacherCopilotModal = ref<any>();
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
const logoUrl = computed(() => {
const schLogo = logoList.value.find(x => x.type == 1);
if (schLogo) return schLogo.url;
const sysLogo = logoList.value.find(x => x.type == 0);
return sysLogo ? sysLogo.url : "";
});
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[]>([]);
// ===== 消息中心 WS 实时推送(UserMsg /ws/msg,一次性票据握手;断线 5s 重连换新票) =====
let msgWs: WebSocket | null = null;
let msgWsPingTimer: any = null;
let msgWsReconnectTimer: any = null;
let msgWsClosed = false;
async function loadSummary() {
try {
const summary = await BaseApi.getMsgCenterSummary();
store.setUnreadCount(summary && summary.unreadCount);
} catch (e) {
/* 摘要失败不打扰页面 */
}
}
async function connectMsgWs() {
if (msgWsClosed) return;
let wsUrl: string | null = null;
try {
const res: any = await BaseApi.getMsgCenterWsTicket();
wsUrl = res && res.url;
} catch (e) {
/* 未登录/不可达:走兜底重连 */
}
// 票据不可得(UserMsg 重启中/未配置)也要保持重连节奏:服务恢复后自动续上
if (!wsUrl) {
if (!msgWsClosed) {
clearTimeout(msgWsReconnectTimer);
msgWsReconnectTimer = setTimeout(connectMsgWs, 5000);
}
return;
}
const ws = new WebSocket(wsUrl);
msgWs = ws;
ws.onopen = () => {
// 25s 心跳保活(服务端 90s 空闲回收)
msgWsPingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send('{"type":"ping"}');
}, 25000);
};
ws.onmessage = ev => {
try {
const frame = JSON.parse(ev.data);
if (frame.type === "newMessage") onNewMessage(frame);
} catch (e) {
/* 非 JSON 帧忽略 */
}
};
ws.onclose = () => {
clearInterval(msgWsPingTimer);
msgWsPingTimer = null;
if (msgWs === ws) msgWs = null;
// 票据一次性:重连必换新票;退避上限 30s 防服务端长时间不可用时空转
if (!msgWsClosed) {
clearTimeout(msgWsReconnectTimer);
msgWsReconnectTimer = setTimeout(connectMsgWs, 5000);
}
};
}
/** 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) {
store.incUnread();
// /msg 页开着时自刷新(页内监听该事件重拉第一页与摘要)
mittBus.emit("msgCenter:newMessage", frame);
Notice.info({
title: `【${frameAppName(frame)}】新消息`,
desc: frame.content || "",
duration: 5,
});
}
function closeMsgWs() {
msgWsClosed = true;
clearTimeout(msgWsReconnectTimer);
msgWsReconnectTimer = null;
clearInterval(msgWsPingTimer);
msgWsPingTimer = null;
if (msgWs) {
try {
msgWs.close();
} catch (e) {
/* 已断开 */
}
msgWs = null;
}
}
const headerClass = computed(() => {
return { "default-header": true, "base-mask": route.name !== "index" };
});
const isWidgetIconShow = computed(() => {
return route.name === "index";
});
const isSearchIconShow = computed(() => {
return route.name === "apps";
});
const isWidgetShow = computed(() => {
return store.getWidgetDragObj.isWidgetShow;
});
const userRole = computed(() => store.userRoleList);
const toggleSearch: any = inject("toggleSearch");
onMounted(async () => {
setTimeout(() => {
loadSummary();
connectMsgWs();
// isWallpaperShow.value = true;
}, 200);
loadLogoList();
mittBus.on("searchReset", () => {
isSearching.value = false;
searchAppText.value = null;
});
});
onUnmounted(() => {
mittBus.off("searchReset");
closeMsgWs();
});
watch(isSearching, val => {
if (val) {
setTimeout(() => refSearchInput.value?.focus(), 300);
} else {
// searchAppText.value = null;
}
});
watch(searchAppText, val => {
mittBus.emit("searchChange", val);
});
function getIsCheckRoles(item: RoleInfo) {
return isEqual(item, store.curRoleInfo);
}
async function changeRole(role: RoleInfo) {
if (!!getIsCheckRoles(role)) return;
setTimeout(() => {
isRoleInfoShow.value = false;
}, 150);
store.setCurRoleInfo(role);
await BaseApi.savUserRole(role);
mittBus.emit("switchRole", role);
await getUserWallpaperList();
await loadLogoList();
}
function onUserinfoClickRoles() {
isUserInfoShow.value = false;
setTimeout(() => {
isRoleInfoShow.value = true;
}, 0);
}
/** 消息类型名(UserMsg msgType:0系统通知/1业务提醒/2审批待办);WS 帧缺 appName 时的降级标签 */
function msgTypeLabel(msgType: number | null) {
return { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[msgType as 0 | 1 | 2] || "系统通知";
}
async function wallpaperSuccess(response: any, file: any, fileListt: any) {
await getUserWallpaperList();
}
async function getUserWallpaperList() {
const list = await UserSettingApi.getUserWallpaperList(store.getCurSchId);
// list.forEach(element => {
// if (element.type == 1) element.url = $conf.serviceHost + element.url;
// });
wallpaperList.value = orderBy(list, ["type", "id"], ["asc", "asc"]);
}
async function settingsWallpaper() {
await getUserWallpaperList();
isUserInfoShow.value = false;
isWallpaperShow.value = true;
}
async function settingsMenu() {
isUserInfoShow.value = false;
refMenuBarModal.value.show();
}
async function settingsStats() {
isUserInfoShow.value = false;
refStatsBarModal.value.show();
}
async function settingsTeacherCopilot() {
isUserInfoShow.value = false;
refTeacherCopilotModal.value.show();
}
async function onClickWallpaper(url: string) {
await UserSettingApi.checkWallpaper(store.getCurSchId, url);
const userSetting = store.curUserSettingInfo;
if (!!userSetting) {
userSetting.wallpaperUrl = url;
store.setCurUserSettingsInfo(userSetting);
}
isWallpaperShow.value = false;
}
async function onWallpaperSelect(item: any) {
await UserSettingApi.deleteWallById(item.id);
await getUserWallpaperList();
}
async function loadLogoList() {
const list = await UserSettingApi.getLogoList();
logoList.value = list || [];
}
async function logoSuccess(response: any, file: any, fileListt: any) {
await loadLogoList();
}
async function onLogoSelect(item: any) {
await UserSettingApi.deleteLogoById(item.id);
await loadLogoList();
}
function getIsWallpaper(url: string) {
return url === store.getCurWallPaperUrl;
}
async function toggleWidgetShow() {
const id = store.curUserSettingInfo?.id;
if (!!store.curUserSettingInfo) store.curUserSettingInfo.isWidgetShow = !store.getWidgetDragObj.isWidgetShow;
await UserSettingApi.toggleWidgetShow(id);
}
function linkSysSetting() {
isUserInfoShow.value = false;
router.push("/syssetting");
}
function linkBackendManage() {
isUserInfoShow.value = false;
const url = `${$conf.baseHost}/userbase/front/index.html`;
window.open(url, "_blank");
}
async function linkLogoSetting() {
await loadLogoList();
isUserInfoShow.value = false;
isLogoShow.value = true;
}
function showSeachBox() { }
function showUserInfo() {
isUserInfoShow.value = false;
mittBus.emit("showUserInfoModal");
}
function logout() {
let url = window.location.href;
url = "/logout?service=" + url;
url = $conf.ssoHost + url;
if (!!window.top) window.top.location.href = url;
else window.location.href = url;
}
</script>
<style lang="scss" scoped>
@import url("@/assets/styles/header.scss");
</style>
<style lang="scss">
.reset-panel .ivu-poptip-body {
padding: 0;
}
.reset-panel .ivu-poptip-inner {
border-radius: 10px;
overflow: hidden;
box-shadow: 0 10px 20px 0 rgba(0, 0, 0, 0.06);
}
.wallpaper-modal {
z-index: 11 !important;
}
.wallpaper-modal .ivu-modal-body {
padding: 0;
}
.logo-modal {
z-index: 11 !important;
}
.logo-modal .ivu-modal-body {
padding: 0;
}
.ivu-table-cell .operating-link {
display: inline-block;
color: #2d8cf0;
cursor: pointer;
padding-right: 10px;
&.disabled {
color: #aaa !important;
cursor: not-allowed;
}
}
.ivu-table:before {
content: none !important;
}
</style>