feat: 提交家校活动前端工程源码(Vue3 + Vite + view-ui-plus)
build / build (push) Successful in 5m24s

This commit is contained in:
lijing
2026-09-20 18:00:18 +08:00
parent 267ba04af2
commit 66ad7fc6c3
333 changed files with 33141 additions and 2 deletions
+289
View File
@@ -0,0 +1,289 @@
<script setup lang="ts">
import { ref, onBeforeMount, watch, computed, inject } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { normalizeModList } from "@/utils/menuRoute";
const props = defineProps<{ layoutData: Record<string, any> }>();
const route = useRoute();
const router = useRouter();
/** 由 App.vue 提供的「软重挂载内容区」信号(替代 router.go(0) 的整页 reload) */
const refreshView = inject<(() => void) | null>("refreshView", null);
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));
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, page, external } = element;
arr.push({ id, name, modId, page, external, pid });
if (element.children && element.children.length) {
arr.push(...flaten(element.children, id));
}
});
return arr;
};
/* 侧边栏渲染用的菜单树:page 已由 normalizeModList 统一归一为内部路由。
跳转(linkMenu)与高亮(currPath 对照)必须共用这一份,否则平台里只配了
名称的模块(如「活动广场」)点不动、且当前项高亮对不上。
用 ref + watch 承载而非 computed:菜单项需要可变的 childState(展开/收起),
computed 里 { ...m } 生成的是普通对象,直接改不会触发重渲染。 */
const displayModList = ref<any[]>([]);
watch(
modList,
(list) => {
displayModList.value = normalizeModList(list);
},
{ immediate: true }
);
const menuArr = computed(() => flaten(displayModList.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) 会整页 reload
// (重新拉身份/菜单、白屏一下),且「点一次没反应」的观感更重
if (refreshView) refreshView();
else router.go(0);
} else {
router.push(page);
}
};
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 === "/list") return "icon-fasong";
if (page === "/signups") return "icon-liebiao";
if (page === "/activitylist") return "icon-liebiao";
if (page === "/stats") return "icon-tongji";
if (page === "/setting") return "icon-shezhi";
if (page === "/par/activities") return "icon-rili";
if (page === "/par/mine") return "icon-chengji";
switch (item?.modId ?? item?.id) {
case 2: return "icon-fasong";
case 3: return "icon-liebiao";
case 4: return "icon-tongji";
case 21: return "icon-shezhi";
case 22: return "icon-xiugaijl";
case 25: return "icon-liebiao";
case 31: return "icon-rili";
case 32: return "icon-chengji";
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 }}
</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;
}
</style>