feat: 升级前端框架,适配手机端

This commit is contained in:
xiaojunnuo
2025-03-06 21:11:07 +08:00
659 changed files with 37406 additions and 873 deletions
@@ -0,0 +1,7 @@
## layout
### header
- 支持N个自定义插槽,命名方式:header-right-nheader-left-n
- header-left-n ,排序方式:0-19 ,breadcrumb 21-x
- header-right-n ,排序方式:0-49global-search51-59theme-toggle61-69language-toggle71-79fullscreen81-89notification91-149user-dropdown151-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 foundplease 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
};
}