Files
UserhomeFront/src/components/Header.vue
T
chenxing 2bd7ef3b80
build / build (push) Successful in 1m21s
refactor(header): 命令面板去背景联动,结果右侧标注应用类型
用户三点修订:
1. 面板已是模态框,去掉背景应用列表的同步过滤(解开 mittBus.searchChange 整条链)
2. 结果右侧灰色小字显示应用类型(来源 BoaAppType.name,与 /apps 分组标题同源)
3. 类型呈现暂按方案 A(右侧灰字);方案 B(类型分组)对照稿见 design/07

- Header.vue: watch(searchAppText) 不再 emit("searchChange");cmdkResult 由
  getFlattenAppList(纯 flatten,会丢类型名)改为遍历 curUserAppList 分组展开并挂
  appTypeName;模板加 .app-cmdk-type
- AppList.vue: 删 searchAppText / searchAppList / isSearch 与模板搜索态分支、
  .no-search-box 样式、孤儿导入 flatten(lodash) 与 requireImg;删 mittBus 监听
- utils/mitt.ts: 删 searchChange 事件类型
- assets/styles/header.scss: 加 .app-cmdk-type 样式
- harness-main.ts: mock curUserAppList 补齐 BoaAppType 分组结构(id + name),
  原 mock 丢了 name 导致类型名取不到

注:本仓工作区另有并行会话 WIP(StatsBarModal 迁入 Settings / 主题预览),
本提交只含本案 5 个文件与 hunks,未触碰对方改动。
2026-10-08 19:10:14 +08:00

672 lines
26 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 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">
<div class="icon-box" v-wave @click="openSearch">
<SvgIcon name="header-1" color="#333333" />
</div>
</li>
<!-- 人像图标 =唯一入口(2026-10-08整合):铃铛与齿轮两枚图标下线,
消息中心仍走左侧菜单 /msg(LeftMenu 保留未读角标);
原齿轮菜单的 个人信息/后台管理/退出登陆 合并进本卡片,
其余 sys/logo/sidebar/statset 五项 2026-09-30 已迁/settings,不再重复入口 -->
<li>
<Poptip class="fl" popper-class="user-card-wrap reset-panel" :width="260" trigger="click"
placement="bottom-end" v-model="isUserCardShow" :transfer="true">
<div class="icon-box" v-wave title="切换身份">
<SvgIcon name="header-3" color="#333333" />
</div>
<template #content>
<div class="user-card">
<div class="user-card-hd">
<SvgIcon name="switch-role" color="#272829" />
<span class="ttl">切换身份</span>
<span class="close" @click="isUserCardShow = false">✕</span>
</div>
<!-- 身份列表有最大高度,超出显示滚动条。
key 用 roleId+schId+childId 复合:同校多身份时 roleId 会重复 -->
<div class="user-card-roles">
<div v-for="item in userRole"
:key="`${item.roleId}-${item.schId}-${item.childId}`"
:class="['u-role', { active: 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="who">
<div class="nm">
<span class="t">{{ item.roleId != 3 ? store.userInfo.name : item.childName }}</span>
<span v-if="!!item.roleName" class="role-info">{{ item.roleName }}</span>
</div>
<div class="sch">{{ item.schName }}</div>
</div>
</div>
</div>
<ul class="userinfo-ul user-card-menu">
<li @click="showUserInfo">
<img :src="requireImg('userinfo/icon1.png')" alt="" />
<span>个人信息</span>
<svg class="arw" viewBox="0 0 16 16" fill="none">
<path
d="M6 3.5L10.5 8L6 12.5" stroke="#b6bbc3" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</li>
<li v-if="!!store.getIsAdmin" @click="linkBackendManage">
<SvgIcon name="admin-backend" size="20" color="#323233" class="backend-manage-icon" />
<span>后台管理</span>
<svg class="arw" viewBox="0 0 16 16" fill="none">
<path
d="M6 3.5L10.5 8L6 12.5" stroke="#b6bbc3" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</li>
</ul>
<div class="user-card-logout">
<button class="logout-btn" @click="logout">退出登录</button>
</div>
</div>
</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" />
<StatsBarModal ref="refStatsBarModal" />
<TeacherCopilotModal ref="refTeacherCopilotModal" />
<!-- 搜索命令面板(2026-10-08 定稿 B 方案):必须 Teleport 到 body ——
.default-header 有 position:relative + z-index:10,会创建层叠上下文,
面板写在 Header 内部时无论 z-index 多高,对外只表现为 10,会被左侧菜单与内容区盖住 -->
<Teleport to="body">
<div class="app-cmdk-layer" :class="{ 'is-open': isSearching }">
<div class="app-cmdk-mask" @click="closeSearch"></div>
<div class="app-cmdk-panel">
<div class="app-cmdk-hd">
<SvgIcon name="header-1" size="19" color="#98a2b0" />
<input
ref="refSearchInput"
v-model="searchAppText"
type="text"
placeholder="输入应用名以搜索"
autocomplete="off"
/>
<span class="app-cmdk-kbd">Esc</span>
</div>
<div class="app-cmdk-list" :class="{ 'is-instant': !cmdkStagger }" ref="refCmdkList">
<div
v-for="(item, i) in cmdkResult"
:key="item.id ?? i"
class="app-cmdk-item"
:class="{ 'is-hl': i === cmdkIndex }"
:style="{ '--i': i }"
@click="pickApp(item)"
>
<AppIcon
v-if="vectorIconOf(item)"
:name="vectorIconOf(item)"
scene="tile"
:tint="item.iconColor"
/>
<img v-else :src="$utils.appIconPath(item.icon)" class="app-cmdk-img" alt="" />
<span class="app-cmdk-name">{{ item.name }}</span>
<span class="app-cmdk-type" v-if="item.appTypeName">{{ item.appTypeName }}</span>
</div>
</div>
<div class="app-cmdk-empty" :class="{ 'is-show': !cmdkResult.length }">
{{ store.appListLoading ? "正在加载应用…" : "未找到匹配的应用" }}
</div>
<div class="app-cmdk-ft">
<span>↑↓ 选择</span><span>↵ 打开</span><span>Esc 关闭</span>
</div>
</div>
</div>
</Teleport>
</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, nextTick } from "vue";
import { useDefaultStore } from "@/store";
import { useRoute, useRouter } from "vue-router";
import { requireImg } from "@/utils/hook/useImg";
import ContextMenu from "@/components/ContextMenu.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";
import AppIcon from "@/components/AppIcon.vue";
import { appiconKeyOf } from "@/constants/appIconRules";
import { openAppPage } from "@/utils/bschLinkPage";
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 铃铛角标已于 2026-10-08 下线,
* 但摘要拉取与 WS 推送仍需保留 —— /msg 页左栏与 LeftMenu 角标与本store 同源 */
const refSearchInput = 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 isUserCardShow = 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;
});
window.addEventListener("keydown", onCmdkKeydown);
});
onUnmounted(() => {
mittBus.off("searchReset");
window.removeEventListener("keydown", onCmdkKeydown);
closeMsgWs();
});
// 面板已是模态框,输入只驱动面板自身过滤;
// 不再 emit("searchChange") —— 背景应用网格保持原样(2026-10-08 用户修订 1)
watch(searchAppText, () => {
cmdkStagger.value = false; // 输入过滤时不重播逐条入场
});
// ===== 搜索命令面板(2026-10-08 定稿 B 方案)=====
const refCmdkList = ref<any>();
const cmdkIndex = ref(0);
const cmdkStagger = ref(true); // 仅首次打开做逐条入场,输入过滤时不重播
/** 与 AppList.vue / index.vue / SearchPanel.vue 同口径的矢量渲染回退链 */
function vectorIconOf(item: any): string {
if (item?.iconKey) return item.iconKey;
if (item?.icon) return "";
return appiconKeyOf(item?.name || "");
}
/** 结果取自 store.curUserAppList(写入方 = AppList.reloadAppData)。
* 它是 BoaAppType 分组结构 [{ id, name, apps }],name 就是应用类型名;
* 展开时把类型名挂到每条上供右侧灰字展示。
* 注意别用 store.getFlattenAppList —— 它是纯 flatten,会把类型名丢掉 */
const cmdkResult = computed(() => {
const q = (searchAppText.value || "").trim();
const out: any[] = [];
(store.curUserAppList || []).forEach((group: any) => {
(group.apps || []).forEach((app: any) => {
if (!q || (app.name || "").indexOf(q) !== -1) {
out.push({ ...app, appTypeName: group.name });
}
});
});
return out;
});
function openSearch() {
cmdkStagger.value = true;
cmdkIndex.value = 0;
searchAppText.value = null;
isSearching.value = true;
nextTick(() => refSearchInput.value?.focus());
}
function closeSearch() {
isSearching.value = false;
searchAppText.value = null;
}
function moveHighlight(step: number) {
const len = cmdkResult.value.length;
if (!len) return;
cmdkIndex.value = (cmdkIndex.value + step + len) % len;
nextTick(() => {
const el = refCmdkList.value?.children?.[cmdkIndex.value];
el?.scrollIntoView({ block: "nearest" });
});
}
function pickApp(item: any) {
closeSearch();
openAppPage(item, $conf.ifameAppIdArr); // 与点击应用图标同一条路径
}
function onCmdkKeydown(ev: KeyboardEvent) {
if (!isSearching.value) return; // 面板未开:绝不拦截全局按键
if (ev.key === "Escape") {
closeSearch();
return;
}
if (ev.key === "ArrowDown") {
ev.preventDefault();
moveHighlight(1);
return;
}
if (ev.key === "ArrowUp") {
ev.preventDefault();
moveHighlight(-1);
return;
}
if (ev.key === "Enter") {
ev.preventDefault();
const it = cmdkResult.value[cmdkIndex.value];
if (it) pickApp(it);
}
}
function getIsCheckRoles(item: RoleInfo) {
return isEqual(item, store.curRoleInfo);
}
async function changeRole(role: RoleInfo) {
if (!!getIsCheckRoles(role)) return;
setTimeout(() => {
isUserCardShow.value = false;
}, 150);
store.setCurRoleInfo(role);
await BaseApi.savUserRole(role);
mittBus.emit("switchRole", role);
await getUserWallpaperList();
await loadLogoList();
}
/** 消息类型名(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"]);
}
/** 以下入口函数自2026-10-08 齿轮菜单下线后已无 UI 调用方,但对应 Modal 组件仍挂在模板尾部、
* 且 logoList 是header 徽标的数据源,故保留函数体不删,仅把已并轨的 isUserInfoShow 换成 isUserCardShow。
* 恢复入口时直接绑到卡片菜单即可。 */
async function settingsWallpaper() {
await getUserWallpaperList();
isUserCardShow.value = false;
isWallpaperShow.value = true;
}
/** 边栏设置入口改跳设置中心 pane(原 settingsMenu 开旧 MenuBarModal 的能力已于 2026-09-30 迁入 Settings.vue sidebar pane) */
function linkSidebarSetting() {
isUserCardShow.value = false;
router.push("/settings?pane=sidebar");
}
async function settingsStats() {
isUserCardShow.value = false;
refStatsBarModal.value.show();
}
async function settingsTeacherCopilot() {
isUserCardShow.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() {
isUserCardShow.value = false;
router.push("/syssetting");
}
function linkBackendManage() {
isUserCardShow.value = false;
const url = `${$conf.baseHost}/userbase/front/index.html`;
window.open(url, "_blank");
}
async function linkLogoSetting() {
await loadLogoList();
isUserCardShow.value = false;
isLogoShow.value = true;
}
/** 卡片菜单:个人信息 → 设置中心 profile pane(与通知端/用户中心同一地址)。
* 注意:mittBus 的 showUserInfoModal 事件仍由 AppHeader/BschIframe 使用,此处不再触发 */
function showUserInfo() {
isUserCardShow.value = false;
router.push("/settings?pane=profile");
}
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>