Files
fe.ems.vue3/src/layouts/BasicLayout.vue
2024-08-23 19:05:35 +08:00

444 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 {
ProLayout,
WaterMark,
getMenuData,
clearMenuItem,
type MenuDataItem,
} from 'antdv-pro-layout';
import RightContent from './components/RightContent.vue';
import Tabs from './components/Tabs.vue';
import GlobalMask from '@/components/GlobalMask/index.vue';
import { scriptUrl } from '@/assets/js/icon_font_8d5l8fzk5b87iudi';
import {
computed,
reactive,
watch,
onMounted,
onUnmounted,
nextTick,
} from 'vue';
import useLayoutStore from '@/store/modules/layout';
import useRouterStore from '@/store/modules/router';
import useTabsStore from '@/store/modules/tabs';
import useAlarmStore from '@/store/modules/alarm';
import useAppStore from '@/store/modules/app';
import { useRouter } from 'vue-router';
import { MENU_PATH_INLINE } from '@/constants/menu-constants';
const { proConfig, waterMarkContent } = useLayoutStore();
import useI18n from '@/hooks/useI18n';
import { getServerTime } from '@/api';
import { RESULT_CODE_SUCCESS } from '@/constants/result-constants';
import { parseDateToStr } from '@/utils/date-utils';
import { parseUrlPath } from '@/plugins/file-static-url';
const { t, currentLocale } = useI18n();
const routerStore = useRouterStore();
const tabsStore = useTabsStore();
const appStore = useAppStore();
const router = useRouter();
/**菜单面板 */
let layoutState = reactive({
collapsed: false, // 是否展开菜单面板
openKeys: ['/'], // 打开菜单key
selectedKeys: ['/index'], // 选中高亮菜单key
});
/**监听路由变化改变菜单面板选项 */
watch(
router.currentRoute,
v => {
const matched = v.matched.concat();
layoutState.openKeys = matched
.filter(r => r.path !== v.path)
.map(r => r.path);
layoutState.selectedKeys = matched
.filter(r => r.name !== 'Root')
.map(r => r.path);
// 路由地址含有内嵌地址标识又是隐藏菜单需要处理打开高亮菜单栏
if (v.path.includes(MENU_PATH_INLINE) && v.meta.hideInMenu) {
const idx = v.path.lastIndexOf(MENU_PATH_INLINE);
layoutState.openKeys = layoutState.selectedKeys.slice(0, -1);
layoutState.selectedKeys[matched.length - 1] = v.path.slice(0, idx);
}
},
{ immediate: true }
);
// 动态路由添加到菜单面板
const rootRoute = router.getRoutes().find(r => r.name === 'Root');
if (rootRoute) {
const children = routerStore.setRootRouterData(rootRoute.children);
const buildRouterData = routerStore.buildRouterData;
if (buildRouterData.length > 0) {
rootRoute.children = children.concat(buildRouterData);
} else {
rootRoute.children = children;
}
}
const { menuData } = getMenuData(clearMenuItem(router.getRoutes()));
/**面包屑数据对象,排除根节点和首页不显示 */
const breadcrumb = computed(() => {
const matched = router.currentRoute.value.matched.concat();
// 菜单中隐藏子节点不显示面包屑
if (matched.length == 2) {
const hideChildInMenu = matched[0].meta?.hideChildInMenu || false;
if (hideChildInMenu) {
return [];
}
}
return matched
.filter(r => !['Root', 'Index'].includes(r.name as string))
.map(item => {
return {
path: item.path,
breadcrumbName: fnLocale(item),
};
});
});
/**
* 给页面组件设置路由名称
*
* 路由名称设为缓存key
* @param component 页面组件
* @param name 路由名称
*/
function fnComponentSetName(component: any, to: any) {
if (component && component.type) {
// 通过路由取最后匹配的,确认是缓存的才处理
const matched = to.matched.concat();
const lastRoute = matched[matched.length - 1];
if (!lastRoute.meta.cache) return component;
const routeName = lastRoute.name;
const routeDef = lastRoute.components.default;
// 有命名但不是跳转的路由文件
const __name = component.type.__name;
if (__name && __name !== routeName) {
routeDef.name = routeName;
routeDef.__name = routeName;
Reflect.set(component, 'type', routeDef);
return component;
}
}
return component;
}
// 清空导航栏标签
tabsStore.clear();
// LOGO地址
const logoUrl = computed(() => {
let url =
appStore.logoType === 'brand'
? parseUrlPath(appStore.filePathBrand)
: parseUrlPath(appStore.filePathIcon);
if (url.indexOf('{language}') === -1) {
return url;
}
// 语言参数替换
const local = currentLocale.value;
const lang = local.split('_')[0];
return url.replace('{language}', lang);
});
/**系统使用手册跳转 */
function fnClickHelpDoc(language?: string) {
const routeData = router.resolve({ name: 'HelpDoc' });
let href = routeData.href;
if (language) {
href = `${routeData.href}?language=${language}`;
}
window.open(href, '_blank');
}
/**
* 国际化翻译转换
*/
function fnLocale(m: MenuDataItem) {
if (!m.meta) return;
let title = m.meta?.title ?? '';
if (title.indexOf('router.') !== -1) {
title = t(title);
}
return title;
}
/**检查系统名称是否超出范围进行滚动 */
function fnCheckAppNameOverflow() {
const container: HTMLDivElement | null = document.querySelector('.app-name');
if (!container) return;
const text: HTMLDivElement | null = container.querySelector('.marquee');
if (!text) return;
if (text.offsetWidth > container.offsetWidth) {
text.classList.add('app-name_scrollable');
text.setAttribute('data-content', text.innerText);
} else {
text.classList.remove('app-name_scrollable');
}
}
watch(
() => appStore.appName,
() => nextTick(fnCheckAppNameOverflow)
);
//
onMounted(() => {
fnCheckAppNameOverflow();
fnGetServerTime();
useAlarmStore().fnGetActiveAlarmInfo();
});
// ==== 服务器时间显示 start
let serverTime = reactive({
timestamp: 0,
zone: 'UTC', // 时区 UTC
interval: null as any, // 定时器
});
// 获取服务器时间
function fnGetServerTime() {
getServerTime().then(res => {
if (res.code === RESULT_CODE_SUCCESS && res.data) {
const serverTimeDom = document.getElementById('serverTimeDom');
// 时区
const utcOffset = res.data.timeZone / 3600;
serverTime.zone = `UTC ${utcOffset}`;
// 时间戳
serverTime.timestamp = parseInt(res.data.timestamp);
serverTime.interval = setInterval(() => {
serverTime.timestamp += 1000;
// serverTimeStr.value = parseDateToStr(serverTime.timestamp);
// 用DOM直接修改
if (serverTimeDom) {
serverTimeDom.innerText = parseDateToStr(serverTime.timestamp);
}
}, 1000);
}
});
}
// 监听可视改变
document.addEventListener('visibilitychange', function () {
if (document.visibilityState == 'hidden') {
//切离该页面时执行
clearInterval(serverTime.interval);
serverTime.interval = null;
}
if (document.visibilityState == 'visible') {
//切换到该页面时执行
clearInterval(serverTime.interval);
serverTime.interval = null;
fnGetServerTime();
useAlarmStore().fnGetActiveAlarmInfo();
}
});
onUnmounted(() => {
clearInterval(serverTime.interval);
serverTime.interval = null;
});
// ==== 服务器时间显示 end
</script>
<template>
<WaterMark :content="waterMarkContent" :z-index="100">
<ProLayout
v-model:collapsed="layoutState.collapsed"
v-model:selectedKeys="layoutState.selectedKeys"
v-model:openKeys="layoutState.openKeys"
:menu-data="menuData"
:breadcrumb="{ routes: breadcrumb }"
v-bind="proConfig"
:iconfont-url="scriptUrl"
:locale="fnLocale"
>
<!--插槽-菜单头-->
<template #menuHeaderRender>
<RouterLink :to="{ name: 'Index' }" :replace="true">
<template
v-if="
appStore.logoType === 'icon' ||
(proConfig.layout === 'side' && layoutState.collapsed)
"
>
<img
class="logo-icon"
:src="logoUrl"
:alt="appStore.appName"
:title="appStore.appName"
/>
<h1 class="app-name" :title="appStore.appName">
<span class="marquee app-name_scrollable">
{{ appStore.appName }}
</span>
</h1>
</template>
<template v-if="appStore.logoType === 'brand'">
<img
class="logo-brand"
:src="logoUrl"
:alt="appStore.appName"
:title="appStore.appName"
/>
</template>
</RouterLink>
</template>
<!--插槽-顶部左侧只对side布局有效-->
<template #headerContentRender></template>
<!--插槽-顶部右侧-->
<template #headerContentRightRender>
<RightContent />
</template>
<!--插槽-导航标签项-->
<template #tabRender="{ width, fixedHeader, headerRender }">
<Tabs
:width="width"
:fixed-header="fixedHeader"
:header-render="headerRender"
/>
</template>
<!--插槽-页面路由导航面包屑-->
<template #breadcrumbRender="{ route, params, routes }">
<span v-if="routes.indexOf(route) === routes.length - 1">
{{ route.breadcrumbName }}
</span>
<RouterLink v-else :to="{ path: route.path, params }">
{{ route.breadcrumbName }}
</RouterLink>
</template>
<!--内容页面视图-->
<RouterView v-slot="{ Component, route }">
<transition name="slide-left" mode="out-in">
<KeepAlive :include="tabsStore.getCaches">
<component
:is="fnComponentSetName(Component, route)"
:key="route.path"
/>
</KeepAlive>
</transition>
</RouterView>
<!--插槽-内容底部-->
<template #footerRender="{ width }">
<footer class="footer">
<div class="footer-fixed" :style="{ width }">
<div style="flex: 1">
<span>{{ appStore.copyright }}</span>
</div>
<a-space direction="horizontal" :size="8">
<span id="serverTimeDom"></span>
<a-button
type="link"
:href="appStore.officialUrl"
target="_blank"
size="small"
v-perms:has="['system:setting:official']"
v-if="appStore.officialUrl !== '#'"
>
{{ t('loayouts.basic.officialUrl') }}
</a-button>
<a-button
type="link"
size="small"
v-perms:has="['system:setting:doc']"
@click="fnClickHelpDoc()"
>
{{ t('loayouts.basic.helpDoc') }}
</a-button>
</a-space>
</div>
</footer>
</template>
</ProLayout>
<!-- 全局遮罩 -->
<GlobalMask />
</WaterMark>
</template>
<style lang="less" scoped>
.logo-icon {
height: 32px;
width: 32px;
vertical-align: middle;
border-style: none;
border-radius: 6.66px;
}
.logo-brand {
height: 48px;
width: 174px;
vertical-align: middle;
border-style: none;
margin-right: 16px;
}
.app-name {
overflow: hidden;
// text-overflow: ellipsis;
white-space: nowrap;
width: 148px;
> .app-name_scrollable {
padding-right: 12px;
display: inline-block;
animation: scrollable-animation linear 6s infinite both;
&::after {
content: attr(data-content);
position: absolute;
top: 0;
right: -100%;
transition: right 0.3s ease;
}
@keyframes scrollable-animation {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-100% - 12px));
}
}
}
}
.footer {
z-index: 16;
margin: 0px;
width: auto;
margin-top: 32px;
&-fixed {
position: fixed;
bottom: 0;
right: 0;
width: auto;
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: space-between;
padding: 4px 16px;
background: #fff;
box-shadow: 0 1px 4px #0015291f;
transition: background 0.3s, width 0.2s;
height: 32px;
}
& #serverTimeDom {
color: inherit;
opacity: 0.85;
transition: all 0.3s;
}
}
</style>