Files
CampusnoticeFront/src/components/layout.vue
T
lijing ec1f94eaa8
build / build (push) Successful in 10m17s
feat: 提交校园公告前端工程源码(Vue3 + Vite + view-ui-plus)
- 同步仓库 CI 配置 .gitea/workflows/build.yml
- 纳入 src/public/config/scripts 等工程文件
- .gitignore 增加 .scratch/.backup/.trash,排除本地临时产物
2026-09-15 11:15:03 +08:00

317 lines
12 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.
<script setup lang="ts">
import { ref, onBeforeMount, onMounted, onUnmounted, watch, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { resolveMenuPage, normalizeModList } from "@/utils/menuRoute";
import { noticeApi } from "@/api/module/notice";
const props = defineProps<{ layoutData: Record<string, any> }>();
const route = useRoute();
const router = useRouter();
const isPanel = ref(false);
const currPath = ref<string>("");
const navPath = ref<any[]>([]);
const baseHost = computed(() => props.layoutData?.baseHost || "");
const ssoHost = computed(() => props.layoutData?.ssoHost || "");
const appName = computed(() => props.layoutData?.appName || "校园公告");
const appList = computed(() => props.layoutData?.appList || []);
const userInfo = computed(() => props.layoutData?.userInfo || { name: "管理员", avatar: "" });
const isAlone = computed(() => props.layoutData?.isAlone ?? false);
const modList = computed(() => props.layoutData?.modList || []);
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
/* 菜单管线(orderNumber 定序 → path 归一 → 草稿箱注入)已下沉 utils/menuRoute.normalizeModList,
栏目名/顺序均以平台 GetUserMod 配置为准,前端不改名不重排(见执行计划任务方案 v10/v11) */
onBeforeMount(() => {
routeChange(route);
});
watch(
() => route.fullPath,
() => routeChange(route)
);
const flaten = (tree: any[], pid = 0): any[] => {
const arr: any[] = [];
tree.forEach((element: any) => {
const { id, name, modId } = element;
// 平台配置的 path 格式不可控,统一解析为内部路由 path(名称/modId 兜底)
const resolved = resolveMenuPage(element.page, element.modId ?? element.id, element.name);
arr.push({ id, name, modId, page: resolved.path, external: resolved.external, pid });
if (element.children && element.children.length) {
arr.push(...flaten(element.children, id));
}
});
return arr;
};
const menuArr = computed(() => flaten(normalizeModList(modList.value)));
const displayModList = computed(() => normalizeModList(modList.value));
const routeChange = (to: any) => {
const meta: any = to.meta || {};
const { modId, title, parentId } = meta;
const navArr: any[] = [];
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
// 匹配不到再退回 modId 对照
const find: any =
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
menuArr.value.find((x: any) => x.modId === modId);
if (find) {
currPath.value = find.page || "";
if (find.pid) {
const pfind = menuArr.value.find((x: any) => x.id === find.pid);
if (pfind) navArr.push({ name: pfind.name });
}
navArr.push({ name: find.name || title, page: find.page });
navPath.value = navArr;
return;
}
if (parentId) {
const parent = menuArr.value.find((x: any) => x.modId === parentId);
if (parent) {
currPath.value = parent.page || "";
if (parent.pid) {
const gp = menuArr.value.find((x: any) => x.id === parent.pid);
if (gp) navArr.push({ name: gp.name });
}
navArr.push({ name: parent.name, page: parent.page });
navArr.push({ name: title });
navPath.value = navArr;
return;
}
}
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
if (r) {
currPath.value = r.path;
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
return;
}
currPath.value = "";
navPath.value = [];
};
const linkMenu = (item: any) => {
const page: string = item?.page || "";
if (!page) {
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
return;
}
// 平台配置的外部链接模块:整页跳转
if (item?.external) {
window.location.href = page;
return;
}
if (page === route.path) {
router.go(0);
} else {
router.push(page);
}
};
/* ============ 审批中心待审角标(ApprovalCount,60s 轮询 + 进页刷新) ============ */
const approvalBadge = ref(0);
let approvalTimer: number | undefined;
async function refreshApprovalBadge() {
try {
const n = await noticeApi.approvalCount().send();
approvalBadge.value = Number(n) || 0;
} catch {
/* 未配置审批或接口异常时角标静默置 0 */
approvalBadge.value = 0;
}
}
onMounted(() => {
refreshApprovalBadge();
approvalTimer = window.setInterval(refreshApprovalBadge, 60000);
});
onUnmounted(() => {
if (approvalTimer) window.clearInterval(approvalTimer);
});
const linkUserInfo = () => {
window.location.href = baseHost.value + "/v/userinfo";
};
const logout = () => {
let url = window.location.href;
url = "/logout?service=" + url;
window.location.href = ssoHost.value + url;
};
const childChange = (index: number) => {
const arr = displayModList.value || [];
if (arr[index]) {
arr[index].childState = !arr[index].childState;
}
};
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
* 兜底保留旧版 modId 对照。图标为本地 bsch-icons(iconfont 掩码类,见 styles/icons.css) */
const getMenuIcon = (item?: any): string => {
const page: string = item?.page || "";
if (page === "/list/new") return "icon-xiugaijl";
if (page === "/drafts") return "icon-caogaoxiang";
if (page === "/list") return "icon-fasong";
if (page === "/" || page === "/accept") return "icon-sjx";
if (page === "/noticelist") return "icon-liebiao";
if (page === "/approval") return "icon-shenpi";
if (page === "/stats") return "icon-tongji";
if (page === "/setting") return "icon-shezhi";
switch (item?.modId ?? item?.id) {
case 1: return "icon-xiugaijl";
case 2: return "icon-fasong";
case 3: return "icon-sjx";
case 4: return "icon-liebiao";
case 5: return "icon-shenpi";
case 6: return "icon-quanxian";
case 21: return "icon-shezhi";
case 22: return "icon-xiugaijl";
default: return "";
}
};
function linkHomePage() {
if (baseHost.value) {
window.location.href = baseHost.value + "/BschHome/front/index.html";
} else {
window.location.href = "/";
}
}
</script>
<template>
<div class="layout-wrap">
<header class="header-container fix">
<div class="fl align-middle icon-box">
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="isPanel = !isPanel">
<i class="iconfont icon-apps"></i>
</div>
</div>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div>
<div class="header-aside fr">
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
{{ userInfo.name }}
</a>
<div class="delimiter fl align-middle"></div>
<a href="javascript:;" @click="logout" class="logout fl align-middle" title="退出登陆">
<i class="iconfont icon-logout"></i>
</a>
</div>
</header>
<div class="left-menu" v-if="!isAlone">
<div class="menu-top-box"></div>
<ul>
<template v-for="(item, index) in displayModList" :key="'menu_li_' + index">
<template v-if="item.children && item.children.length > 0">
<li>
<a href="javascript:;" @click="childChange(index)">
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
{{ item.name }}
</a>
</li>
<transition name="fade_child_ul">
<div class="child-item-wrap" v-show="item.childState">
<li
:class="citem.page == currPath ? 'active child-item' : 'child-item'"
v-for="citem in item.children"
:key="citem.id"
>
<a href="javascript:;" @click="linkMenu(citem)">
{{ citem.name }}
</a>
</li>
</div>
</transition>
</template>
<li v-else :class="item.page == currPath ? 'active' : ''">
<a href="javascript:;" @click="linkMenu(item)">
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
{{ item.name }}
<span v-if="item.page === '/approval' && approvalBadge > 0" class="menu-badge">
{{ approvalBadge > 99 ? "99+" : approvalBadge }}
</span>
</a>
</li>
</template>
</ul>
<div class="clear-box"></div>
</div>
<div class="path-nav" :style="pathNavStyle">
<Breadcrumb separator=">">
<a href="javascript:;" @click="linkHomePage">
<BreadcrumbItem>
<i class="iconfont icon-category"></i>
首页
</BreadcrumbItem>
</a>
<BreadcrumbItem v-if="!isAlone" to="/">{{ appName }}</BreadcrumbItem>
<template v-for="(item, index) in navPath" :key="index">
<BreadcrumbItem v-if="index != navPath.length - 1" :to="item.page">
{{ item.name }}
</BreadcrumbItem>
<BreadcrumbItem v-else>{{ item.name }}</BreadcrumbItem>
</template>
</Breadcrumb>
</div>
<transition name="fade_apps">
<div class="app-panel" v-if="isPanel">
<div class="tips-box"></div>
<ul class="fix">
<li class="fl" v-for="(item, index) in appList" :key="index">
<a :href="item.url" target="_blank" rel="noopener noreferrer">
<div class="icon-box">
<img :src="item.icon" alt="" />
</div>
<p class="text-ellipsis">{{ item.name }}</p>
</a>
</li>
</ul>
<div class="link-panel">
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
全部应用
<i>→</i>
</a>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 审批中心待审角标 */
.menu-badge {
display: inline-block;
min-width: 18px;
height: 18px;
line-height: 18px;
padding: 0 5px;
margin-left: 6px;
border-radius: 9px;
background: #ed4014;
color: #fff;
font-size: 11px;
text-align: center;
vertical-align: middle;
}
</style>