mirror of
https://github.com/certd/certd.git
synced 2026-05-15 04:27:31 +08:00
feat: 升级前端框架,适配手机端
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
## layout
|
||||
|
||||
### header
|
||||
|
||||
- 支持N个自定义插槽,命名方式:header-right-n,header-left-n
|
||||
- header-left-n ,排序方式:0-19 ,breadcrumb 21-x
|
||||
- header-right-n ,排序方式:0-49,global-search,51-59,theme-toggle,61-69,language-toggle,71-79,fullscreen,81-89,notification,91-149,user-dropdown,151-x
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
import { VbenSpinner } from "../../../shadcn-ui";
|
||||
|
||||
import { useContentSpinner } from "./use-content-spinner";
|
||||
|
||||
defineOptions({ name: "LayoutContentSpinner" });
|
||||
|
||||
const { spinning } = useContentSpinner();
|
||||
</script>
|
||||
<template>
|
||||
<VbenSpinner :spinning="spinning" />
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VNode } from "vue";
|
||||
import type { RouteLocationNormalizedLoaded, RouteLocationNormalizedLoadedGeneric } from "vue-router";
|
||||
|
||||
import { RouterView } from "vue-router";
|
||||
|
||||
import { preferences, usePreferences } from "../../../preferences";
|
||||
import { storeToRefs, useTabbarStore } from "../../../stores";
|
||||
|
||||
import { IFrameRouterView } from "../../iframe";
|
||||
|
||||
defineOptions({ name: "LayoutContent" });
|
||||
|
||||
const tabbarStore = useTabbarStore();
|
||||
const { keepAlive } = usePreferences();
|
||||
|
||||
const { getCachedTabs, getExcludeCachedTabs, renderRouteView } = storeToRefs(tabbarStore);
|
||||
|
||||
// 页面切换动画
|
||||
function getTransitionName(_route: RouteLocationNormalizedLoaded) {
|
||||
// 如果偏好设置未设置,则不使用动画
|
||||
const { tabbar, transition } = preferences;
|
||||
const transitionName = transition.name;
|
||||
if (!transitionName || !transition.enable) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标签页未启用或者未开启缓存,则使用全局配置动画
|
||||
if (!tabbar.enable || !keepAlive) {
|
||||
return transitionName;
|
||||
}
|
||||
|
||||
// 如果页面已经加载过,则不使用动画
|
||||
// if (route.meta.loaded) {
|
||||
// return;
|
||||
// }
|
||||
// 已经打开且已经加载过的页面不使用动画
|
||||
// const inTabs = getCachedTabs.value.includes(route.name as string);
|
||||
|
||||
// return inTabs && route.meta.loaded ? undefined : transitionName;
|
||||
return transitionName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 转换组件,自动添加 name
|
||||
* @param component
|
||||
*/
|
||||
function transformComponent(component: VNode, route: RouteLocationNormalizedLoadedGeneric) {
|
||||
// 组件视图未找到,如果有设置后备视图,则返回后备视图,如果没有,则抛出错误
|
||||
if (!component) {
|
||||
console.error("Component view not found,please check the route configuration");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const routeName = route.name as string;
|
||||
// 如果组件没有 name,则直接返回
|
||||
if (!routeName) {
|
||||
return component;
|
||||
}
|
||||
const componentName = (component?.type as any)?.name;
|
||||
|
||||
// 已经设置过 name,则直接返回
|
||||
if (componentName) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// componentName 与 routeName 一致,则直接返回
|
||||
if (componentName === routeName) {
|
||||
return component;
|
||||
}
|
||||
|
||||
// 设置 name
|
||||
component.type ||= {};
|
||||
(component.type as any).name = routeName;
|
||||
|
||||
return component;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full">
|
||||
<IFrameRouterView />
|
||||
<RouterView v-slot="{ Component, route }">
|
||||
<Transition :name="getTransitionName(route)" appear mode="out-in">
|
||||
<KeepAlive v-if="keepAlive" :exclude="getExcludeCachedTabs" :include="getCachedTabs">
|
||||
<component :is="transformComponent(Component, route)" v-if="renderRouteView" v-show="!route.meta.iframeSrc" :key="route.fullPath" />
|
||||
</KeepAlive>
|
||||
<component :is="Component" v-else-if="renderRouteView" :key="route.fullPath" />
|
||||
</Transition>
|
||||
</RouterView>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as LayoutContentSpinner } from './content-spinner.vue';
|
||||
export { default as LayoutContent } from './content.vue';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { preferences } from '/@/vben/preferences';
|
||||
|
||||
function useContentSpinner() {
|
||||
const spinning = ref(false);
|
||||
const startTime = ref(0);
|
||||
const router = useRouter();
|
||||
const minShowTime = 500; // 最小显示时间
|
||||
const enableLoading = computed(() => preferences.transition.loading);
|
||||
|
||||
// 结束加载动画
|
||||
const onEnd = () => {
|
||||
if (!enableLoading.value) {
|
||||
return;
|
||||
}
|
||||
const processTime = performance.now() - startTime.value;
|
||||
if (processTime < minShowTime) {
|
||||
setTimeout(() => {
|
||||
spinning.value = false;
|
||||
}, minShowTime - processTime);
|
||||
} else {
|
||||
spinning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 路由前置守卫
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) {
|
||||
return true;
|
||||
}
|
||||
startTime.value = performance.now();
|
||||
spinning.value = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
// 路由后置守卫
|
||||
router.afterEach((to) => {
|
||||
if (to.meta.loaded || !enableLoading.value || to.meta.iframeSrc) {
|
||||
return true;
|
||||
}
|
||||
onEnd();
|
||||
return true;
|
||||
});
|
||||
|
||||
return { spinning };
|
||||
}
|
||||
|
||||
export { useContentSpinner };
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts" setup>
|
||||
interface Props {
|
||||
companyName: string;
|
||||
companySiteLink?: string;
|
||||
date: string;
|
||||
icp?: string;
|
||||
icpLink?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "Copyright"
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
companyName: "Vben Admin",
|
||||
companySiteLink: "",
|
||||
date: "2024",
|
||||
icp: "",
|
||||
icpLink: ""
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-md flex-center">
|
||||
<!-- ICP Link -->
|
||||
<a v-if="icp" :href="icpLink || 'javascript:void(0)'" class="hover:text-primary-hover mx-1" target="_blank">
|
||||
{{ icp }}
|
||||
</a>
|
||||
|
||||
<!-- Copyright Text -->
|
||||
Copyright © {{ date }}
|
||||
|
||||
<!-- Company Link -->
|
||||
<a v-if="companyName" :href="companySiteLink || 'javascript:void(0)'" class="hover:text-primary-hover mx-1" target="_blank">
|
||||
{{ companyName }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Copyright } from "./copyright.vue";
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
defineOptions({
|
||||
name: "LayoutFooter"
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-center text-muted-foreground relative h-full w-full text-xs">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LayoutFooter } from "./footer.vue";
|
||||
@@ -0,0 +1,165 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, useSlots } from "vue";
|
||||
|
||||
import { useRefresh } from "../../../hooks";
|
||||
import { RotateCw } from "../../../icons";
|
||||
import { preferences, usePreferences } from "../../../preferences";
|
||||
import { useAccessStore } from "../../../stores";
|
||||
|
||||
import { VbenFullScreen, VbenIconButton } from "../../../shadcn-ui";
|
||||
|
||||
import { GlobalSearch, LanguageToggle, PreferencesButton, ThemeToggle } from "../../widgets";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* Logo 主题
|
||||
*/
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
name: "LayoutHeader"
|
||||
});
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
theme: "light"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
|
||||
const REFERENCE_VALUE = 50;
|
||||
|
||||
const accessStore = useAccessStore();
|
||||
const { globalSearchShortcutKey, preferencesButtonPosition } = usePreferences();
|
||||
const slots = useSlots();
|
||||
const { refresh } = useRefresh();
|
||||
|
||||
const rightSlots = computed(() => {
|
||||
const list = [{ index: REFERENCE_VALUE + 100, name: "user-dropdown" }];
|
||||
if (preferences.widget.globalSearch) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE,
|
||||
name: "global-search"
|
||||
});
|
||||
}
|
||||
|
||||
if (preferencesButtonPosition.value.header) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 10,
|
||||
name: "preferences"
|
||||
});
|
||||
}
|
||||
if (preferences.widget.themeToggle) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 20,
|
||||
name: "theme-toggle"
|
||||
});
|
||||
}
|
||||
if (preferences.widget.languageToggle) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 30,
|
||||
name: "language-toggle"
|
||||
});
|
||||
}
|
||||
if (preferences.widget.fullscreen) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 40,
|
||||
name: "fullscreen"
|
||||
});
|
||||
}
|
||||
if (preferences.widget.notification) {
|
||||
list.push({
|
||||
index: REFERENCE_VALUE + 50,
|
||||
name: "notification"
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(slots).forEach((key) => {
|
||||
const name = key.split("-");
|
||||
if (key.startsWith("header-right")) {
|
||||
list.push({ index: Number(name[2]), name: key });
|
||||
}
|
||||
});
|
||||
return list.sort((a, b) => a.index - b.index);
|
||||
});
|
||||
|
||||
const leftSlots = computed(() => {
|
||||
const list: Array<{ index: number; name: string }> = [];
|
||||
|
||||
if (preferences.widget.refresh) {
|
||||
list.push({
|
||||
index: 0,
|
||||
name: "refresh"
|
||||
});
|
||||
}
|
||||
|
||||
Object.keys(slots).forEach((key) => {
|
||||
const name = key.split("-");
|
||||
if (key.startsWith("header-left")) {
|
||||
list.push({ index: Number(name[2]), name: key });
|
||||
}
|
||||
});
|
||||
return list.sort((a, b) => a.index - b.index);
|
||||
});
|
||||
|
||||
function clearPreferencesAndLogout() {
|
||||
emit("clearPreferencesAndLogout");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="slot in leftSlots.filter((item) => item.index < REFERENCE_VALUE)" :key="slot.name">
|
||||
<slot :name="slot.name">
|
||||
<template v-if="slot.name === 'refresh'">
|
||||
<VbenIconButton class="my-0 mr-1 rounded-md" @click="refresh">
|
||||
<RotateCw class="size-4" />
|
||||
</VbenIconButton>
|
||||
</template>
|
||||
</slot>
|
||||
</template>
|
||||
<div class="flex-center hidden lg:block">
|
||||
<slot name="breadcrumb"></slot>
|
||||
</div>
|
||||
<template v-for="slot in leftSlots.filter((item) => item.index > REFERENCE_VALUE)" :key="slot.name">
|
||||
<slot :name="slot.name"></slot>
|
||||
</template>
|
||||
<div :class="`menu-align-${preferences.header.menuAlign}`" class="flex h-full min-w-0 flex-1 items-center">
|
||||
<slot name="menu"></slot>
|
||||
<slot name="header-menu-right"></slot>
|
||||
</div>
|
||||
<div class="flex h-full min-w-0 flex-shrink-0 items-center">
|
||||
<template v-for="slot in rightSlots" :key="slot.name">
|
||||
<slot :name="slot.name">
|
||||
<template v-if="slot.name === 'global-search'">
|
||||
<GlobalSearch :enable-shortcut-key="globalSearchShortcutKey" :menus="accessStore.accessMenus" class="mr-1 sm:mr-4" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="slot.name === 'preferences'">
|
||||
<PreferencesButton class="mr-1" @clear-preferences-and-logout="clearPreferencesAndLogout" />
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'theme-toggle'">
|
||||
<ThemeToggle class="mr-1 mt-[2px]" />
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'language-toggle'">
|
||||
<LanguageToggle class="mr-1" />
|
||||
</template>
|
||||
<template v-else-if="slot.name === 'fullscreen'">
|
||||
<VbenFullScreen class="mr-1" />
|
||||
</template>
|
||||
</slot>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<style lang="less" scoped>
|
||||
.menu-align-start {
|
||||
--menu-align: start;
|
||||
}
|
||||
|
||||
.menu-align-center {
|
||||
--menu-align: center;
|
||||
}
|
||||
|
||||
.menu-align-end {
|
||||
--menu-align: end;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LayoutHeader } from './header.vue';
|
||||
@@ -0,0 +1 @@
|
||||
export { default as BasicLayout } from "./layout.vue";
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SetupContext } from "vue";
|
||||
|
||||
import type { MenuRecordRaw } from "../../types";
|
||||
|
||||
import { computed, useSlots, watch } from "vue";
|
||||
|
||||
import { useRefresh } from "../../hooks";
|
||||
import { $t, i18n } from "../../locales";
|
||||
import { preferences, updatePreferences, usePreferences } from "../../preferences";
|
||||
import { useLockStore } from "../../stores";
|
||||
import { cloneDeep, mapTree } from "../../utils";
|
||||
|
||||
import { VbenAdminLayout } from "../../layout-ui";
|
||||
import { VbenBackTop, VbenLogo } from "../../shadcn-ui";
|
||||
|
||||
import { Breadcrumb, CheckUpdates, Preferences } from "../widgets";
|
||||
import { LayoutContent, LayoutContentSpinner } from "./content";
|
||||
import { Copyright } from "./copyright";
|
||||
import { LayoutFooter } from "./footer";
|
||||
import { LayoutHeader } from "./header";
|
||||
import { LayoutExtraMenu, LayoutMenu, LayoutMixedMenu, useExtraMenu, useMixedMenu } from "./menu";
|
||||
import { LayoutTabbar } from "./tabbar";
|
||||
|
||||
defineOptions({ name: "BasicLayout" });
|
||||
|
||||
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
|
||||
|
||||
const { isDark, isHeaderNav, isMixedNav, isMobile, isSideMixedNav, isHeaderMixedNav, isHeaderSidebarNav, layout, preferencesButtonPosition, sidebarCollapsed, theme } = usePreferences();
|
||||
const lockStore = useLockStore();
|
||||
const { refresh } = useRefresh();
|
||||
|
||||
const sidebarTheme = computed(() => {
|
||||
const dark = isDark.value || preferences.theme.semiDarkSidebar;
|
||||
return dark ? "dark" : "light";
|
||||
});
|
||||
|
||||
const headerTheme = computed(() => {
|
||||
const dark = isDark.value || preferences.theme.semiDarkHeader;
|
||||
return dark ? "dark" : "light";
|
||||
});
|
||||
|
||||
const logoClass = computed(() => {
|
||||
const { collapsedShowTitle } = preferences.sidebar;
|
||||
const classes: string[] = [];
|
||||
|
||||
if (collapsedShowTitle && sidebarCollapsed.value && !isMixedNav.value) {
|
||||
classes.push("mx-auto");
|
||||
}
|
||||
|
||||
if (isSideMixedNav.value) {
|
||||
classes.push("flex-center");
|
||||
}
|
||||
|
||||
return classes.join(" ");
|
||||
});
|
||||
|
||||
const isMenuRounded = computed(() => {
|
||||
return preferences.navigation.styleType === "rounded";
|
||||
});
|
||||
|
||||
const logoCollapsed = computed(() => {
|
||||
if (isMobile.value && sidebarCollapsed.value) {
|
||||
return true;
|
||||
}
|
||||
if (isHeaderNav.value || isMixedNav.value || isHeaderSidebarNav.value) {
|
||||
return false;
|
||||
}
|
||||
return sidebarCollapsed.value || isSideMixedNav.value || isHeaderMixedNav.value;
|
||||
});
|
||||
|
||||
const showHeaderNav = computed(() => {
|
||||
return !isMobile.value && (isHeaderNav.value || isMixedNav.value || isHeaderMixedNav.value);
|
||||
});
|
||||
|
||||
const { handleMenuSelect, handleMenuOpen, headerActive, headerMenus, sidebarActive, sidebarMenus, mixHeaderMenus, sidebarVisible } = useMixedMenu();
|
||||
|
||||
// 侧边多列菜单
|
||||
const { extraActiveMenu, extraMenus, handleDefaultSelect, handleMenuMouseEnter, handleMixedMenuSelect, handleSideMouseLeave, sidebarExtraVisible } = useExtraMenu(mixHeaderMenus);
|
||||
|
||||
/**
|
||||
* 包装菜单,翻译菜单名称
|
||||
* @param menus 原始菜单数据
|
||||
* @param deep 是否深度包装。对于双列布局,只需要包装第一层,因为更深层的数据会在扩展菜单中重新包装
|
||||
*/
|
||||
function wrapperMenus(menus: MenuRecordRaw[], deep: boolean = true) {
|
||||
return deep
|
||||
? mapTree(menus, (item: any) => {
|
||||
return { ...cloneDeep(item), name: $t(item.name) };
|
||||
})
|
||||
: menus.map((item) => {
|
||||
return { ...cloneDeep(item), name: $t(item.name) };
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
updatePreferences({
|
||||
sidebar: {
|
||||
hidden: !preferences.sidebar.hidden
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function clearPreferencesAndLogout() {
|
||||
emit("clearPreferencesAndLogout");
|
||||
}
|
||||
|
||||
watch(
|
||||
() => preferences.app.layout,
|
||||
async (val) => {
|
||||
if (val === "sidebar-mixed-nav" && preferences.sidebar.hidden) {
|
||||
updatePreferences({
|
||||
sidebar: {
|
||||
hidden: false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 语言更新后,刷新页面
|
||||
// i18n.global.locale会在preference.app.locale变更之后才会更新,因此watchpreference.app.locale是不合适的,刷新页面时可能语言配置尚未完全加载完成
|
||||
watch(i18n.global.locale, refresh, { flush: "post" });
|
||||
|
||||
const slots: SetupContext["slots"] = useSlots();
|
||||
const headerSlots = computed(() => {
|
||||
return Object.keys(slots).filter((key) => key.startsWith("header-"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VbenAdminLayout
|
||||
v-model:sidebar-extra-visible="sidebarExtraVisible"
|
||||
:content-compact="preferences.app.contentCompact"
|
||||
:footer-enable="preferences.footer.enable"
|
||||
:footer-fixed="preferences.footer.fixed"
|
||||
:header-hidden="preferences.header.hidden"
|
||||
:header-mode="preferences.header.mode"
|
||||
:header-theme="headerTheme"
|
||||
:header-toggle-sidebar-button="preferences.widget.sidebarToggle"
|
||||
:header-visible="preferences.header.enable"
|
||||
:is-mobile="preferences.app.isMobile"
|
||||
:layout="layout"
|
||||
:sidebar-collapse="preferences.sidebar.collapsed"
|
||||
:sidebar-collapse-show-title="preferences.sidebar.collapsedShowTitle"
|
||||
:sidebar-enable="sidebarVisible"
|
||||
:sidebar-expand-on-hover="preferences.sidebar.expandOnHover"
|
||||
:sidebar-extra-collapse="preferences.sidebar.extraCollapse"
|
||||
:sidebar-hidden="preferences.sidebar.hidden"
|
||||
:sidebar-theme="sidebarTheme"
|
||||
:sidebar-width="preferences.sidebar.width"
|
||||
:tabbar-enable="preferences.tabbar.enable"
|
||||
:tabbar-height="preferences.tabbar.height"
|
||||
@side-mouse-leave="handleSideMouseLeave"
|
||||
@toggle-sidebar="toggleSidebar"
|
||||
@update:sidebar-collapse="(value: boolean) => updatePreferences({ sidebar: { collapsed: value } })"
|
||||
@update:sidebar-enable="(value: boolean) => updatePreferences({ sidebar: { enable: value } })"
|
||||
@update:sidebar-expand-on-hover="(value: boolean) => updatePreferences({ sidebar: { expandOnHover: value } })"
|
||||
@update:sidebar-extra-collapse="(value: boolean) => updatePreferences({ sidebar: { extraCollapse: value } })"
|
||||
>
|
||||
<!-- logo -->
|
||||
<template #logo>
|
||||
<VbenLogo v-if="preferences.logo.enable" :class="logoClass" :collapsed="logoCollapsed" :src="preferences.logo.source" :text="preferences.app.name" :theme="showHeaderNav ? headerTheme : theme" />
|
||||
</template>
|
||||
<!-- 头部区域 -->
|
||||
<template #header>
|
||||
<LayoutHeader :theme="theme" @clear-preferences-and-logout="clearPreferencesAndLogout">
|
||||
<template v-if="!showHeaderNav && preferences.breadcrumb.enable" #breadcrumb>
|
||||
<Breadcrumb :hide-when-only-one="preferences.breadcrumb.hideOnlyOne" :show-home="preferences.breadcrumb.showHome" :show-icon="preferences.breadcrumb.showIcon" :type="preferences.breadcrumb.styleType" />
|
||||
</template>
|
||||
<template v-if="showHeaderNav" #menu>
|
||||
<LayoutMenu :default-active="headerActive" :menus="wrapperMenus(headerMenus)" :rounded="isMenuRounded" :theme="headerTheme" class="w-full" mode="horizontal" @select="handleMenuSelect" />
|
||||
</template>
|
||||
<template #user-dropdown>
|
||||
<slot name="user-dropdown"></slot>
|
||||
</template>
|
||||
<template #notification>
|
||||
<slot name="notification"></slot>
|
||||
</template>
|
||||
<template v-for="item in headerSlots" #[item]>
|
||||
<slot :name="item"></slot>
|
||||
</template>
|
||||
</LayoutHeader>
|
||||
</template>
|
||||
<!-- 侧边菜单区域 -->
|
||||
<template #menu>
|
||||
<LayoutMenu
|
||||
:accordion="preferences.navigation.accordion"
|
||||
:collapse="preferences.sidebar.collapsed"
|
||||
:collapse-show-title="preferences.sidebar.collapsedShowTitle"
|
||||
:default-active="sidebarActive"
|
||||
:menus="wrapperMenus(sidebarMenus)"
|
||||
:rounded="isMenuRounded"
|
||||
:theme="sidebarTheme"
|
||||
mode="vertical"
|
||||
@open="handleMenuOpen"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
<template #mixed-menu>
|
||||
<LayoutMixedMenu
|
||||
:active-path="extraActiveMenu"
|
||||
:menus="wrapperMenus(mixHeaderMenus, false)"
|
||||
:rounded="isMenuRounded"
|
||||
:theme="sidebarTheme"
|
||||
@default-select="handleDefaultSelect"
|
||||
@enter="handleMenuMouseEnter"
|
||||
@select="handleMixedMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
<!-- 侧边额外区域 -->
|
||||
<template #side-extra>
|
||||
<LayoutExtraMenu :accordion="preferences.navigation.accordion" :collapse="preferences.sidebar.extraCollapse" :menus="wrapperMenus(extraMenus)" :rounded="isMenuRounded" :theme="sidebarTheme" />
|
||||
</template>
|
||||
<template #side-extra-title>
|
||||
<VbenLogo v-if="preferences.logo.enable" :text="preferences.app.name" :theme="theme" />
|
||||
</template>
|
||||
|
||||
<template #tabbar>
|
||||
<LayoutTabbar v-if="preferences.tabbar.enable" :show-icon="preferences.tabbar.showIcon" :theme="theme" />
|
||||
</template>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<template #content>
|
||||
<LayoutContent />
|
||||
</template>
|
||||
|
||||
<template v-if="preferences.transition.loading" #content-overlay>
|
||||
<LayoutContentSpinner />
|
||||
</template>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<template v-if="preferences.footer.enable" #footer>
|
||||
<LayoutFooter>
|
||||
<Copyright v-if="preferences.copyright.enable" v-bind="preferences.copyright" />
|
||||
<slot name="footer"></slot>
|
||||
</LayoutFooter>
|
||||
</template>
|
||||
|
||||
<template #extra>
|
||||
<slot name="extra"></slot>
|
||||
<CheckUpdates v-if="preferences.app.enableCheckUpdates" :check-updates-interval="preferences.app.checkUpdatesInterval" />
|
||||
|
||||
<Transition v-if="preferences.widget.lockScreen" name="slide-up">
|
||||
<slot v-if="lockStore.isLockScreen" name="lock-screen"></slot>
|
||||
</Transition>
|
||||
|
||||
<template v-if="preferencesButtonPosition.fixed">
|
||||
<Preferences class="z-100 fixed bottom-20 right-0" @clear-preferences-and-logout="clearPreferencesAndLogout" />
|
||||
</template>
|
||||
<VbenBackTop />
|
||||
</template>
|
||||
</VbenAdminLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from "../../../types";
|
||||
|
||||
import type { MenuProps } from "../../../menu-ui";
|
||||
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { Menu } from "../../../menu-ui";
|
||||
|
||||
import { useNavigation } from "./use-navigation";
|
||||
|
||||
interface Props extends MenuProps {
|
||||
collapse?: boolean;
|
||||
menus: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
accordion: true,
|
||||
menus: () => []
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const { navigation } = useNavigation();
|
||||
|
||||
async function handleSelect(key: string) {
|
||||
await navigation(key);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Menu :accordion="accordion" :collapse="collapse" :default-active="route.meta?.activePath || route.path" :menus="menus" :rounded="rounded" :theme="theme" mode="vertical" @select="handleSelect" />
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as LayoutExtraMenu } from "./extra-menu.vue";
|
||||
export { default as LayoutMenu } from "./menu.vue";
|
||||
export { default as LayoutMixedMenu } from "./mixed-menu.vue";
|
||||
export * from "./use-extra-menu";
|
||||
export * from "./use-mixed-menu";
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from "../../../types";
|
||||
|
||||
import type { MenuProps } from "../../../menu-ui";
|
||||
|
||||
import { Menu } from "../../../menu-ui";
|
||||
|
||||
interface Props extends MenuProps {
|
||||
menus: MenuRecordRaw[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
accordion: true,
|
||||
menus: () => []
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
open: [string, string[]];
|
||||
select: [string, string?];
|
||||
}>();
|
||||
|
||||
function handleMenuSelect(key: string) {
|
||||
emit("select", key, props.mode);
|
||||
}
|
||||
|
||||
function handleMenuOpen(key: string, path: string[]) {
|
||||
emit("open", key, path);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Menu
|
||||
:accordion="accordion"
|
||||
:collapse="collapse"
|
||||
:collapse-show-title="collapseShowTitle"
|
||||
:default-active="defaultActive"
|
||||
:menus="menus"
|
||||
:mode="mode"
|
||||
:rounded="rounded"
|
||||
:theme="theme"
|
||||
@open="handleMenuOpen"
|
||||
@select="handleMenuSelect"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" setup>
|
||||
import type { MenuRecordRaw } from "../../../types";
|
||||
|
||||
import type { NormalMenuProps } from "../../../menu-ui";
|
||||
|
||||
import { onBeforeMount } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { findMenuByPath } from "../../../utils";
|
||||
|
||||
import { NormalMenu } from "../../../menu-ui";
|
||||
|
||||
interface Props extends NormalMenuProps {}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
defaultSelect: [MenuRecordRaw, MenuRecordRaw?];
|
||||
enter: [MenuRecordRaw];
|
||||
select: [MenuRecordRaw];
|
||||
}>();
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
onBeforeMount(() => {
|
||||
const menu = findMenuByPath(props.menus || [], route.path);
|
||||
if (menu) {
|
||||
const rootMenu = (props.menus || []).find((item) => item.path === menu.parents?.[0]);
|
||||
emit("defaultSelect", menu, rootMenu);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NormalMenu :active-path="activePath" :collapse="collapse" :menus="menus" :rounded="rounded" :theme="theme" @enter="(menu) => emit('enter', menu)" @select="(menu) => emit('select', menu)" />
|
||||
</template>
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ComputedRef } from "vue";
|
||||
|
||||
import type { MenuRecordRaw } from "../../../types";
|
||||
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { preferences } from "../../../preferences";
|
||||
import { useAccessStore } from "../../../stores";
|
||||
import { findRootMenuByPath } from "../../../utils";
|
||||
|
||||
import { useNavigation } from "./use-navigation";
|
||||
|
||||
function useExtraMenu(useRootMenus?: ComputedRef<MenuRecordRaw[]>) {
|
||||
const accessStore = useAccessStore();
|
||||
const { navigation } = useNavigation();
|
||||
|
||||
const menus = computed(() => useRootMenus?.value ?? accessStore.accessMenus);
|
||||
|
||||
/** 记录当前顶级菜单下哪个子菜单最后激活 */
|
||||
const defaultSubMap = new Map<string, string>();
|
||||
const extraRootMenus = ref<MenuRecordRaw[]>([]);
|
||||
const route = useRoute();
|
||||
const extraMenus = ref<MenuRecordRaw[]>([]);
|
||||
const sidebarExtraVisible = ref<boolean>(false);
|
||||
const extraActiveMenu = ref("");
|
||||
const parentLevel = computed(() => (preferences.app.layout === "header-mixed-nav" ? 1 : 0));
|
||||
|
||||
/**
|
||||
* 选择混合菜单事件
|
||||
* @param menu
|
||||
*/
|
||||
const handleMixedMenuSelect = async (menu: MenuRecordRaw) => {
|
||||
extraMenus.value = menu?.children ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
const hasChildren = extraMenus.value.length > 0;
|
||||
|
||||
sidebarExtraVisible.value = hasChildren;
|
||||
if (!hasChildren) {
|
||||
await navigation(menu.path);
|
||||
} else if (preferences.sidebar.autoActivateChild) {
|
||||
await navigation(defaultSubMap.has(menu.path) ? (defaultSubMap.get(menu.path) as string) : menu.path);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 选择默认菜单事件
|
||||
* @param menu
|
||||
* @param rootMenu
|
||||
*/
|
||||
const handleDefaultSelect = async (menu: MenuRecordRaw, rootMenu?: MenuRecordRaw) => {
|
||||
extraMenus.value = rootMenu?.children ?? extraRootMenus.value ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 侧边菜单鼠标移出事件
|
||||
*/
|
||||
const handleSideMouseLeave = () => {
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(menus.value, route.path);
|
||||
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? "";
|
||||
extraMenus.value = rootMenu?.children ?? [];
|
||||
};
|
||||
|
||||
const handleMenuMouseEnter = (menu: MenuRecordRaw) => {
|
||||
if (!preferences.sidebar.expandOnHover) {
|
||||
const { findMenu } = findRootMenuByPath(menus.value, menu.path);
|
||||
extraMenus.value = findMenu?.children ?? [];
|
||||
extraActiveMenu.value = menu.parents?.[parentLevel.value] ?? menu.path;
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
}
|
||||
};
|
||||
|
||||
function calcExtraMenus(path: string) {
|
||||
const currentPath = route.meta?.activePath || path;
|
||||
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(menus.value, currentPath, parentLevel.value);
|
||||
extraRootMenus.value = rootMenu?.children ?? [];
|
||||
if (rootMenuPath) defaultSubMap.set(rootMenuPath, currentPath);
|
||||
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? "";
|
||||
extraMenus.value = rootMenu?.children ?? [];
|
||||
if (preferences.sidebar.expandOnHover) {
|
||||
sidebarExtraVisible.value = extraMenus.value.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.path, preferences.app.layout],
|
||||
([path]) => {
|
||||
calcExtraMenus(path || "");
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return {
|
||||
extraActiveMenu,
|
||||
extraMenus,
|
||||
handleDefaultSelect,
|
||||
handleMenuMouseEnter,
|
||||
handleMixedMenuSelect,
|
||||
handleSideMouseLeave,
|
||||
sidebarExtraVisible
|
||||
};
|
||||
}
|
||||
|
||||
export { useExtraMenu };
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { MenuRecordRaw } from "../../../types";
|
||||
|
||||
import { computed, onBeforeMount, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { preferences, usePreferences } from "../../../preferences";
|
||||
import { useAccessStore } from "../../../stores";
|
||||
import { findRootMenuByPath } from "../../../utils";
|
||||
|
||||
import { useNavigation } from "./use-navigation";
|
||||
|
||||
function useMixedMenu() {
|
||||
const { navigation } = useNavigation();
|
||||
const accessStore = useAccessStore();
|
||||
const route = useRoute();
|
||||
const lastSideMenus = ref<MenuRecordRaw[]>([]);
|
||||
const splitSideMenus = ref<MenuRecordRaw[]>([]);
|
||||
const rootMenuPath = ref<string>("");
|
||||
const mixedRootMenuPath = ref<string>("");
|
||||
const mixExtraMenus = ref<MenuRecordRaw[]>([]);
|
||||
/** 记录当前顶级菜单下哪个子菜单最后激活 */
|
||||
const defaultSubMap = new Map<string, string>();
|
||||
const { isMixedNav, isHeaderMixedNav } = usePreferences();
|
||||
|
||||
const needSplit = computed(() => (preferences.navigation.split && isMixedNav.value) || isHeaderMixedNav.value);
|
||||
|
||||
const sidebarVisible = computed(() => {
|
||||
const enableSidebar = preferences.sidebar.enable;
|
||||
if (needSplit.value) {
|
||||
return enableSidebar && splitSideMenus.value.length > 0;
|
||||
}
|
||||
return enableSidebar;
|
||||
});
|
||||
|
||||
function buildItemMenus(menus: any) {
|
||||
if (menus == null) {
|
||||
return;
|
||||
}
|
||||
const list: any = [];
|
||||
for (const sub of menus) {
|
||||
if (sub.meta?.show != null) {
|
||||
if (sub.meta.show === false || (typeof sub.meta.show === "function" && !sub.meta.show())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const item: any = {
|
||||
...sub
|
||||
};
|
||||
|
||||
list.push(item);
|
||||
if (sub.children && sub.children.length > 0) {
|
||||
item.children = buildItemMenus(sub.children);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
const allMenus = computed(() => {
|
||||
const menus = accessStore.accessMenus;
|
||||
return buildItemMenus(menus);
|
||||
});
|
||||
|
||||
const menus = computed(() => {
|
||||
return allMenus.value.filter((item: any) => item?.meta?.isMenu !== false && item?.meta?.hideInMenu !== true && !item?.meta?.fixedAside);
|
||||
});
|
||||
const holdMenus = computed(() => {
|
||||
return allMenus.value.filter((item: any) => item?.meta?.fixedAside);
|
||||
});
|
||||
/**
|
||||
* 头部菜单
|
||||
*/
|
||||
const headerMenus = computed(() => {
|
||||
if (!needSplit.value) {
|
||||
return menus.value;
|
||||
}
|
||||
return menus.value.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
children: []
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边菜单
|
||||
*/
|
||||
const sidebarMenus = computed(() => {
|
||||
const sideMenus = needSplit.value ? splitSideMenus.value : menus.value;
|
||||
return [...holdMenus.value, ...sideMenus];
|
||||
});
|
||||
|
||||
const mixHeaderMenus = computed(() => {
|
||||
return isHeaderMixedNav.value ? sidebarMenus.value : headerMenus.value;
|
||||
});
|
||||
|
||||
/**
|
||||
* 侧边菜单激活路径
|
||||
*/
|
||||
const sidebarActive = computed(() => {
|
||||
return (route?.meta?.activePath as string) ?? route.path;
|
||||
});
|
||||
|
||||
/**
|
||||
* 头部菜单激活路径
|
||||
*/
|
||||
const headerActive = computed(() => {
|
||||
if (!needSplit.value) {
|
||||
return route.path;
|
||||
}
|
||||
return rootMenuPath.value;
|
||||
});
|
||||
|
||||
function saveLastSplitSideMenus() {
|
||||
if (splitSideMenus.value.length == 0 && lastSideMenus.value?.length > 0) {
|
||||
splitSideMenus.value = lastSideMenus.value;
|
||||
}
|
||||
if (!splitSideMenus.value || splitSideMenus.value.length === 0) {
|
||||
//仍然为空,从所有菜单中查找
|
||||
const hasChildren = allMenus.value.find((item) => {
|
||||
return item.children && item.children.length > 0;
|
||||
});
|
||||
if (hasChildren) {
|
||||
splitSideMenus.value = hasChildren.children;
|
||||
}
|
||||
}
|
||||
lastSideMenus.value = splitSideMenus.value;
|
||||
}
|
||||
/**
|
||||
* 菜单点击事件处理
|
||||
* @param key 菜单路径
|
||||
* @param mode 菜单模式
|
||||
*/
|
||||
const handleMenuSelect = (key: string, mode?: string) => {
|
||||
if (!needSplit.value || mode === "vertical") {
|
||||
navigation(key);
|
||||
return;
|
||||
}
|
||||
|
||||
const rootMenu = menus.value.find((item) => item.path === key);
|
||||
rootMenuPath.value = rootMenu?.path ?? "";
|
||||
splitSideMenus.value = rootMenu?.children ?? [];
|
||||
saveLastSplitSideMenus();
|
||||
if (splitSideMenus.value.length === 0) {
|
||||
navigation(key);
|
||||
} else if (rootMenu && preferences.sidebar.autoActivateChild) {
|
||||
navigation(defaultSubMap.has(rootMenu.path) ? (defaultSubMap.get(rootMenu.path) as string) : rootMenu.path);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 侧边菜单展开事件
|
||||
* @param key 路由路径
|
||||
* @param parentsPath 父级路径
|
||||
*/
|
||||
const handleMenuOpen = (key: string, parentsPath: string[]) => {
|
||||
if (parentsPath.length <= 1 && preferences.sidebar.autoActivateChild) {
|
||||
navigation(defaultSubMap.has(key) ? (defaultSubMap.get(key) as string) : key);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 计算侧边菜单
|
||||
* @param path 路由路径
|
||||
*/
|
||||
function calcSideMenus(path: string = route.path) {
|
||||
let { rootMenu } = findRootMenuByPath(menus.value, path);
|
||||
if (!rootMenu) {
|
||||
rootMenu = menus.value.find((item) => item.path === path);
|
||||
}
|
||||
const result = findRootMenuByPath(rootMenu?.children || [], path, 1);
|
||||
mixedRootMenuPath.value = result.rootMenuPath ?? "";
|
||||
mixExtraMenus.value = result.rootMenu?.children ?? [];
|
||||
rootMenuPath.value = rootMenu?.path ?? "";
|
||||
splitSideMenus.value = rootMenu?.children ?? lastSideMenus.value ?? [];
|
||||
saveLastSplitSideMenus();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
const currentPath = (route?.meta?.activePath as string) ?? path;
|
||||
calcSideMenus(currentPath);
|
||||
if (rootMenuPath.value) defaultSubMap.set(rootMenuPath.value, currentPath);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 初始化计算侧边菜单
|
||||
onBeforeMount(() => {
|
||||
calcSideMenus((route.meta?.activePath || route.path) as string);
|
||||
});
|
||||
|
||||
return {
|
||||
handleMenuSelect,
|
||||
handleMenuOpen,
|
||||
headerActive,
|
||||
headerMenus,
|
||||
sidebarActive,
|
||||
sidebarMenus,
|
||||
mixHeaderMenus,
|
||||
mixExtraMenus,
|
||||
sidebarVisible
|
||||
};
|
||||
}
|
||||
|
||||
export { useMixedMenu };
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { RouteRecordNormalized } from "vue-router";
|
||||
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { isHttpUrl, openRouteInNewWindow, openWindow } from "../../../utils";
|
||||
|
||||
function useNavigation() {
|
||||
const router = useRouter();
|
||||
const routes = router.getRoutes();
|
||||
|
||||
const routeMetaMap = new Map<string, RouteRecordNormalized>();
|
||||
|
||||
routes.forEach((route) => {
|
||||
routeMetaMap.set(route.path, route);
|
||||
});
|
||||
|
||||
const navigation = async (path: string) => {
|
||||
const route = routeMetaMap.get(path);
|
||||
const { openInNewWindow = false, query = {} as any } = route?.meta ?? {};
|
||||
if (isHttpUrl(path)) {
|
||||
openWindow(path, { target: "_blank" });
|
||||
} else if (openInNewWindow) {
|
||||
openRouteInNewWindow(path);
|
||||
} else {
|
||||
await router.push({
|
||||
path,
|
||||
query
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return { navigation };
|
||||
}
|
||||
|
||||
export { useNavigation };
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as LayoutTabbar } from "./tabbar.vue";
|
||||
export * from "./use-tabbar";
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { useContentMaximize, useTabs } from "../../../hooks";
|
||||
import { preferences } from "../../../preferences";
|
||||
import { useTabbarStore } from "../../../stores";
|
||||
|
||||
import { TabsToolMore, TabsToolScreen, TabsView } from "../../../tabs-ui";
|
||||
|
||||
import { useTabbar } from "./use-tabbar";
|
||||
|
||||
defineOptions({
|
||||
name: "LayoutTabbar"
|
||||
});
|
||||
|
||||
defineProps<{ showIcon?: boolean; theme?: string }>();
|
||||
|
||||
const route = useRoute();
|
||||
const tabbarStore = useTabbarStore();
|
||||
const { contentIsMaximize, toggleMaximize } = useContentMaximize();
|
||||
const { unpinTab } = useTabs();
|
||||
|
||||
const { createContextMenus, currentActive, currentTabs, handleClick, handleClose } = useTabbar();
|
||||
|
||||
const menus = computed(() => {
|
||||
const tab = tabbarStore.getTabByPath(currentActive.value);
|
||||
const menus = createContextMenus(tab);
|
||||
return menus.map((item) => {
|
||||
return {
|
||||
...item,
|
||||
label: item.text,
|
||||
value: item.key
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
// 刷新后如果不保持tab状态,关闭其他tab
|
||||
if (!preferences.tabbar.persist) {
|
||||
tabbarStore.closeOtherTabs(route);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsView
|
||||
:active="currentActive"
|
||||
:class="theme"
|
||||
:context-menus="createContextMenus"
|
||||
:draggable="preferences.tabbar.draggable"
|
||||
:show-icon="showIcon"
|
||||
:style-type="preferences.tabbar.styleType"
|
||||
:tabs="currentTabs"
|
||||
:wheelable="preferences.tabbar.wheelable"
|
||||
:middle-click-to-close="preferences.tabbar.middleClickToClose"
|
||||
@close="handleClose"
|
||||
@sort-tabs="tabbarStore.sortTabs"
|
||||
@unpin="unpinTab"
|
||||
@update:active="handleClick"
|
||||
/>
|
||||
<div class="flex-center h-full">
|
||||
<TabsToolMore v-if="preferences.tabbar.showMore" :menus="menus" />
|
||||
<TabsToolScreen v-if="preferences.tabbar.showMaximize" :screen="contentIsMaximize" @change="toggleMaximize" @update:screen="toggleMaximize" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { RouteLocationNormalizedGeneric } from "vue-router";
|
||||
|
||||
import type { TabDefinition } from "../../../types";
|
||||
|
||||
import type { IContextMenuItem } from "../../../tabs-ui";
|
||||
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { useContentMaximize, useTabs } from "../../../hooks";
|
||||
import { ArrowLeftToLine, ArrowRightLeft, ArrowRightToLine, ExternalLink, FoldHorizontal, Fullscreen, Minimize2, Pin, PinOff, RotateCw, X } from "../../../icons";
|
||||
import { $t, useI18n } from "../../../locales";
|
||||
import { useAccessStore, useTabbarStore } from "../../../stores";
|
||||
import { filterTree } from "../../../utils";
|
||||
|
||||
export function useTabbar() {
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const accessStore = useAccessStore();
|
||||
const tabbarStore = useTabbarStore();
|
||||
const { contentIsMaximize, toggleMaximize } = useContentMaximize();
|
||||
const { closeAllTabs, closeCurrentTab, closeLeftTabs, closeOtherTabs, closeRightTabs, closeTabByKey, getTabDisableState, openTabInNewWindow, refreshTab, toggleTabPin } = useTabs();
|
||||
|
||||
const currentActive = computed(() => {
|
||||
return route.fullPath;
|
||||
});
|
||||
|
||||
const { locale } = useI18n();
|
||||
const currentTabs = ref<RouteLocationNormalizedGeneric[]>();
|
||||
watch([() => tabbarStore.getTabs, () => tabbarStore.updateTime, () => locale.value], ([tabs]) => {
|
||||
currentTabs.value = tabs.map((item: any) => wrapperTabLocale(item));
|
||||
});
|
||||
|
||||
/**
|
||||
* 初始化固定标签页
|
||||
*/
|
||||
const initAffixTabs = () => {
|
||||
const affixTabs = filterTree(router.getRoutes(), (route: any) => {
|
||||
return !!route.meta?.affixTab;
|
||||
});
|
||||
tabbarStore.setAffixTabs(affixTabs);
|
||||
};
|
||||
|
||||
// 点击tab,跳转路由
|
||||
const handleClick = (key: string) => {
|
||||
router.push(key);
|
||||
};
|
||||
|
||||
// 关闭tab
|
||||
const handleClose = async (key: string) => {
|
||||
await closeTabByKey(key);
|
||||
};
|
||||
|
||||
function wrapperTabLocale(tab: RouteLocationNormalizedGeneric) {
|
||||
return {
|
||||
...tab,
|
||||
meta: {
|
||||
...tab?.meta,
|
||||
title: $t(tab?.meta?.title as string)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => accessStore.accessMenus,
|
||||
() => {
|
||||
initAffixTabs();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
const meta = route.matched?.[route.matched.length - 1]?.meta;
|
||||
tabbarStore.addTab({
|
||||
...route,
|
||||
meta: meta || route.meta
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const createContextMenus = (tab: TabDefinition) => {
|
||||
const { disabledCloseAll, disabledCloseCurrent, disabledCloseLeft, disabledCloseOther, disabledCloseRight, disabledRefresh } = getTabDisableState(tab);
|
||||
|
||||
const affixTab = tab?.meta?.affixTab ?? false;
|
||||
|
||||
const menus: IContextMenuItem[] = [
|
||||
{
|
||||
disabled: disabledCloseCurrent,
|
||||
handler: async () => {
|
||||
await closeCurrentTab(tab);
|
||||
},
|
||||
icon: X,
|
||||
key: "close",
|
||||
text: $t("preferences.tabbar.contextMenu.close")
|
||||
},
|
||||
{
|
||||
handler: async () => {
|
||||
await toggleTabPin(tab);
|
||||
},
|
||||
icon: affixTab ? PinOff : Pin,
|
||||
key: "affix",
|
||||
text: affixTab ? $t("preferences.tabbar.contextMenu.unpin") : $t("preferences.tabbar.contextMenu.pin")
|
||||
},
|
||||
{
|
||||
handler: async () => {
|
||||
if (!contentIsMaximize.value) {
|
||||
await router.push(tab.fullPath);
|
||||
}
|
||||
toggleMaximize();
|
||||
},
|
||||
icon: contentIsMaximize.value ? Minimize2 : Fullscreen,
|
||||
key: contentIsMaximize.value ? "restore-maximize" : "maximize",
|
||||
text: contentIsMaximize.value ? $t("preferences.tabbar.contextMenu.restoreMaximize") : $t("preferences.tabbar.contextMenu.maximize")
|
||||
},
|
||||
{
|
||||
disabled: disabledRefresh,
|
||||
handler: refreshTab,
|
||||
icon: RotateCw,
|
||||
key: "reload",
|
||||
text: $t("preferences.tabbar.contextMenu.reload")
|
||||
},
|
||||
{
|
||||
handler: async () => {
|
||||
await openTabInNewWindow(tab);
|
||||
},
|
||||
icon: ExternalLink,
|
||||
key: "open-in-new-window",
|
||||
separator: true,
|
||||
text: $t("preferences.tabbar.contextMenu.openInNewWindow")
|
||||
},
|
||||
|
||||
{
|
||||
disabled: disabledCloseLeft,
|
||||
handler: async () => {
|
||||
await closeLeftTabs(tab);
|
||||
},
|
||||
icon: ArrowLeftToLine,
|
||||
key: "close-left",
|
||||
text: $t("preferences.tabbar.contextMenu.closeLeft")
|
||||
},
|
||||
{
|
||||
disabled: disabledCloseRight,
|
||||
handler: async () => {
|
||||
await closeRightTabs(tab);
|
||||
},
|
||||
icon: ArrowRightToLine,
|
||||
key: "close-right",
|
||||
separator: true,
|
||||
text: $t("preferences.tabbar.contextMenu.closeRight")
|
||||
},
|
||||
{
|
||||
disabled: disabledCloseOther,
|
||||
handler: async () => {
|
||||
await closeOtherTabs(tab);
|
||||
},
|
||||
icon: FoldHorizontal,
|
||||
key: "close-other",
|
||||
text: $t("preferences.tabbar.contextMenu.closeOther")
|
||||
},
|
||||
{
|
||||
disabled: disabledCloseAll,
|
||||
handler: closeAllTabs,
|
||||
icon: ArrowRightLeft,
|
||||
key: "close-all",
|
||||
text: $t("preferences.tabbar.contextMenu.closeAll")
|
||||
}
|
||||
];
|
||||
return menus;
|
||||
};
|
||||
|
||||
return {
|
||||
createContextMenus,
|
||||
currentActive,
|
||||
currentTabs,
|
||||
handleClick,
|
||||
handleClose
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user