头部、侧边栏 样式调整
Frontend CI/CD / build (web-office) (push) Failing after 7s Details

This commit is contained in:
hujiale 2024-10-09 19:04:42 +08:00
parent fe47552c5a
commit 3e41e8ca50
31 changed files with 1664 additions and 40 deletions

View File

@ -39,6 +39,13 @@
"@vben/stores": "workspace:*",
"@vben/styles": "workspace:*",
"@vben/types": "workspace:*",
"@vben-core/form-ui": "workspace:*",
"@vben-core/layout-ui": "workspace:*",
"@vben-core/menu-ui": "workspace:*",
"@vben-core/popup-ui": "workspace:*",
"@vben-core/shadcn-ui": "workspace:*",
"@vben-core/shared": "workspace:*",
"@vben-core/tabs-ui": "workspace:*",
"@vben/utils": "workspace:*",
"@vueuse/core": "^11.0.3",
"ant-design-vue": "^4.2.3",

View File

@ -7,11 +7,11 @@ import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
import {
BasicLayout,
LockScreen,
Notification,
UserDropdown,
} from '@vben/layouts';
import { Notification } from './notification';
import { BasicLayout } from '#/layouts/basic/index'
import { preferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
@ -66,7 +66,7 @@ const menus = computed(() => [
});
},
icon: BookOpenText,
text: $t('widgets.document'),
text: '个人信息',
},
{
handler: () => {
@ -75,7 +75,7 @@ const menus = computed(() => [
});
},
icon: MdiGithub,
text: 'GitHub',
text: '切换租户',
},
{
handler: () => {
@ -84,7 +84,7 @@ const menus = computed(() => [
});
},
icon: CircleHelp,
text: $t('widgets.qa'),
text: '个人偏好',
},
]);
@ -107,29 +107,17 @@ function handleMakeAll() {
<template>
<BasicLayout @clear-preferences-and-logout="handleLogout">
<template #user-dropdown>
<UserDropdown
:avatar
:menus
:text="userStore.userInfo?.realName"
description="ann.vben@gmail.com"
tag-text="Pro"
@logout="handleLogout"
/>
<UserDropdown :avatar :menus :text="userStore.userInfo?.realName"
@logout="handleLogout" :hideLockLcreen="true" />
</template>
<template #notification>
<Notification
:dot="showDot"
:notifications="notifications"
@clear="handleNoticeClear"
@make-all="handleMakeAll"
/>
<Notification :dot="showDot" :notifications="notifications" @clear="handleNoticeClear" :icColor="'#fff'"
@make-all="handleMakeAll" />
</template>
<template #extra>
<AuthenticationLoginExpiredModal
v-model:open="accessStore.loginExpired"
:avatar
>
<AuthenticationLoginExpiredModal v-model:open="accessStore.loginExpired" :avatar>
<LoginForm />
</AuthenticationLoginExpiredModal>
</template>

View File

@ -0,0 +1,12 @@
<script lang="ts" setup>
import { VbenSpinner } from '@vben-core/shadcn-ui';
import { useContentSpinner } from './use-content-spinner';
defineOptions({ name: 'LayoutContentSpinner' });
const { spinning } = useContentSpinner();
</script>
<template>
<VbenSpinner :spinning="spinning" />
</template>

View File

@ -0,0 +1,107 @@
<script lang="ts" setup>
import type {
RouteLocationNormalizedLoaded,
RouteLocationNormalizedLoadedGeneric,
} from 'vue-router';
import { type VNode } from 'vue';
import { RouterView } from 'vue-router';
import { preferences, usePreferences } from '@vben/preferences';
import { storeToRefs, useTabbarStore } from '@vben/stores';
import { IFrameRouterView } from '@vben/layouts';
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,
) {
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>

View File

@ -0,0 +1,2 @@
export { default as LayoutContent } from './content.vue';
export { default as LayoutContentSpinner } from './content-spinner.vue';

View File

@ -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 };

View File

@ -0,0 +1,48 @@
<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>

View File

@ -0,0 +1 @@
export { default as Copyright } from './copyright.vue';

View File

@ -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>

View File

@ -0,0 +1 @@
export { default as LayoutFooter } from './footer.vue';

View File

@ -0,0 +1,149 @@
<script lang="ts" setup>
import { computed, useSlots } from 'vue';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
import { VbenFullScreen } from '@vben-core/shadcn-ui';
import {
GlobalSearch,
LanguageToggle,
PreferencesButton,
ThemeToggle,
} from '@vben/layouts';
interface Props {
/**
* Logo 主题
*/
theme?: string;
}
defineOptions({
name: 'LayoutHeader',
});
withDefaults(defineProps<Props>(), {
theme: 'light',
});
const emit = defineEmits<{ clearPreferencesAndLogout: [] }>();
const accessStore = useAccessStore();
const { globalSearchShortcutKey, preferencesButtonPosition } = usePreferences();
const slots = useSlots();
const rightSlots = computed(() => {
const list = [{ index: 100, name: 'user-dropdown' }];
if (preferences.widget.globalSearch) {
list.push({
index: 5,
name: 'global-search',
});
}
if (preferencesButtonPosition.value.header) {
list.push({
index: 10,
name: 'preferences',
});
}
if (preferences.widget.themeToggle) {
list.push({
index: 15,
name: 'theme-toggle',
});
}
if (preferences.widget.languageToggle) {
list.push({
index: 20,
name: 'language-toggle',
});
}
if (preferences.widget.fullscreen) {
list.push({
index: 25,
name: 'fullscreen',
});
}
if (preferences.widget.notification) {
list.push({
index: 30,
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: any[] = [];
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 < 5)"
:key="slot.name"
>
<slot :name="slot.name"></slot>
</template>
<div class="flex-center hidden lg:block">
<slot name="breadcrumb"></slot>
</div>
<template
v-for="slot in leftSlots.filter((item) => item.index > 5)"
:key="slot.name"
>
<slot :name="slot.name"></slot>
</template>
<div class="flex h-full min-w-0 flex-1 items-center">
<slot name="menu"></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>

View File

@ -0,0 +1 @@
export { default as LayoutHeader } from './header.vue';

View File

@ -0,0 +1 @@
export { default as BasicLayout } from './layout.vue';

View File

@ -0,0 +1,339 @@
<script lang="ts" setup>
import type { MenuRecordRaw } from '@vben/types';
import { computed, useSlots, watch } from 'vue';
import { useWatermark } from '@vben/hooks';
import { $t } from '@vben/locales';
import {
preferences,
updatePreferences,
usePreferences,
} from '@vben/preferences';
import { useLockStore, useUserStore } from '@vben/stores';
import { deepToRaw, mapTree } from '@vben/utils';
import { VbenAdminLayout } from '@vben-core/layout-ui';
import { Toaster, VbenBackTop, VbenLogo } from '@vben-core/shadcn-ui';
import { Breadcrumb, CheckUpdates, Preferences } from '@vben/layouts';
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,
layout,
preferencesButtonPosition,
sidebarCollapsed,
theme,
} = usePreferences();
const userStore = useUserStore();
const { updateWatermark } = useWatermark();
const lockStore = useLockStore();
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';
return '!bg-[#007da3]'
});
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) {
return false;
}
return sidebarCollapsed.value || isSideMixedNav.value;
});
const showHeaderNav = computed(() => {
return !isMobile.value && (isHeaderNav.value || isMixedNav.value);
});
//
const {
extraActiveMenu,
extraMenus,
handleDefaultSelect,
handleMenuMouseEnter,
handleMixedMenuSelect,
handleSideMouseLeave,
sidebarExtraVisible,
} = useExtraMenu();
const {
handleMenuSelect,
headerActive,
headerMenus,
sidebarActive,
sidebarMenus,
sidebarVisible,
} = useMixedMenu();
function wrapperMenus(menus: MenuRecordRaw[]) {
return mapTree(menus, (item) => {
return { ...deepToRaw(item), name: $t(item.name) };
});
}
function toggleSidebar() {
updatePreferences({
sidebar: {
hidden: !preferences.sidebar.hidden,
},
});
}
function clearPreferencesAndLogout() {
emit('clearPreferencesAndLogout');
}
watch(
() => preferences.app.watermark,
async (val) => {
if (val) {
await updateWatermark({
content: `${userStore.userInfo?.username}`,
});
}
},
{
immediate: true,
},
);
const 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"
:header-height="preferences.header.height"
: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="'西北油田办公平台'"
:theme="'!white'"
:textClass="'text-white text-2xl'"
/>
</template>
<!-- 头部区域 -->
<template #header>
<LayoutHeader
:theme="theme"
@clear-preferences-and-logout="clearPreferencesAndLogout"
>
<template
v-if="!showHeaderNav && preferences.breadcrumb.enable"
#breadcrumb
>
<Breadcrumb
v-if="preferences.app.layout !== 'sidebar-topbar'"
: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"
@select="handleMenuSelect"
/>
</template>
<template #mixed-menu>
<!-- :collapse="!preferences.sidebar.collapsedShowTitle" -->
<LayoutMixedMenu
:active-path="extraActiveMenu"
:menus="wrapperMenus(headerMenus)"
: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"
/>
</LayoutFooter>
</template>
<template #extra>
<slot name="extra"></slot>
<Toaster />
<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>

View File

@ -0,0 +1,40 @@
<script lang="ts" setup>
import type { MenuRecordRaw } from '@vben/types';
import type { MenuProps } from '@vben-core/menu-ui';
import { useRoute } from 'vue-router';
import { Menu } from '@vben-core/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>

View File

@ -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';

View File

@ -0,0 +1,37 @@
<script lang="ts" setup>
import type { MenuRecordRaw } from '@vben/types';
import type { MenuProps } from '@vben-core/menu-ui';
import { Menu } from '@vben-core/menu-ui';
interface Props extends MenuProps {
menus: MenuRecordRaw[];
}
const props = withDefaults(defineProps<Props>(), {
accordion: true,
menus: () => [],
});
const emit = defineEmits<{
select: [string, string?];
}>();
function handleMenuSelect(key: string) {
emit('select', key, props.mode);
}
</script>
<template>
<Menu
:accordion="accordion"
:collapse="collapse"
:collapse-show-title="collapseShowTitle"
:default-active="defaultActive"
:menus="menus"
:mode="mode"
:rounded="rounded"
:theme="theme"
@select="handleMenuSelect"
/>
</template>

View File

@ -0,0 +1,37 @@
<script lang="ts" setup>
import type { MenuRecordRaw } from '@vben/types';
import type { NormalMenuProps } from '@vben-core/menu-ui';
import { onBeforeMount } from 'vue';
import { useRoute } from 'vue-router';
import { findMenuByPath } from '@vben/utils';
import { NormalMenu } from '@vben-core/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>

View File

@ -0,0 +1,109 @@
import type { MenuRecordRaw } from '@vben/types';
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { preferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
import { findRootMenuByPath } from '@vben/utils';
import { useNavigation } from './use-navigation';
function useExtraMenu() {
const accessStore = useAccessStore();
const { navigation } = useNavigation();
const menus = computed(() => accessStore.accessMenus);
const route = useRoute();
const extraMenus = ref<MenuRecordRaw[]>([]);
const sidebarExtraVisible = ref<boolean>(false);
const extraActiveMenu = ref('');
/**
*
* @param menu
*/
const handleMixedMenuSelect = async (menu: MenuRecordRaw) => {
extraMenus.value = menu?.children ?? [];
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
const hasChildren = extraMenus.value.length > 0;
sidebarExtraVisible.value = hasChildren;
if (!hasChildren) {
await navigation(menu.path);
}
};
/**
*
* @param menu
* @param rootMenu
*/
const handleDefaultSelect = (
menu: MenuRecordRaw,
rootMenu?: MenuRecordRaw,
) => {
extraMenus.value = rootMenu?.children ?? [];
extraActiveMenu.value = menu.parents?.[0] ?? menu.path;
if (preferences.sidebar.expandOnHover) {
sidebarExtraVisible.value = extraMenus.value.length > 0;
}
};
/**
*
*/
const handleSideMouseLeave = () => {
if (preferences.sidebar.expandOnHover) {
return;
}
sidebarExtraVisible.value = false;
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?.[0] ?? menu.path;
sidebarExtraVisible.value = extraMenus.value.length > 0;
}
};
watch(
() => route.path,
(path) => {
const currentPath = route.meta?.activePath || path;
// if (preferences.sidebar.expandOnHover) {
// return;
// }
const { findMenu, rootMenu, rootMenuPath } = findRootMenuByPath(
menus.value,
currentPath,
);
extraActiveMenu.value = rootMenuPath ?? findMenu?.path ?? '';
extraMenus.value = rootMenu?.children ?? [];
},
{ immediate: true },
);
return {
extraActiveMenu,
extraMenus,
handleDefaultSelect,
handleMenuMouseEnter,
handleMixedMenuSelect,
handleSideMouseLeave,
sidebarExtraVisible,
};
}
export { useExtraMenu };

View File

@ -0,0 +1,129 @@
import type { MenuRecordRaw } from '@vben/types';
import { computed, onBeforeMount, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore } from '@vben/stores';
import { findRootMenuByPath } from '@vben/utils';
import { useNavigation } from './use-navigation';
function useMixedMenu() {
const { navigation } = useNavigation();
const accessStore = useAccessStore();
const route = useRoute();
const splitSideMenus = ref<MenuRecordRaw[]>([]);
const rootMenuPath = ref<string>('');
const { isMixedNav } = usePreferences();
const needSplit = computed(
() => preferences.navigation.split && isMixedNav.value,
);
const sidebarVisible = computed(() => {
const enableSidebar = preferences.sidebar.enable;
if (needSplit.value) {
return enableSidebar && splitSideMenus.value.length > 0;
}
return enableSidebar;
});
const menus = computed(() => accessStore.accessMenus);
/**
*
*/
const headerMenus = computed(() => {
if (!needSplit.value) {
return menus.value;
}
return menus.value.map((item) => {
return {
...item,
children: [],
};
});
});
/**
*
*/
const sidebarMenus = computed(() => {
return needSplit.value ? splitSideMenus.value : menus.value;
});
/**
*
*/
const sidebarActive = computed(() => {
return (route?.meta?.activePath as string) ?? route.path;
});
/**
*
*/
const headerActive = computed(() => {
if (!needSplit.value) {
return route.path;
}
return rootMenuPath.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 ?? [];
if (splitSideMenus.value.length === 0) {
navigation(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);
}
rootMenuPath.value = rootMenu?.path ?? '';
splitSideMenus.value = rootMenu?.children ?? [];
}
watch(
() => route.path,
(path) => {
const currentPath = (route?.meta?.activePath as string) ?? path;
calcSideMenus(currentPath);
},
{ immediate: true },
);
// 初始化计算侧边菜单
onBeforeMount(() => {
calcSideMenus(route.meta?.activePath || route.path);
});
return {
handleMenuSelect,
headerActive,
headerMenus,
sidebarActive,
sidebarMenus,
sidebarVisible,
};
}
export { useMixedMenu };

View File

@ -0,0 +1,19 @@
import { useRouter } from 'vue-router';
import { isHttpUrl, openWindow } from '@vben/utils';
function useNavigation() {
const router = useRouter();
const navigation = async (path: string) => {
if (isHttpUrl(path)) {
openWindow(path, { target: '_blank' });
} else {
await router.push(path);
}
};
return { navigation };
}
export { useNavigation };

View File

@ -0,0 +1,2 @@
export { default as LayoutTabbar } from './tabbar.vue';
export * from './use-tabbar';

View File

@ -0,0 +1,81 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useContentMaximize, useTabs } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import { useTabbarStore } from '@vben/stores';
import {
TabsToolMore,
TabsToolRefresh,
TabsToolScreen,
TabsView,
} from '@vben-core/tabs-ui';
import { useTabbar } from './use-tabbar';
defineOptions({
name: 'LayoutTabbar',
});
defineProps<{ showIcon?: boolean; theme?: string }>();
const route = useRoute();
const tabbarStore = useTabbarStore();
const { toggleMaximize } = useContentMaximize();
const { refreshTab, 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,
};
});
});
// tabtab
if (!preferences.tabbar.persist) {
tabbarStore.closeOtherTabs(route);
}
</script>
<template>
<TabsView
:active="currentActive"
:class="theme"
:context-menus="createContextMenus"
:dragable="preferences.tabbar.dragable"
:show-icon="showIcon"
:style-type="preferences.tabbar.styleType"
:tabs="currentTabs"
@close="handleClose"
@sort-tabs="tabbarStore.sortTabs"
@unpin="unpinTab"
@update:active="handleClick"
/>
<div class="flex-center h-full">
<TabsToolRefresh
v-if="preferences.tabbar.showRefresh"
@refresh="refreshTab"
/>
<TabsToolMore v-if="preferences.tabbar.showMore" :menus="menus" />
<TabsToolScreen
v-if="preferences.tabbar.showMaximize"
:screen="preferences.sidebar.hidden"
@change="toggleMaximize"
@update:screen="toggleMaximize"
/>
</div>
</template>

View File

@ -0,0 +1,220 @@
import type { TabDefinition } from '@vben/types';
import type { IContextMenuItem } from '@vben-core/tabs-ui';
import type { RouteLocationNormalizedGeneric } from 'vue-router';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useContentMaximize, useTabs } from '@vben/hooks';
import {
ArrowLeftToLine,
ArrowRightLeft,
ArrowRightToLine,
ExternalLink,
FoldHorizontal,
Fullscreen,
Minimize2,
Pin,
PinOff,
RotateCw,
X,
} from '@vben/icons';
import { $t, useI18n } from '@vben/locales';
import { useAccessStore, useTabbarStore } from '@vben/stores';
import { filterTree } from '@vben/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) => wrapperTabLocale(item));
},
);
/**
*
*/
const initAffixTabs = () => {
const affixTabs = filterTree(router.getRoutes(), (route) => {
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,
};
}

View File

@ -0,0 +1 @@
<template></template>

View File

@ -0,0 +1,3 @@
export { default as Notification } from './notification.vue';
export type * from './types';

View File

@ -0,0 +1,184 @@
<script lang="ts" setup>
import type { NotificationItem } from './types';
import { Bell, MailCheck } from '@vben/icons';
import { $t } from '@vben/locales';
import {
VbenButton,
VbenIconButton,
VbenPopover,
VbenScrollbar,
} from '@vben-core/shadcn-ui';
import { useToggle } from '@vueuse/core';
interface Props {
/**
* 显示圆点
*/
dot?: boolean;
/**
* 消息列表
*/
notifications?: NotificationItem[];
/**
* 图标颜色
*/
icColor?:string;
}
defineOptions({ name: 'NotificationPopup' });
withDefaults(defineProps<Props>(), {
dot: false,
notifications: () => [],
});
const emit = defineEmits<{
clear: [];
makeAll: [];
read: [NotificationItem];
viewAll: [];
}>();
const [open, toggle] = useToggle();
function close() {
open.value = false;
}
function handleViewAll() {
emit('viewAll');
close();
}
function handleMakeAll() {
emit('makeAll');
}
function handleClear() {
emit('clear');
}
function handleClick(item: NotificationItem) {
emit('read', item);
}
</script>
<template>
<VbenPopover
v-model:open="open"
content-class="relative right-2 w-[360px] p-0"
>
<template #trigger>
<div class="flex-center mr-2 h-full" @click.stop="toggle()">
<VbenIconButton class="bell-button text-white relative hover:text-[#007da3]">
<span
v-if="dot"
class="bg-[#ef4444] absolute right-0.5 top-0.5 h-2 w-2 rounded"
></span>
<Bell class="size-4 hover:text-[#007da3]" />
</VbenIconButton>
</div>
</template>
<div class="relative">
<div class="flex items-center justify-between p-4 py-3">
<div class="text-foreground">{{ $t('widgets.notifications') }}</div>
<VbenIconButton
:tooltip="$t('widgets.markAllAsRead')"
@click="handleMakeAll"
>
<MailCheck class="size-4" />
</VbenIconButton>
</div>
<VbenScrollbar v-if="notifications.length > 0">
<ul class="!flex max-h-[360px] w-full flex-col">
<template v-for="item in notifications" :key="item.title">
<li
class="hover:bg-accent border-border relative flex w-full cursor-pointer items-start gap-5 border-t px-3 py-3"
@click="handleClick(item)"
>
<span
v-if="!item.isRead"
class="bg-primary absolute right-2 top-2 h-2 w-2 rounded"
></span>
<span
class="relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full"
>
<img
:src="item.avatar"
class="aspect-square h-full w-full object-cover"
role="img"
/>
</span>
<div class="flex flex-col gap-1 leading-none">
<p class="font-semibold">{{ item.title }}</p>
<p class="text-muted-foreground my-1 line-clamp-2 text-xs">
{{ item.message }}
</p>
<p class="text-muted-foreground line-clamp-2 text-xs">
{{ item.date }}
</p>
</div>
</li>
</template>
</ul>
</VbenScrollbar>
<template v-else>
<div class="flex-center text-muted-foreground min-h-[150px] w-full">
{{ $t('common.noData') }}
</div>
</template>
<div
class="border-border flex items-center justify-between border-t px-4 py-3"
>
<VbenButton size="sm" variant="ghost" @click="handleClear">
{{ $t('widgets.clearNotifications') }}
</VbenButton>
<VbenButton size="sm" @click="handleViewAll">
{{ $t('widgets.viewAll') }}
</VbenButton>
</div>
</div>
</VbenPopover>
</template>
<style scoped>
:deep(.bell-button) {
&:hover {
svg {
animation: bell-ring 1s both;
}
}
}
@keyframes bell-ring {
0%,
100% {
transform-origin: top;
}
15% {
transform: rotateZ(10deg);
}
30% {
transform: rotateZ(-10deg);
}
45% {
transform: rotateZ(5deg);
}
60% {
transform: rotateZ(-5deg);
}
75% {
transform: rotateZ(2deg);
}
}
</style>

View File

@ -0,0 +1,9 @@
interface NotificationItem {
avatar: string;
date: string;
isRead?: boolean;
message: string;
title: string;
}
export type { NotificationItem };

View File

@ -21,7 +21,7 @@ export const overridesPreferences = defineOverridesPreferences({
// 检查更新的时间间隔,单位为分钟
checkUpdatesInterval: 1,
// 开启布局设置按钮
enablePreferences: false,
enablePreferences: true,
enableRefreshToken: false,
isMobile: false,
layout: 'sidebar-topbar',
@ -30,4 +30,7 @@ export const overridesPreferences = defineOverridesPreferences({
preferencesButtonPosition: 'auto',
watermark: false,
},
header: {
height: 60,
},
});

View File

@ -8,7 +8,7 @@ const routes: RouteRecordRaw[] = [
meta: {
icon: 'lucide:layout-dashboard',
order: -1,
title: '首页',
title: '总部系统',
},
name: 'Dashboard',
path: '/home',
@ -18,22 +18,29 @@ const routes: RouteRecordRaw[] = [
path: '/home',
component: () => import('#/views/dashboard/home/index.vue'),
meta: {
affixTab: true,
hideInMenu: true,
icon: 'lucide:area-chart',
title: '首页',
title: '总部系统',
},
},
{
name: 'zshxgw',
path: '',
component: () => null,
meta: {
title: '中石化新公文系统',
link: 'http://www.baidu.com'
},
},
{
name: 'zshht',
path: '',
component: () => null,
meta: {
title: '中石化合同系统',
link: 'http://www.baidu.com'
},
},
// {
// name: 'Analytics',
// path: '/analytics',
// component: () => import('#/views/dashboard/analytics/index.vue'),
// meta: {
// affixTab: true,
// icon: 'lucide:area-chart',
// title: $t('page.dashboard.analytics'),
// },
// },
// {
// name: 'Workspace',
// path: '/workspace',

View File

@ -609,6 +609,27 @@ importers:
apps/web-test:
dependencies:
'@vben-core/form-ui':
specifier: workspace:*
version: link:../../packages/@core/ui-kit/form-ui
'@vben-core/layout-ui':
specifier: workspace:*
version: link:../../packages/@core/ui-kit/layout-ui
'@vben-core/menu-ui':
specifier: workspace:*
version: link:../../packages/@core/ui-kit/menu-ui
'@vben-core/popup-ui':
specifier: workspace:*
version: link:../../packages/@core/ui-kit/popup-ui
'@vben-core/shadcn-ui':
specifier: workspace:*
version: link:../../packages/@core/ui-kit/shadcn-ui
'@vben-core/shared':
specifier: workspace:*
version: link:../../packages/@core/base/shared
'@vben-core/tabs-ui':
specifier: workspace:*
version: link:../../packages/@core/ui-kit/tabs-ui
'@vben/access':
specifier: workspace:*
version: link:../../packages/effects/access
@ -13962,10 +13983,10 @@ snapshots:
'@interactjs/feedback@1.10.2': {}
'@interactjs/inertia@1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/modifiers@1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/utils@1.10.2))(@interactjs/utils@1.10.2)':
'@interactjs/inertia@1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/modifiers@1.10.2(@interactjs/core@1.10.27(@interactjs/utils@1.10.27))(@interactjs/utils@1.10.27))(@interactjs/utils@1.10.2)':
dependencies:
'@interactjs/core': 1.10.2(@interactjs/utils@1.10.2)
'@interactjs/modifiers': 1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/utils@1.10.2)
'@interactjs/modifiers': 1.10.2(@interactjs/core@1.10.27(@interactjs/utils@1.10.27))(@interactjs/utils@1.10.27)
'@interactjs/offset': 1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/utils@1.10.2)
'@interactjs/utils': 1.10.2
optionalDependencies:
@ -14001,7 +14022,7 @@ snapshots:
'@interactjs/core': 1.10.2(@interactjs/utils@1.10.2)
'@interactjs/dev-tools': 1.10.2
'@interactjs/feedback': 1.10.2
'@interactjs/inertia': 1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/modifiers@1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/utils@1.10.2))(@interactjs/utils@1.10.2)
'@interactjs/inertia': 1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/modifiers@1.10.2(@interactjs/core@1.10.27(@interactjs/utils@1.10.27))(@interactjs/utils@1.10.27))(@interactjs/utils@1.10.2)
'@interactjs/interact': 1.10.2
'@interactjs/modifiers': 1.10.2(@interactjs/core@1.10.2(@interactjs/utils@1.10.2))(@interactjs/utils@1.10.2)
'@interactjs/multi-target': 1.10.2