feat(layout): 对齐 Vben 5 布局与菜单路由行为

- 补齐六种 Vben 布局模式及设置面板入口
- 支持顶部根菜单、侧边 split 菜单、混合布局与双列菜单联动
- 支持菜单路由 query/hash/params、动态路径与登录重定向保参
- 外链路由唯一化,并支持 iframe 外链页面
- 调整设置入口、面包屑与折叠按钮展示逻辑
- 修复水平菜单更多弹层,仅展示溢出根菜单并避免原生弹层重复
- 新增布局路由与交互自测脚本
master
YunaiV 2026-06-12 15:11:13 +08:00
parent 394a3d075a
commit 74aaa6605e
27 changed files with 1382 additions and 273 deletions

View File

@ -7,6 +7,7 @@ import { useWindowSize } from '@vueuse/core'
import { useAppStore } from '@/store/modules/app'
import { setCssVar } from '@/utils'
import { useDesign } from '@/hooks/web/useDesign'
import { normalizeLayout } from '@/utils/layout'
const { variables } = useDesign()
@ -33,7 +34,9 @@ watch(
!appStore.getMobile ? appStore.setMobile(true) : undefined
setCssVar('--left-menu-min-width', '0')
appStore.setCollapse(true)
appStore.getLayout !== 'classic' ? appStore.setLayout('classic') : undefined
normalizeLayout(appStore.getLayout) !== 'sidebar-nav'
? appStore.setLayout('sidebar-nav')
: undefined
} else {
appStore.getMobile ? appStore.setMobile(false) : undefined
setCssVar('--left-menu-min-width', '64px')

View File

@ -7,7 +7,7 @@ const props = defineProps({
src: propTypes.string.def('')
})
const loading = ref(true)
const frameRef = ref<HTMLElement | null>(null)
const frameRef = ref<HTMLIFrameElement | null>(null)
const init = () => {
nextTick(() => {
loading.value = true
@ -20,6 +20,11 @@ const init = () => {
onMounted(() => {
init()
})
onBeforeUnmount(() => {
if (!frameRef.value) return
frameRef.value.onload = null
frameRef.value.src = 'about:blank'
})
watch(
() => props.src,
() => {

View File

@ -5,6 +5,7 @@ import { Backtop } from '@/components/Backtop'
import { Setting } from '@/layout/components/Setting'
import { useRenderLayout } from './components/useRenderLayout'
import { useDesign } from '@/hooks/web/useDesign'
import { getLayoutRenderMode } from '@/utils/layout'
const { getPrefixCls } = useDesign()
@ -25,7 +26,7 @@ const handleClickOutside = () => {
}
const renderLayout = () => {
switch (unref(layout)) {
switch (getLayoutRenderMode(unref(layout))) {
case 'classic':
const { renderClassic } = useRenderLayout()
return renderClassic()
@ -47,7 +48,14 @@ export default defineComponent({
name: 'Layout',
setup() {
return () => (
<section class={[prefixCls, `${prefixCls}__${layout.value}`, 'w-[100%] h-[100%] relative']}>
<section
class={[
prefixCls,
`${prefixCls}__${layout.value}`,
`${prefixCls}__${getLayoutRenderMode(layout.value)}`,
'w-[100%] h-[100%] relative'
]}
>
{mobile.value && !collapse.value ? (
<div
class="absolute left-0 top-0 z-99 h-full w-full bg-[var(--el-color-black)] opacity-30"

View File

@ -2,6 +2,7 @@
import { computed, onMounted, ref, unref, watch } from 'vue'
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
import { getLayoutRenderMode, isHeaderNavLayout } from '@/utils/layout'
defineOptions({ name: 'Logo' })
@ -26,7 +27,7 @@ onMounted(() => {
watch(
() => collapse.value,
(collapse: boolean) => {
if (unref(layout) === 'topLeft' || unref(layout) === 'cutMenu') {
if (getLayoutRenderMode(unref(layout)) === 'topLeft' || getLayoutRenderMode(unref(layout)) === 'cutMenu') {
show.value = true
return
}
@ -43,7 +44,8 @@ watch(
watch(
() => layout.value,
(layout) => {
if (layout === 'top' || layout === 'cutMenu') {
const renderMode = getLayoutRenderMode(layout)
if (renderMode === 'top' || renderMode === 'cutMenu') {
show.value = true
} else {
if (unref(collapse)) {
@ -61,7 +63,7 @@ watch(
<router-link
:class="[
prefixCls,
layout !== 'classic' ? `${prefixCls}__Top` : '',
getLayoutRenderMode(layout) !== 'classic' ? `${prefixCls}__Top` : '',
'flex !h-[var(--logo-height)] items-center cursor-pointer pl-8px relative decoration-none overflow-hidden'
]"
to="/"
@ -75,9 +77,11 @@ watch(
:class="[
'ml-10px text-16px font-700',
{
'text-[var(--logo-title-text-color)]': layout === 'classic',
'text-[var(--logo-title-text-color)]': getLayoutRenderMode(layout) === 'classic',
'text-[var(--top-header-text-color)]':
layout === 'topLeft' || layout === 'top' || layout === 'cutMenu'
getLayoutRenderMode(layout) === 'topLeft' ||
isHeaderNavLayout(layout) ||
getLayoutRenderMode(layout) === 'cutMenu'
}
]"
>

View File

@ -1,12 +1,26 @@
<script lang="tsx">
import { PropType } from 'vue'
import { PropType, nextTick, onBeforeUnmount, onMounted } from 'vue'
import { ElMenu, ElScrollbar } from 'element-plus'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
import { useRenderMenuItem } from './components/useRenderMenuItem'
import { hasOneShowingChild } from './helper'
import { isUrl } from '@/utils/is'
import { useDesign } from '@/hooks/web/useDesign'
import { LayoutType } from '@/types/layout'
import { createRouteLocation, resolveDynamicPath } from '@/utils/routeParams'
import {
isHeaderNavLayout,
isHorizontalMenuLayout,
isTwoColumnLayout
} from '@/utils/layout'
import { cloneDeep } from 'lodash-es'
import { pathResolve } from '@/utils/routerHelper'
import {
findRouteByPath,
getRootMenuActivePath,
getRootMenuRoute,
normalizeMenuTargetPath
} from './menuRoute'
const { getPrefixCls } = useDesign()
@ -15,9 +29,29 @@ const prefixCls = getPrefixCls('menu')
export default defineComponent({
name: 'Menu',
props: {
mode: {
type: String as PropType<'horizontal' | 'vertical'>,
default: undefined
},
menuSelect: {
type: Function as PropType<(index: string) => void>,
default: undefined
},
rootOnly: {
type: Boolean,
default: false
},
theme: {
type: String as PropType<'header' | 'menu'>,
default: 'menu'
},
split: {
type: Boolean,
default: false
},
menus: {
type: Array as PropType<AppRouteRecordRaw[]>,
default: undefined
}
},
setup(props) {
@ -25,39 +59,316 @@ export default defineComponent({
const layout = computed(() => appStore.getLayout)
const { push, currentRoute } = useRouter()
const { push, currentRoute, resolve } = useRouter()
const { t } = useI18n()
const permissionStore = usePermissionStore()
const menuMode = computed((): 'vertical' | 'horizontal' => {
//
const vertical: LayoutType[] = ['classic', 'topLeft', 'cutMenu']
const menuWrapRef = ref<HTMLElement>()
if (vertical.includes(unref(layout))) {
return 'vertical'
} else {
return 'horizontal'
}
})
const menuRef = ref<InstanceType<typeof ElMenu>>()
const routers = computed(() =>
unref(layout) === 'cutMenu' ? permissionStore.getMenuTabRouters : permissionStore.getRouters
const menuMode = computed((): 'vertical' | 'horizontal' =>
props.mode || (isHorizontalMenuLayout(unref(layout)) ? 'horizontal' : 'vertical')
)
const collapse = computed(() => appStore.getCollapse)
const uniqueOpened = computed(() => appStore.getUniqueOpened)
const horizontalOverflowOpened = ref(false)
const horizontalOverflowStyle = ref<Record<string, string>>({})
const horizontalOverflowRoutes = ref<AppRouteRecordRaw[]>([])
const getRootOnlyMenuRoute = (route: AppRouteRecordRaw): AppRouteRecordRaw => {
const firstVisibleChild = route.children?.find((child) => !child.meta?.hidden)
const meta =
route.meta?.title || !firstVisibleChild
? route.meta
: {
...route.meta,
icon: firstVisibleChild.meta?.icon,
title: firstVisibleChild.meta?.title
}
return {
...route,
children: undefined,
meta
}
}
const routers = computed(() => {
const sourceRouters =
props.menus || (props.split ? permissionStore.getMenuTabRouters : permissionStore.getRouters)
if (!props.rootOnly) {
return sourceRouters
}
return sourceRouters.map(getRootOnlyMenuRoute)
})
const activeMenu = computed(() => {
const { meta, path } = unref(currentRoute)
const currentPath = (meta.activeMenu as string) || path
if (props.rootOnly) {
return permissionStore.getMenuRootPath || getRootMenuActivePath(permissionStore.getRouters, currentPath)
}
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu as string
return normalizeMenuTargetPath(meta.activeMenu as string)
}
return path
})
const menuBgColor = computed(() =>
props.theme === 'header' ? 'var(--top-header-bg-color)' : 'var(--left-menu-bg-color)'
)
const menuTextColor = computed(() =>
props.theme === 'header' ? 'var(--top-header-text-color)' : 'var(--left-menu-text-color)'
)
const menuActiveTextColor = computed(() =>
props.theme === 'header' ? 'var(--el-color-primary)' : 'var(--left-menu-text-active-color)'
)
const getFirstChildPath = (route: AppRouteRecordRaw, parentPath: string): string | undefined => {
const firstChild = route.children?.find((child) => !child.meta?.hidden)
if (!firstChild) {
return undefined
}
const childPath = pathResolve(parentPath, firstChild.path)
if (firstChild.redirect) {
return firstChild.redirect as string
}
if (firstChild.children?.length) {
return getFirstChildPath(firstChild, childPath) || childPath
}
return childPath
}
const isSubMenuRoute = (route: AppRouteRecordRaw) => {
const children = route.children || []
const { oneShowingChild, onlyOneChild } = hasOneShowingChild(children, route)
return (
!!children.length &&
!(
oneShowingChild &&
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
!route.meta?.alwaysShow
)
)
}
const findOpenMenuPaths = (
routes: AppRouteRecordRaw[],
targetPath: string,
parentPath = '/',
parents: string[] = []
): string[] => {
for (const route of routes) {
if (route.meta?.hidden) {
continue
}
const fullPath = isUrl(route.path) ? route.path : pathResolve(parentPath, route.path)
const resolvedFullPath = isUrl(fullPath)
? fullPath
: resolveDynamicPath(fullPath, route.meta?.params as Record<string, any>)
const nextParents = isSubMenuRoute(route) ? [...parents, resolvedFullPath] : parents
const matchedSelf = fullPath === targetPath || resolvedFullPath === targetPath
const matchedChild = route.children?.length
? findOpenMenuPaths(route.children, targetPath, fullPath, nextParents)
: []
if (matchedChild.length) {
return matchedChild
}
if (
matchedSelf ||
(!isUrl(resolvedFullPath) &&
resolvedFullPath !== '/' &&
targetPath.startsWith(`${resolvedFullPath}/`))
) {
return nextParents
}
}
return []
}
const defaultOpeneds = computed(() => {
if (unref(menuMode) === 'horizontal') {
return []
}
if (props.rootOnly) {
return []
}
return findOpenMenuPaths(unref(routers), normalizeMenuTargetPath(unref(activeMenu)))
})
let lastAutoOpeneds: string[] = []
const warnMenuOpenError = (action: 'close' | 'open', path: string, error: unknown) => {
if (!import.meta.env.DEV) {
return
}
console.warn(`[Menu] Failed to ${action} submenu "${path}"`, error)
}
const setSplitMenus = (targetPath: string) => {
const rootInfo = getRootMenuRoute(permissionStore.getRouters, targetPath)
const rootPath = rootInfo?.fullPath || getRootMenuActivePath(permissionStore.getRouters, targetPath)
const rootRoute = rootInfo?.route
const children = rootRoute?.children?.length
? cloneDeep(rootRoute.children).map((child) => {
child.path = pathResolve(rootPath, child.path)
return child
})
: []
permissionStore.setMenuRootPath(rootPath)
permissionStore.setMenuTabRouters(children)
}
watch(
() =>
[
(unref(currentRoute).meta.activeMenu as string) || unref(currentRoute).path,
permissionStore.getRouters,
props.rootOnly,
props.split
] as const,
([path, _routers, rootOnly, split]) => {
if (split) {
setSplitMenus(path)
} else if (rootOnly) {
permissionStore.setMenuRootPath(getRootMenuActivePath(permissionStore.getRouters, path))
}
},
{
immediate: true
}
)
const syncDefaultOpeneds = () => {
if (unref(menuMode) === 'horizontal' || props.rootOnly) {
lastAutoOpeneds = []
return
}
nextTick(() => {
const menu = unref(menuRef)
if (!menu) {
return
}
const nextOpeneds = unref(defaultOpeneds)
if (unref(uniqueOpened)) {
lastAutoOpeneds
.filter((path) => !nextOpeneds.includes(path))
.forEach((path) => {
try {
menu.close(path)
} catch (error) {
warnMenuOpenError('close', path, error)
}
})
}
nextOpeneds.forEach((path) => {
try {
menu.open(path)
} catch (error) {
warnMenuOpenError('open', path, error)
}
})
lastAutoOpeneds = [...nextOpeneds]
})
}
watch(
() =>
[
unref(menuMode),
unref(collapse),
unref(defaultOpeneds).join(','),
unref(uniqueOpened)
] as const,
syncDefaultOpeneds,
{
flush: 'post',
immediate: true
}
)
const scrollActiveHorizontalMenuIntoView = () => {
if (unref(menuMode) !== 'horizontal') {
return
}
nextTick(() => {
const wrapper = unref(menuWrapRef)
const activeItem = wrapper?.querySelector<HTMLElement>(
'.el-menu--horizontal > .el-menu-item.is-active,.el-menu--horizontal > .el-sub-menu.is-active'
)
if (!wrapper || !activeItem) {
return
}
const wrapperRect = wrapper.getBoundingClientRect()
const itemRect = activeItem.getBoundingClientRect()
if (itemRect.left < wrapperRect.left) {
wrapper.scrollLeft -= wrapperRect.left - itemRect.left
} else if (itemRect.right > wrapperRect.right) {
wrapper.scrollLeft += itemRect.right - wrapperRect.right
}
})
}
watch(
() => [unref(activeMenu), unref(menuMode), unref(routers)] as const,
scrollActiveHorizontalMenuIntoView,
{
flush: 'post',
immediate: true
}
)
const openHorizontalOverflowMenu = (event: Event) => {
if (unref(menuMode) !== 'horizontal') {
return
}
const target = event.target as HTMLElement | null
const trigger = target?.closest<HTMLElement>('.el-sub-menu__hide-arrow')
if (!trigger) {
return
}
if (event.type === 'click') {
event.preventDefault()
event.stopPropagation()
}
const rect = trigger.getBoundingClientRect()
horizontalOverflowRoutes.value = getHorizontalOverflowRoutes()
horizontalOverflowStyle.value = {
left: `${rect.left}px`,
minWidth: '200px',
position: 'fixed',
right: 'auto',
top: `${rect.bottom + 6}px`,
zIndex: '5000'
}
horizontalOverflowOpened.value = true
}
onMounted(() => {
const wrapper = unref(menuWrapRef)
wrapper?.addEventListener('click', openHorizontalOverflowMenu, true)
wrapper?.addEventListener('mouseover', openHorizontalOverflowMenu, true)
})
onBeforeUnmount(() => {
const wrapper = unref(menuWrapRef)
wrapper?.removeEventListener('click', openHorizontalOverflowMenu, true)
wrapper?.removeEventListener('mouseover', openHorizontalOverflowMenu, true)
})
const menuSelect = (index: string) => {
horizontalOverflowOpened.value = false
if (props.menuSelect) {
props.menuSelect(index)
}
@ -65,12 +376,34 @@ export default defineComponent({
if (isUrl(index)) {
window.open(index)
} else {
push(index)
const routeInfo = findRouteByPath(permissionStore.getRouters, index, '/', !props.rootOnly)
const link = routeInfo?.route.meta?.link
if (typeof link === 'string') {
window.open(link)
return
}
const targetPath =
props.rootOnly && routeInfo?.route.children?.length
? ((routeInfo.route.redirect as string) || getFirstChildPath(routeInfo.route, routeInfo.fullPath))
: index
if (targetPath) {
const targetRouteInfo = findRouteByPath(permissionStore.getRouters, targetPath)
const targetLocation = createRouteLocation(
targetPath,
targetRouteInfo?.route.meta,
targetRouteInfo?.route.name
)
if (resolve(targetLocation).fullPath === unref(currentRoute).fullPath) {
return
}
push(targetLocation)
}
}
}
const renderMenuWrap = () => {
if (unref(layout) === 'top') {
if (isHeaderNavLayout(unref(layout)) || unref(menuMode) === 'horizontal') {
return renderMenu()
} else {
return <ElScrollbar>{renderMenu()}</ElScrollbar>
@ -80,25 +413,31 @@ export default defineComponent({
const renderMenu = () => {
return (
<ElMenu
ref={menuRef}
defaultActive={unref(activeMenu)}
defaultOpeneds={unref(defaultOpeneds)}
mode={unref(menuMode)}
menuTrigger={props.rootOnly && unref(menuMode) === 'horizontal' ? 'click' : 'hover'}
collapse={
unref(layout) === 'top' || unref(layout) === 'cutMenu' ? false : unref(collapse)
unref(menuMode) === 'horizontal' || isTwoColumnLayout(unref(layout))
? false
: unref(collapse)
}
uniqueOpened={unref(layout) === 'top' ? false : unref(uniqueOpened)}
backgroundColor="var(--left-menu-bg-color)"
textColor="var(--left-menu-text-color)"
activeTextColor="var(--left-menu-text-active-color)"
uniqueOpened={unref(menuMode) === 'horizontal' ? false : unref(uniqueOpened)}
backgroundColor={unref(menuBgColor)}
ellipsis={unref(menuMode) === 'horizontal' ? true : undefined}
textColor={unref(menuTextColor)}
activeTextColor={unref(menuActiveTextColor)}
popperClass={
unref(menuMode) === 'vertical'
? `${prefixCls}-popper--vertical`
: `${prefixCls}-popper--horizontal`
? `${prefixCls}-popper--vertical ${prefixCls}-popper--${props.theme}`
: `${prefixCls}-popper--horizontal ${prefixCls}-popper--${props.theme}`
}
onSelect={menuSelect}
>
{{
default: () => {
const { renderMenuItem } = useRenderMenuItem(unref(menuMode))
const { renderMenuItem } = useRenderMenuItem()
return renderMenuItem(unref(routers))
}
}}
@ -106,19 +445,111 @@ export default defineComponent({
)
}
const getOverflowMenuIndex = (route: AppRouteRecordRaw): string => {
const fullPath = isUrl(route.path) ? route.path : pathResolve('/', route.path)
return isUrl(fullPath)
? fullPath
: resolveDynamicPath(fullPath, route.meta?.params as Record<string, any>)
}
const isVisibleElement = (element: HTMLElement) => {
const rect = element.getBoundingClientRect()
const style = window.getComputedStyle(element)
return (
rect.width > 0 &&
rect.height > 0 &&
style.display !== 'none' &&
style.visibility !== 'hidden'
)
}
const getVisibleHorizontalRootCount = () => {
const menu = unref(menuWrapRef)?.querySelector<HTMLElement>('.el-menu--horizontal')
if (!menu) {
return 0
}
return Array.from(menu.children).filter((item) => {
const element = item as HTMLElement
return (
element.matches('.el-menu-item,.el-sub-menu') &&
!element.classList.contains('el-sub-menu__hide-arrow') &&
isVisibleElement(element)
)
}).length
}
const getHorizontalOverflowRoutes = () => {
const visibleRoutes = unref(routers).filter((route) => !route.meta?.hidden)
const menu = unref(menuWrapRef)?.querySelector<HTMLElement>('.el-menu--horizontal')
const moreMenu = menu?.querySelector(':scope > .el-sub-menu.el-sub-menu__hide-arrow')
if (!moreMenu) {
return []
}
const visibleRootCount = getVisibleHorizontalRootCount()
return visibleRoutes.slice(Math.min(visibleRootCount, visibleRoutes.length))
}
const renderHorizontalOverflowFallback = () => {
if (!props.rootOnly || unref(menuMode) !== 'horizontal') {
return undefined
}
const overflowRoutes = unref(horizontalOverflowRoutes)
if (!overflowRoutes.length) {
return undefined
}
return (
<div
class={[
'el-popper is-pure is-light el-tooltip',
`${prefixCls}-overflow-fallback`,
{
'is-open': unref(horizontalOverflowOpened)
},
`${prefixCls}-popper--horizontal`,
`${prefixCls}-popper--${props.theme}`
]}
style={unref(horizontalOverflowStyle)}
onMouseleave={() => {
horizontalOverflowOpened.value = false
}}
>
<ul class="el-menu el-menu--popup">
{overflowRoutes.map((route) => (
<li
key={getOverflowMenuIndex(route)}
class="el-menu-item"
role="menuitem"
onClick={() => menuSelect(getOverflowMenuIndex(route))}
>
{route.meta?.title ? t(route.meta.title as string) : ''}
</li>
))}
</ul>
</div>
)
}
return () => (
<div
id={prefixCls}
ref={menuWrapRef}
class={[
`${prefixCls} ${prefixCls}__${unref(menuMode)}`,
`${prefixCls}--${props.theme}`,
'h-[100%] overflow-hidden flex-col bg-[var(--left-menu-bg-color)]',
{
'w-[var(--left-menu-min-width)]': unref(collapse) && unref(layout) !== 'cutMenu',
'w-[var(--left-menu-max-width)]': !unref(collapse) && unref(layout) !== 'cutMenu'
'w-[var(--left-menu-min-width)]':
unref(collapse) && !isTwoColumnLayout(unref(layout)) && unref(menuMode) !== 'horizontal',
'w-[var(--left-menu-max-width)]':
!unref(collapse) && !isTwoColumnLayout(unref(layout)) && unref(menuMode) !== 'horizontal'
}
]}
style={{
backgroundColor: unref(menuBgColor)
}}
>
{renderMenuWrap()}
{renderHorizontalOverflowFallback()}
</div>
)
}
@ -207,10 +638,15 @@ $prefix-cls: #{$namespace}-menu;
//
&__horizontal {
height: calc(var(--top-tool-height)) !important;
min-width: 0;
flex-shrink: 1;
overflow-x: hidden;
overflow-y: hidden;
:deep(.#{$elNamespace}-menu--horizontal) {
height: calc(var(--top-tool-height));
border-bottom: none;
min-width: 100%;
//
& > .#{$elNamespace}-sub-menu.is-active {
.#{$elNamespace}-sub-menu__title {
@ -234,6 +670,60 @@ $prefix-cls: #{$namespace}-menu;
}
}
}
&--header {
:deep(.#{$elNamespace}-menu) {
.is-active {
& > .#{$elNamespace}-sub-menu__title {
color: var(--el-color-primary) !important;
}
}
.#{$elNamespace}-sub-menu__title,
.#{$elNamespace}-menu-item {
&:hover {
color: var(--el-color-primary) !important;
background-color: var(--top-header-hover-color) !important;
}
}
.#{$elNamespace}-menu-item.is-active {
color: var(--el-color-primary) !important;
background-color: var(--top-header-bg-color) !important;
&:hover {
background-color: var(--top-header-hover-color) !important;
}
}
.#{$elNamespace}-menu {
.#{$elNamespace}-sub-menu__title,
.#{$elNamespace}-menu-item:not(.is-active) {
background-color: var(--top-header-bg-color) !important;
}
}
}
}
}
.#{$prefix-cls}-overflow-fallback {
position: fixed;
top: calc(var(--top-tool-height) + 6px);
right: clamp(16px, 38vw, 640px);
z-index: 5000;
display: none;
min-width: 200px;
.#{$elNamespace}-menu {
border: none;
}
}
.#{$prefix-cls}__horizontal:has(.#{$elNamespace}-sub-menu__hide-arrow:hover)
> .#{$prefix-cls}-overflow-fallback,
.#{$prefix-cls}-overflow-fallback:hover,
.#{$prefix-cls}-overflow-fallback.is-open {
display: block;
}
</style>
@ -268,4 +758,28 @@ $prefix-cls: #{$namespace}-menu-popper;
}
}
}
.#{$prefix-cls}--header {
.is-active {
& > .el-sub-menu__title {
color: var(--el-color-primary) !important;
}
}
.el-sub-menu__title,
.el-menu-item {
&:hover {
color: var(--el-color-primary) !important;
background-color: var(--top-header-hover-color) !important;
}
}
.el-menu-item.is-active {
background-color: var(--top-header-bg-color) !important;
&:hover {
background-color: var(--top-header-hover-color) !important;
}
}
}
</style>

View File

@ -3,28 +3,43 @@ import { hasOneShowingChild } from '../helper'
import { isUrl } from '@/utils/is'
import { useRenderMenuTitle } from './useRenderMenuTitle'
import { pathResolve } from '@/utils/routerHelper'
import { resolveDynamicPath } from '@/utils/routeParams'
const { renderMenuTitle } = useRenderMenuTitle()
export const useRenderMenuItem = () =>
// allRouters: AppRouteRecordRaw[] = [],
{
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => {
const renderMenuItem = (routers: AppRouteRecordRaw[] = [], parentPath = '/') => {
return routers
.filter((v) => !v.meta?.hidden)
.map((v) => {
const meta = v.meta ?? {}
const { oneShowingChild, onlyOneChild } = hasOneShowingChild(v.children, v)
const children = v.children || []
const { oneShowingChild, onlyOneChild } = hasOneShowingChild(children, v)
const fullPath = isUrl(v.path) ? v.path : pathResolve(parentPath, v.path) // getAllParentPath<AppRouteRecordRaw>(allRouters, v.path).join('/')
const resolvedFullPath = isUrl(fullPath)
? fullPath
: resolveDynamicPath(fullPath, meta.params as Record<string, any>)
const onlyOneChildPath = onlyOneChild
? pathResolve(fullPath, onlyOneChild.path)
: resolvedFullPath
const resolvedOnlyOneChildPath = isUrl(onlyOneChildPath)
? onlyOneChildPath
: resolveDynamicPath(
onlyOneChildPath,
(onlyOneChild?.meta?.params || meta.params) as Record<string, any>
)
if (
!children.length ||
oneShowingChild &&
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
!meta?.alwaysShow
) {
return (
<ElMenuItem
index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath}
index={resolvedOnlyOneChildPath}
>
{{
default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
@ -33,10 +48,10 @@ export const useRenderMenuItem = () =>
)
} else {
return (
<ElSubMenu index={fullPath}>
<ElSubMenu index={resolvedFullPath}>
{{
title: () => renderMenuTitle(meta),
default: () => renderMenuItem(v.children!, fullPath)
default: () => renderMenuItem(children, resolvedFullPath)
}}
</ElSubMenu>
)

View File

@ -0,0 +1,66 @@
import { isUrl } from '@/utils/is'
import { getRootPath } from '@/utils/layout'
import { resolveDynamicPath } from '@/utils/routeParams'
import { pathResolve } from '@/utils/routerHelper'
export const normalizeMenuTargetPath = (targetPath: string): string => {
if (!targetPath || isUrl(targetPath)) {
return targetPath
}
return targetPath.startsWith('/') ? targetPath : pathResolve('/', targetPath)
}
export const findRouteByPath = (
routes: AppRouteRecordRaw[],
targetPath: string,
parentPath = '/',
preferChild = true
): { fullPath: string; route: AppRouteRecordRaw } | undefined => {
for (const route of routes) {
const fullPath = isUrl(route.path) ? route.path : pathResolve(parentPath, route.path)
const resolvedFullPath = resolveDynamicPath(fullPath, route.meta?.params as Record<string, any>)
if (fullPath === targetPath || resolvedFullPath === targetPath) {
const child = preferChild
? findRouteByPath(route.children || [], targetPath, fullPath, preferChild)
: undefined
if (child) {
return child
}
return { fullPath: resolvedFullPath, route }
}
const child = route.children?.length
? findRouteByPath(route.children, targetPath, fullPath, preferChild)
: undefined
if (child) {
return child
}
}
}
export const getRootMenuRoute = (
routes: AppRouteRecordRaw[],
targetPath: string
): { fullPath: string; route: AppRouteRecordRaw } | undefined => {
const normalizedPath = normalizeMenuTargetPath(targetPath)
for (const route of routes) {
if (route.meta?.hidden) {
continue
}
const fullPath = isUrl(route.path) ? route.path : pathResolve('/', route.path)
const resolvedFullPath = resolveDynamicPath(fullPath, route.meta?.params as Record<string, any>)
if (
resolvedFullPath === normalizedPath ||
(resolvedFullPath !== '/' && normalizedPath.startsWith(`${resolvedFullPath}/`)) ||
findRouteByPath(route.children || [], normalizedPath, fullPath)
) {
return { fullPath: resolvedFullPath, route }
}
}
}
export const getRootMenuActivePath = (
routes: AppRouteRecordRaw[],
targetPath: string
): string => {
return getRootMenuRoute(routes, targetPath)?.fullPath || getRootPath(normalizeMenuTargetPath(targetPath))
}

View File

@ -1,3 +1,5 @@
import Setting from './src/Setting.vue'
import { useSetting } from './src/useSetting'
export { Setting }
export { useSetting }

View File

@ -3,7 +3,6 @@ import { ElMessage } from 'element-plus'
import { useClipboard, useCssVar } from '@vueuse/core'
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
import { useDesign } from '@/hooks/web/useDesign'
import { setCssVar, trim } from '@/utils'
import { colorIsDark, hexToRGB, lighten } from '@/utils/color'
@ -12,16 +11,15 @@ import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
import ColorRadioPicker from './components/ColorRadioPicker.vue'
import InterfaceDisplay from './components/InterfaceDisplay.vue'
import LayoutRadioPicker from './components/LayoutRadioPicker.vue'
import { isHeaderNavLayout } from '@/utils/layout'
import { useSetting } from './useSetting'
defineOptions({ name: 'Setting' })
const { t } = useI18n()
const appStore = useAppStore()
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('setting')
const layout = computed(() => appStore.getLayout)
const drawer = ref(false)
const { drawerVisible } = useSetting()
//
const systemTheme = ref(appStore.getTheme.elColorPrimary)
@ -30,7 +28,7 @@ const setSystemTheme = (color: string) => {
setCssVar('--el-color-primary', color)
appStore.setTheme({ elColorPrimary: color })
const leftMenuBgColor = useCssVar('--left-menu-bg-color', document.documentElement)
setMenuTheme(trim(unref(leftMenuBgColor)))
setMenuTheme(trim(unref(leftMenuBgColor) || ''))
}
//
@ -50,7 +48,7 @@ const setHeaderTheme = (color: string) => {
topHeaderHoverColor: textHoverColor,
topToolBorderColor
})
if (unref(layout) === 'top') {
if (isHeaderNavLayout(unref(layout))) {
setMenuTheme(color)
}
}
@ -71,11 +69,11 @@ const setMenuTheme = (color: string) => {
//
leftMenuBgActiveColor: isDarkColor
? 'var(--el-color-primary)'
: hexToRGB(unref(primaryColor), 0.1),
: hexToRGB(unref(primaryColor) || '#409eff', 0.1),
//
leftMenuCollapseBgActiveColor: isDarkColor
? 'var(--el-color-primary)'
: hexToRGB(unref(primaryColor), 0.1),
: hexToRGB(unref(primaryColor) || '#409eff', 0.1),
//
leftMenuTextColor: isDarkColor ? '#bfcbd9' : '#333',
//
@ -88,7 +86,7 @@ const setMenuTheme = (color: string) => {
appStore.setTheme(theme)
appStore.setCssVarTheme()
}
if (layout.value === 'top' && !appStore.getIsDark) {
if (isHeaderNavLayout(layout.value) && !appStore.getIsDark) {
headerTheme.value = '#fff'
setHeaderTheme('#fff')
}
@ -97,7 +95,7 @@ if (layout.value === 'top' && !appStore.getIsDark) {
watch(
() => layout.value,
(n) => {
if (n === 'top' && !appStore.getIsDark) {
if (isHeaderNavLayout(n) && !appStore.getIsDark) {
headerTheme.value = '#fff'
setHeaderTheme('#fff')
} else {
@ -201,15 +199,7 @@ const clear = () => {
</script>
<template>
<div
:class="prefixCls"
class="fixed right-0 top-[45%] h-40px w-40px cursor-pointer bg-[var(--el-color-primary)] text-center leading-40px"
@click="drawer = true"
>
<Icon color="#fff" icon="ep:setting" />
</div>
<ElDrawer v-model="drawer" :z-index="4000" direction="rtl" size="350px">
<ElDrawer v-model="drawerVisible" :z-index="4000" direction="rtl" size="350px">
<template #header>
<span class="text-16px font-700">{{ t('setting.projectSetting') }}</span>
</template>
@ -258,7 +248,7 @@ const clear = () => {
/>
<!-- 菜单主题 -->
<template v-if="layout !== 'top'">
<template v-if="!isHeaderNavLayout(layout)">
<ElDivider>{{ t('setting.menuTheme') }}</ElDivider>
<ColorRadioPicker
v-model="menuTheme"
@ -292,12 +282,3 @@ const clear = () => {
</div>
</ElDrawer>
</template>
<style lang="scss" scoped>
$prefix-cls: #{$namespace}-setting;
.#{$prefix-cls} {
z-index: 1200; /* 修正没有z-index会被表格层覆盖,值不要超过4000 */
border-radius: 6px 0 0 6px;
}
</style>

View File

@ -4,6 +4,7 @@ import { setCssVar } from '@/utils'
import { useDesign } from '@/hooks/web/useDesign'
import { useWatermark } from '@/hooks/web/useWatermark'
import { useAppStore } from '@/store/modules/app'
import { isHeaderNavLayout } from '@/utils/layout'
defineOptions({ name: 'InterfaceDisplay' })
@ -139,7 +140,7 @@ const layout = computed(() => appStore.getLayout)
watch(
() => layout.value,
(n) => {
if (n === 'top') {
if (isHeaderNavLayout(n)) {
appStore.setCollapse(false)
}
}
@ -228,9 +229,9 @@ watch(
<ElSwitch v-model="fixedMenu" @change="fixedMenuChange" />
</div>
<div class="flex items-center justify-between">
<span class="text-14px">{{ t('watermark.watermark') }}</span>
<ElInput v-model="water" class="right-1 w-20" @change="setWater()" />
<div class="flex items-center justify-between gap-10px">
<span class="shrink-0 whitespace-nowrap text-14px">{{ t('watermark.watermark') }}</span>
<ElInput v-model="water" class="min-w-0 flex-1" @change="setWater()" />
</div>
</div>
</template>

View File

@ -1,9 +1,11 @@
<script lang="ts" setup>
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
import type { VbenLayoutType } from '@/types/layout'
defineOptions({ name: 'LayoutRadioPicker' })
const { t } = useI18n()
const { getPrefixCls } = useDesign()
const prefixCls = getPrefixCls('layout-radio-picker')
@ -11,51 +13,68 @@ const prefixCls = getPrefixCls('layout-radio-picker')
const appStore = useAppStore()
const layout = computed(() => appStore.getLayout)
const layouts: { className: string; label: string; type: VbenLayoutType }[] = [
{
className: 'sidebar-nav',
label: t('setting.vertical'),
type: 'sidebar-nav'
},
{
className: 'sidebar-mixed-nav',
label: t('setting.twoColumn'),
type: 'sidebar-mixed-nav'
},
{
className: 'header-nav',
label: t('setting.horizontal'),
type: 'header-nav'
},
{
className: 'header-sidebar-nav',
label: t('setting.headerSidebarNav'),
type: 'header-sidebar-nav'
},
{
className: 'mixed-nav',
label: t('setting.mixedMenu'),
type: 'mixed-nav'
},
{
className: 'header-mixed-nav',
label: t('setting.headerTwoColumn'),
type: 'header-mixed-nav'
}
]
</script>
<template>
<div :class="prefixCls" class="flex flex-wrap space-x-14px">
<div :class="prefixCls" class="grid grid-cols-3 gap-14px">
<div
:class="[
`${prefixCls}__classic`,
'relative w-56px h-48px cursor-pointer bg-gray-300',
{
'is-acitve': layout === 'classic'
}
]"
@click="appStore.setLayout('classic')"
></div>
<div
:class="[
`${prefixCls}__top-left`,
'relative w-56px h-48px cursor-pointer bg-gray-300',
{
'is-acitve': layout === 'topLeft'
}
]"
@click="appStore.setLayout('topLeft')"
></div>
<div
:class="[
`${prefixCls}__top`,
'relative w-56px h-48px cursor-pointer bg-gray-300',
{
'is-acitve': layout === 'top'
}
]"
@click="appStore.setLayout('top')"
></div>
<div
:class="[
`${prefixCls}__cut-menu`,
'relative w-56px h-48px cursor-pointer bg-gray-300',
{
'is-acitve': layout === 'cutMenu'
}
]"
@click="appStore.setLayout('cutMenu')"
v-for="item in layouts"
:key="item.type"
class="flex cursor-pointer flex-col items-center gap-6px"
@click="appStore.setLayout(item.type)"
>
<div class="absolute left-[10%] top-0 h-full w-[33%] bg-gray-200"></div>
<div
:aria-label="item.label"
:class="[
`${prefixCls}__${item.className}`,
'relative h-48px w-56px bg-gray-300',
{
'is-acitve': layout === item.type
}
]"
:title="item.label"
>
<div
v-if="item.type === 'sidebar-mixed-nav' || item.type === 'header-mixed-nav'"
class="absolute left-[10%] top-0 h-full w-[33%] bg-gray-200"
></div>
</div>
<span class="max-w-76px truncate text-12px text-[var(--el-text-color-regular)]">
{{ item.label }}
</span>
</div>
</div>
</template>
@ -64,7 +83,7 @@ const layout = computed(() => appStore.getLayout)
$prefix-cls: #{$namespace}-layout-radio-picker;
.#{$prefix-cls} {
&__classic {
&__sidebar-nav {
border: 2px solid #e5e7eb;
border-radius: 4px;
@ -92,7 +111,8 @@ $prefix-cls: #{$namespace}-layout-radio-picker;
}
}
&__top-left {
&__header-sidebar-nav,
&__mixed-nav {
border: 2px solid #e5e7eb;
border-radius: 4px;
@ -120,7 +140,7 @@ $prefix-cls: #{$namespace}-layout-radio-picker;
}
}
&__top {
&__header-nav {
border: 2px solid #e5e7eb;
border-radius: 4px;
@ -137,7 +157,8 @@ $prefix-cls: #{$namespace}-layout-radio-picker;
}
}
&__cut-menu {
&__header-mixed-nav,
&__sidebar-mixed-nav {
border: 2px solid #e5e7eb;
border-radius: 4px;
@ -165,6 +186,25 @@ $prefix-cls: #{$namespace}-layout-radio-picker;
}
}
&__mixed-nav::after {
position: absolute;
top: 33%;
left: 0;
width: 33%;
height: 67%;
background-color: #fff;
border-radius: 0 0 0 4px;
content: '';
}
&__header-mixed-nav::after {
background-color: #273352;
}
&__sidebar-mixed-nav::before {
display: none;
}
.is-acitve {
border-color: var(--el-color-primary);
}

View File

@ -0,0 +1,14 @@
import { ref } from 'vue'
const drawerVisible = ref(false)
export const useSetting = () => {
const openSetting = () => {
drawerVisible.value = true
}
return {
drawerVisible,
openSetting
}
}

View File

@ -10,6 +10,12 @@ import { cloneDeep } from 'lodash-es'
import { filterMenusPath, initTabMap, tabPathMap } from './helper'
import { useDesign } from '@/hooks/web/useDesign'
import { isUrl } from '@/utils/is'
import { createRouteLocation } from '@/utils/routeParams'
import { getRootPath, isHeaderMixedNavLayout, isTwoColumnLayout } from '@/utils/layout'
import {
getRootMenuRoute,
normalizeMenuTargetPath
} from '@/layout/components/Menu/src/menuRoute'
const { getPrefixCls, variables } = useDesign()
@ -18,7 +24,7 @@ const prefixCls = getPrefixCls('tab-menu')
export default defineComponent({
name: 'TabMenu',
setup() {
const { push, currentRoute } = useRouter()
const { push, currentRoute, resolve } = useRouter()
const { t } = useI18n()
@ -28,37 +34,47 @@ export default defineComponent({
const fixedMenu = computed(() => appStore.getFixedMenu)
const layout = computed(() => appStore.getLayout)
const headerMixed = computed(() => isHeaderMixedNavLayout(unref(layout)))
const twoColumn = computed(() => isTwoColumnLayout(unref(layout)))
const permissionStore = usePermissionStore()
const routers = computed(() => permissionStore.getRouters)
const tabRouters = computed(() => unref(routers).filter((v) => !v?.meta?.hidden))
const currentMenuPath = computed(() =>
normalizeMenuTargetPath(
(unref(currentRoute).meta.activeMenu as string) || unref(currentRoute).path
)
)
const activeRootInfo = computed(() => {
const targetPath =
unref(headerMixed) && permissionStore.getMenuRootPath
? permissionStore.getMenuRootPath
: unref(currentMenuPath)
return getRootMenuRoute(unref(routers), targetPath)
})
const rootPath = computed(() => unref(activeRootInfo)?.fullPath || getRootPath(unref(currentMenuPath)))
const activeRootRoute = computed(() => unref(activeRootInfo)?.route)
const tabRouters = computed(() => {
const sourceRoutes = unref(headerMixed)
? unref(activeRootRoute)?.children || []
: unref(routers)
return sourceRoutes.filter((v) => !v?.meta?.hidden)
})
const getTabParentPath = () => (unref(headerMixed) ? unref(rootPath) : '')
const setCollapse = () => {
appStore.setCollapse(!unref(collapse))
}
onMounted(() => {
if (unref(fixedMenu)) {
const path = `/${unref(currentRoute).path.split('/')[1]}`
const children = unref(tabRouters).find(
(v) =>
(v.meta?.alwaysShow || (v?.children?.length && v?.children?.length > 1)) &&
v.path === path
)?.children
tabActive.value = path
if (children) {
permissionStore.setMenuTabRouters(
cloneDeep(children).map((v) => {
v.path = pathResolve(unref(tabActive), v.path)
return v
})
)
}
}
})
watch(
() => routers.value,
(routers: AppRouteRecordRaw[]) => {
@ -66,8 +82,7 @@ export default defineComponent({
filterMenusPath(routers, routers)
},
{
immediate: true,
deep: true
immediate: true
}
)
@ -87,35 +102,134 @@ export default defineComponent({
)
//
const showMenu = ref(unref(fixedMenu) ? true : false)
const showMenu = ref(unref(fixedMenu) || unref(headerMixed) || unref(twoColumn))
// tab
const tabActive = ref('')
const hasExtraMenu = computed(() => permissionStore.getMenuTabRouters.length > 0)
const getFullTabPath = (route: AppRouteRecordRaw) => pathResolve(getTabParentPath(), route.path)
const getVisibleChildren = (route: AppRouteRecordRaw): AppRouteRecordRaw[] =>
(route.children || []).filter((child) => !child.meta?.hidden)
const shouldShowExtraMenu = (route: AppRouteRecordRaw): boolean => {
const children = getVisibleChildren(route)
if (!children.length) {
return false
}
return unref(headerMixed) || !!route.meta?.alwaysShow || children.length > 1
}
const isSameMenuRouters = (left: AppRouteRecordRaw[], right: AppRouteRecordRaw[]): boolean => {
return (
left.length === right.length &&
left.every((route, index) => route.path === right[index]?.path && route.name === right[index]?.name)
)
}
const setExtraMenuRouters = (routers: AppRouteRecordRaw[]) => {
if (isSameMenuRouters(permissionStore.getMenuTabRouters, routers)) {
return
}
permissionStore.setMenuTabRouters(routers)
}
const buildExtraMenuRouters = (children: AppRouteRecordRaw[], parentPath: string) =>
cloneDeep(children).map((v) => {
v.path = pathResolve(parentPath, v.path)
return v
})
const getTabItem = (route: AppRouteRecordRaw): AppRouteRecordRaw => {
const fullTabPath = getFullTabPath(route)
const children = getVisibleChildren(route)
if (shouldShowExtraMenu(route) || !children.length) {
return { ...route, path: fullTabPath }
}
return {
...children[0],
path: pathResolve(fullTabPath, children[0].path)
} as AppRouteRecordRaw
}
const isCurrentRouteInTab = (tabPath: string) => {
const currentPath = unref(currentMenuPath)
if (unref(headerMixed)) {
return currentPath === tabPath || currentPath.startsWith(`${tabPath}/`)
}
return !!tabPathMap[tabPath]?.includes(currentPath)
}
const syncTabMenusByRoute = () => {
if (!unref(fixedMenu) && !unref(headerMixed) && !unref(twoColumn)) {
return
}
const currentPath = unref(currentMenuPath)
const activeTab = unref(tabRouters).find((route) => {
const tabPath = getFullTabPath(route)
return currentPath === tabPath || currentPath.startsWith(`${tabPath}/`)
})
if (!activeTab || !shouldShowExtraMenu(activeTab)) {
tabActive.value = activeTab ? getFullTabPath(activeTab) : ''
showMenu.value = unref(fixedMenu) || unref(headerMixed) || unref(twoColumn)
setExtraMenuRouters([])
return
}
const activeTabPath = getFullTabPath(activeTab)
tabActive.value = activeTabPath
showMenu.value = true
setExtraMenuRouters(buildExtraMenuRouters(getVisibleChildren(activeTab), activeTabPath))
}
watch(
() =>
[
unref(currentRoute).path,
permissionStore.getMenuRootPath,
unref(tabRouters),
unref(fixedMenu),
unref(headerMixed),
unref(twoColumn)
] as const,
syncTabMenusByRoute,
{
immediate: true
}
)
// tab
const tabClick = (item: AppRouteRecordRaw) => {
const link = item.meta?.link
if (typeof link === 'string') {
window.open(link)
return
}
if (isUrl(item.path)) {
window.open(item.path)
return
}
const newPath = item.children ? item.path : item.path.split('/')[0]
const newPath = normalizeMenuTargetPath(item.path)
const oldPath = unref(tabActive)
tabActive.value = item.children ? item.path : item.path.split('/')[0]
if (item.children) {
tabActive.value = newPath
const children = getVisibleChildren(item)
if (children.length) {
if (newPath === oldPath || !unref(showMenu)) {
showMenu.value = unref(fixedMenu) ? true : !unref(showMenu)
showMenu.value = unref(fixedMenu) || unref(headerMixed) || unref(twoColumn) ? true : !unref(showMenu)
}
if (unref(showMenu)) {
permissionStore.setMenuTabRouters(
cloneDeep(item.children).map((v) => {
v.path = pathResolve(unref(tabActive), v.path)
return v
})
)
setExtraMenuRouters(buildExtraMenuRouters(children, unref(tabActive)))
}
} else {
push(item.path)
permissionStore.setMenuTabRouters([])
const targetLocation = createRouteLocation(item.path, item.meta, item.name)
if (resolve(targetLocation).fullPath !== unref(currentRoute).fullPath) {
push(targetLocation)
}
setExtraMenuRouters([])
showMenu.value = false
}
}
@ -123,14 +237,14 @@ export default defineComponent({
//
const isActive = (currentPath: string) => {
const { path } = unref(currentRoute)
if (tabPathMap[currentPath].includes(path)) {
if (isCurrentRouteInTab(currentPath) || tabPathMap[currentPath]?.includes(path)) {
return true
}
return false
}
const mouseleave = () => {
if (!unref(showMenu) || unref(fixedMenu)) return
if (!unref(showMenu) || unref(fixedMenu) || unref(headerMixed) || unref(twoColumn)) return
showMenu.value = false
}
@ -151,21 +265,15 @@ export default defineComponent({
<div>
{() => {
return unref(tabRouters).map((v) => {
const item = (
v.meta?.alwaysShow || (v?.children?.length && v?.children?.length > 1)
? v
: {
...(v?.children && v?.children[0]),
path: pathResolve(v.path, (v?.children && v?.children[0])?.path as string)
}
) as AppRouteRecordRaw
const fullTabPath = getFullTabPath(v)
const item = getTabItem(v)
return (
<div
class={[
`${prefixCls}__item`,
'text-center text-12px relative py-12px cursor-pointer',
{
'is-active': isActive(v.path)
'is-active': isActive(fullTabPath)
}
]}
onClick={() => {
@ -193,18 +301,23 @@ export default defineComponent({
>
<Icon icon={unref(collapse) ? 'ep:d-arrow-right' : 'ep:d-arrow-left'}></Icon>
</div>
<Menu
class={[
'!absolute top-0 z-11',
{
'!left-[var(--tab-menu-min-width)]': unref(collapse),
'!left-[var(--tab-menu-max-width)]': !unref(collapse),
'!w-[var(--left-menu-max-width)]': unref(showMenu) || unref(fixedMenu),
'!w-0': !unref(showMenu) && !unref(fixedMenu)
}
]}
style="transition: width var(--transition-time-02), left var(--transition-time-02);"
></Menu>
{unref(hasExtraMenu) ? (
<Menu
split={false}
menus={permissionStore.getMenuTabRouters}
mode="vertical"
class={[
'!absolute top-0 z-11',
{
'!left-[var(--tab-menu-min-width)]': unref(collapse),
'!left-[var(--tab-menu-max-width)]': !unref(collapse),
'!w-[var(--left-menu-max-width)]': unref(showMenu) || unref(fixedMenu),
'!w-0': !unref(showMenu) && !unref(fixedMenu)
}
]}
style="transition: width var(--transition-time-02), left var(--transition-time-02);"
></Menu>
) : undefined}
</div>
)
}

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, unref, watch } from 'vue'
import { computed, nextTick, ref, unref, watch } from 'vue'
import type { RouteLocationNormalizedLoaded, RouterLinkProps } from 'vue-router'
import { useRouter } from 'vue-router'
import { usePermissionStore } from '@/store/modules/permission'
@ -139,10 +139,12 @@ const tagLinksRefs = useTemplateRefsList<RouterLinkProps>()
const moveToTarget = (currentTag: RouteLocationNormalizedLoaded) => {
const wrap$ = unref(scrollbarRef)?.wrapRef
if (!wrap$) return
let firstTag: Nullable<RouterLinkProps> = null
let lastTag: Nullable<RouterLinkProps> = null
const tagList = unref(tagLinksRefs)
if (!tagList.length) return
// find first tag and last tag
if (tagList.length > 0) {
firstTag = tagList[0]
@ -169,12 +171,14 @@ const moveToTarget = (currentTag: RouteLocationNormalizedLoaded) => {
} else {
// find preTag and nextTag
const currentIndex: number = tagList.findIndex(
(item) => (item?.to as RouteLocationNormalizedLoaded).fullPath === currentTag.fullPath
(item) => (item?.to as RouteLocationNormalizedLoaded | undefined)?.fullPath === currentTag.fullPath
)
if (currentIndex < 0) return
const tgsRefs = document.getElementsByClassName(`${prefixCls}__item`)
const prevTag = tgsRefs[currentIndex - 1] as HTMLElement
const nextTag = tgsRefs[currentIndex + 1] as HTMLElement
if (!prevTag || !nextTag) return
// the tag's offsetLeft after of nextTag
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + 4
@ -245,16 +249,6 @@ const move = (to: number) => {
start()
}
const canShowIcon = (item: RouteLocationNormalizedLoaded) => {
if (
(item?.matched?.[1]?.meta?.icon && unref(tagsViewIcon)) ||
(item?.meta?.affix && unref(tagsViewIcon) && item?.meta?.icon)
) {
return true
}
return false
}
const closeTabOnMouseMidClick = (e: MouseEvent, item) => {
// button === 1
if (e.button === 1) {

View File

@ -10,10 +10,12 @@ import { SizeDropdown } from '@/layout/components/SizeDropdown'
import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
import RouterSearch from '@/components/RouterSearch/index.vue'
import TenantVisit from '@/layout/components/TenantVisit/index.vue'
import { useSetting } from '@/layout/components/Setting'
import { useAppStore } from '@/store/modules/app'
import { useDesign } from '@/hooks/web/useDesign'
import { Icon } from '@/components/Icon'
import { checkPermi } from '@/utils/permission'
import { isHorizontalMenuLayout, isMixedNavLayout, isTwoColumnLayout } from '@/utils/layout'
const { getPrefixCls, variables } = useDesign()
@ -61,6 +63,13 @@ const goToChat = () => {
export default defineComponent({
name: 'ToolHeader',
setup() {
const { t } = useI18n()
const { openSetting } = useSetting()
const showSidebarControl = computed(
() => !isHorizontalMenuLayout(layout.value) || isMixedNavLayout(layout.value)
)
const showBreadcrumb = computed(() => !isHorizontalMenuLayout(layout.value))
return () => (
<div
id={`${variables.namespace}-tool-header`}
@ -70,16 +79,21 @@ export default defineComponent({
'dark:bg-[var(--el-bg-color)]'
]}
>
{layout.value !== 'top' ? (
{showSidebarControl.value || showBreadcrumb.value ? (
<div class="h-full flex items-center">
{hamburger.value && layout.value !== 'cutMenu' ? (
{showSidebarControl.value && hamburger.value && !isTwoColumnLayout(layout.value) ? (
<Collapse class="custom-hover" color="var(--top-header-text-color)"></Collapse>
) : undefined}
{breadcrumb.value ? <Breadcrumb class="lt-md:hidden"></Breadcrumb> : undefined}
{showBreadcrumb.value && breadcrumb.value ? (
<Breadcrumb class="lt-md:hidden"></Breadcrumb>
) : undefined}
</div>
) : undefined}
<div class="h-full flex items-center">
{hasTenantVisitPermission.value ? <TenantVisit /> : undefined}
<div class="v-setting custom-hover" title={t('setting.projectSetting')} onClick={openSetting}>
<Icon color="var(--top-header-text-color)" size={18} icon="ep:setting" />
</div>
{screenfull.value ? (
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
) : undefined}

View File

@ -1,5 +1,6 @@
import { computed } from 'vue'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
import { Menu } from '@/layout/components/Menu'
import { TabMenu } from '@/layout/components/TabMenu'
import { TagsView } from '@/layout/components/TagsView'
@ -8,6 +9,11 @@ import AppView from './AppView.vue'
import ToolHeader from './ToolHeader.vue'
import { ElScrollbar } from 'element-plus'
import { useDesign } from '@/hooks/web/useDesign'
import {
isHeaderMixedNavLayout,
isMixedNavLayout,
isTwoColumnLayout
} from '@/utils/layout'
const { getPrefixCls } = useDesign()
@ -15,6 +21,8 @@ const prefixCls = getPrefixCls('layout')
const appStore = useAppStore()
const permissionStore = usePermissionStore()
const pageLoading = computed(() => appStore.getPageLoading)
// 标签页
@ -35,6 +43,8 @@ const mobile = computed(() => appStore.getMobile)
// 固定菜单
const fixedMenu = computed(() => appStore.getFixedMenu)
const layout = computed(() => appStore.getLayout)
export const useRenderLayout = () => {
const renderClassic = () => {
return (
@ -119,19 +129,33 @@ export const useRenderLayout = () => {
}
const renderTopLeft = () => {
const showHeaderMenu = isMixedNavLayout(layout.value)
return (
<>
<div class="relative flex items-center bg-[var(--top-header-bg-color)] layout-border__bottom dark:bg-[var(--el-bg-color)]">
{logo.value ? <Logo class="custom-hover"></Logo> : undefined}
{logo.value ? <Logo class="custom-hover !w-auto !pr-15px"></Logo> : undefined}
<ToolHeader class="flex-1"></ToolHeader>
{showHeaderMenu ? (
<Menu
mode="horizontal"
rootOnly
theme="header"
class="h-[var(--top-tool-height)] min-w-0 flex-1 px-10px"
></Menu>
) : undefined}
<ToolHeader class={showHeaderMenu ? 'flex-none' : 'flex-1'}></ToolHeader>
</div>
<div class="absolute left-0 top-[var(--logo-height)] h-[calc(100%-var(--logo-height))] w-full flex">
<Menu class="relative layout-border__right !h-full"></Menu>
<Menu
split={showHeaderMenu}
mode="vertical"
class="relative layout-border__right !h-full"
></Menu>
<div
class={[
`${prefixCls}-content`,
'h-[100%]',
'h-[100%] flex-none',
{
'w-[calc(100%-var(--left-menu-min-width))] left-[var(--left-menu-min-width)]':
collapse.value,
@ -187,7 +211,11 @@ export const useRenderLayout = () => {
]}
>
{logo.value ? <Logo class="custom-hover"></Logo> : undefined}
<Menu class="h-[var(--top-tool-height)] flex-1 px-10px"></Menu>
<Menu
mode="horizontal"
theme="header"
class="h-[var(--top-tool-height)] flex-1 px-10px"
></Menu>
<ToolHeader></ToolHeader>
</div>
<div class={[`${prefixCls}-content`, 'w-full h-[calc(100%-var(--top-tool-height))]']}>
@ -221,31 +249,43 @@ export const useRenderLayout = () => {
}
const renderCutMenu = () => {
const showHeaderMenu = isHeaderMixedNavLayout(layout.value)
const fixedTwoColumnMenu = fixedMenu.value || isTwoColumnLayout(layout.value)
const showTwoColumnExtraMenu = fixedTwoColumnMenu && permissionStore.getMenuTabRouters.length > 0
return (
<>
<div class="relative flex items-center bg-[var(--top-header-bg-color)] layout-border__bottom">
{logo.value ? <Logo class="custom-hover !pr-15px"></Logo> : undefined}
{logo.value ? <Logo class="custom-hover !w-auto !pr-15px"></Logo> : undefined}
<ToolHeader class="flex-1"></ToolHeader>
{showHeaderMenu ? (
<Menu
mode="horizontal"
rootOnly
theme="header"
class="h-[var(--top-tool-height)] min-w-0 flex-1 px-10px"
></Menu>
) : undefined}
<ToolHeader class={showHeaderMenu ? 'flex-none' : 'flex-1'}></ToolHeader>
</div>
<div class="absolute left-0 top-[var(--logo-height)] h-[calc(100%-var(--logo-height))] w-full flex">
<TabMenu></TabMenu>
<div
class={[
`${prefixCls}-content`,
'h-[100%]',
'absolute top-0 h-[100%] flex-none',
{
'w-[calc(100%-var(--tab-menu-min-width))] left-[var(--tab-menu-min-width)]':
collapse.value && !fixedMenu.value,
collapse.value && !showTwoColumnExtraMenu,
'w-[calc(100%-var(--tab-menu-max-width))] left-[var(--tab-menu-max-width)]':
!collapse.value && !fixedMenu.value,
'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] ml-[var(--left-menu-max-width)]':
collapse.value && fixedMenu.value,
'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] ml-[var(--left-menu-max-width)]':
!collapse.value && fixedMenu.value
!collapse.value && !showTwoColumnExtraMenu,
'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] left-[calc(var(--tab-menu-min-width)+var(--left-menu-max-width))]':
collapse.value && showTwoColumnExtraMenu,
'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] left-[calc(var(--tab-menu-max-width)+var(--left-menu-max-width))]':
!collapse.value && showTwoColumnExtraMenu
}
]}
style="transition: all var(--transition-time-02);"
style="transition: width var(--transition-time-02);"
>
<ElScrollbar
v-loading={pageLoading.value}
@ -264,16 +304,20 @@ export const useRenderLayout = () => {
{
'!fixed top-0 left-0 z-10': fixedHeader.value,
'w-[calc(100%-var(--tab-menu-min-width))] !left-[var(--tab-menu-min-width)] mt-[var(--logo-height)]':
collapse.value && fixedHeader.value && !fixedMenu.value,
collapse.value &&
fixedHeader.value &&
(!fixedTwoColumnMenu || !showTwoColumnExtraMenu),
'w-[calc(100%-var(--tab-menu-max-width))] !left-[var(--tab-menu-max-width)] mt-[var(--logo-height)]':
!collapse.value && fixedHeader.value && !fixedMenu.value,
!collapse.value &&
fixedHeader.value &&
(!fixedTwoColumnMenu || !showTwoColumnExtraMenu),
'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] !left-[calc(var(--tab-menu-min-width)+var(--left-menu-max-width))] mt-[var(--logo-height)]':
collapse.value && fixedHeader.value && fixedMenu.value,
collapse.value && fixedHeader.value && showTwoColumnExtraMenu,
'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] !left-[calc(var(--tab-menu-max-width)+var(--left-menu-max-width))] mt-[var(--logo-height)]':
!collapse.value && fixedHeader.value && fixedMenu.value
!collapse.value && fixedHeader.value && showTwoColumnExtraMenu
}
]}
style="transition: width var(--transition-time-02), left var(--transition-time-02);"
style="transition: width var(--transition-time-02);"
></TagsView>
) : undefined}

View File

@ -97,6 +97,12 @@ export default {
fixedHeader: 'Fixed header',
headerTheme: 'Header theme',
cutMenu: 'Cut Menu',
vertical: 'Vertical',
twoColumn: 'Two-column',
horizontal: 'Horizontal',
headerSidebarNav: 'Sidebar nav',
mixedMenu: 'Mixed vertical',
headerTwoColumn: 'Mixed two-column',
copy: 'Copy',
clearAndReset: 'Clear cache and reset',
copySuccess: 'Copy success',
@ -459,4 +465,4 @@ export default {
btn_zoom_out: 'Zoom out',
preview: 'Preivew'
}
}
}

View File

@ -98,6 +98,12 @@ export default {
fixedHeader: '固定头部',
headerTheme: '头部主题',
cutMenu: '切割菜单',
vertical: '垂直',
twoColumn: '双列菜单',
horizontal: '水平',
headerSidebarNav: '侧边导航',
mixedMenu: '混合垂直',
headerTwoColumn: '混合双列',
copy: '拷贝',
clearAndReset: '清除缓存并且重置',
copySuccess: '拷贝成功',
@ -455,4 +461,4 @@ export default {
preview: '预览'
},
'OAuth 2.0': 'OAuth 2.0' // 避免菜单名是 OAuth 2.0 时,一直 warn 报错
}
}

View File

@ -8,44 +8,12 @@ import { usePageLoading } from '@/hooks/web/usePageLoading'
import { useDictStoreWithOut } from '@/store/modules/dict'
import { useUserStoreWithOut } from '@/store/modules/user'
import { usePermissionStoreWithOut } from '@/store/modules/permission'
import { parseRouteLocation } from '@/utils/routeParams'
const { start, done } = useNProgress()
const { loadStart, loadDone } = usePageLoading()
const parseURL = (
url: string | null | undefined
): { basePath: string; paramsObject: { [key: string]: string } } => {
// 如果输入为 null 或 undefined返回空字符串和空对象
if (url == null) {
return { basePath: '', paramsObject: {} }
}
// 找到问号 (?) 的位置,它之前是基础路径,之后是查询参数
const questionMarkIndex = url.indexOf('?')
let basePath = url
const paramsObject: { [key: string]: string } = {}
// 如果找到了问号,说明有查询参数
if (questionMarkIndex !== -1) {
// 获取 basePath
basePath = url.substring(0, questionMarkIndex)
// 从 URL 中获取查询字符串部分
const queryString = url.substring(questionMarkIndex + 1)
// 使用 URLSearchParams 遍历参数
const searchParams = new URLSearchParams(queryString)
searchParams.forEach((value, key) => {
// 封装进 paramsObject 对象
paramsObject[key] = value
})
}
// 返回 basePath 和 paramsObject
return { basePath, paramsObject }
}
// 路由不重定向白名单
const whiteList = [
'/login',
@ -81,11 +49,14 @@ router.beforeEach(async (to, from, next) => {
permissionStore.getAddRouters.forEach((route) => {
router.addRoute(route as unknown as RouteRecordRaw) // 动态添加可访问路由表
})
const redirectPath = from.query.redirect || to.path
const redirectPath = from.query.redirect
// 修复跳转时不带参数的问题
const redirect = decodeURIComponent(redirectPath as string)
const { paramsObject: query } = parseURL(redirect)
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect, query }
const redirect = typeof redirectPath === 'string' ? redirectPath : to.fullPath
const redirectLocation = parseRouteLocation(redirect)
const nextData =
to.fullPath === redirect
? { ...to, replace: true }
: { ...redirectLocation, replace: true }
next(nextData)
} else {
next()
@ -95,7 +66,7 @@ router.beforeEach(async (to, from, next) => {
if (whiteList.indexOf(to.path) !== -1) {
next()
} else {
next(`/login?redirect=${to.fullPath}`) // 否则全部重定向到登录页
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`) // 否则全部重定向到登录页
}
}
})

View File

@ -4,6 +4,7 @@ import { LayoutType } from '@/types/layout'
import { ThemeTypes } from '@/types/theme'
import { humpToUnderline, setCssVar } from '@/utils'
import { getCssColorVariable, hexToRGB, mix } from '@/utils/color'
import { normalizeLayout } from '@/utils/layout'
import { ElMessage } from 'element-plus'
import { defineStore } from 'pinia'
import { store } from '../index'
@ -68,7 +69,7 @@ export const useAppStore = defineStore('app', {
greyMode: false, // 是否开始灰色模式,用于特殊悼念日
fixedMenu: wsCache.get('fixedMenu') || false, // 是否固定菜单
layout: wsCache.get(CACHE_KEY.LAYOUT) || 'classic', // layout布局
layout: normalizeLayout(wsCache.get(CACHE_KEY.LAYOUT)), // layout布局
isDark: wsCache.get(CACHE_KEY.IS_DARK) || false, // 是否是暗黑模式
currentSize: wsCache.get('default') || 'default', // 组件尺寸
theme: wsCache.get(CACHE_KEY.THEME) || {
@ -271,11 +272,12 @@ export const useAppStore = defineStore('app', {
this.pageLoading = pageLoading
},
setLayout(layout: LayoutType) {
if (this.mobile && layout !== 'classic') {
const normalizedLayout = normalizeLayout(layout)
if (this.mobile && normalizedLayout !== 'sidebar-nav') {
ElMessage.warning('移动端模式下不支持切换其他布局')
return
}
this.layout = layout
this.layout = normalizedLayout
wsCache.set(CACHE_KEY.LAYOUT, this.layout)
},
setTitle(title: string) {

View File

@ -11,13 +11,15 @@ export interface PermissionState {
routers: AppRouteRecordRaw[]
addRouters: AppRouteRecordRaw[]
menuTabRouters: AppRouteRecordRaw[]
menuRootPath: string
}
export const usePermissionStore = defineStore('permission', {
state: (): PermissionState => ({
routers: [],
addRouters: [],
menuTabRouters: []
menuTabRouters: [],
menuRootPath: ''
}),
getters: {
getRouters(): AppRouteRecordRaw[] {
@ -28,6 +30,9 @@ export const usePermissionStore = defineStore('permission', {
},
getMenuTabRouters(): AppRouteRecordRaw[] {
return this.menuTabRouters
},
getMenuRootPath(): string {
return this.menuRootPath
}
},
actions: {
@ -61,6 +66,9 @@ export const usePermissionStore = defineStore('permission', {
},
setMenuTabRouters(routers: AppRouteRecordRaw[]): void {
this.menuTabRouters = routers
},
setMenuRootPath(path: string): void {
this.menuRootPath = path
}
},
persist: false

14
src/types/layout.d.ts vendored
View File

@ -1 +1,13 @@
export type LayoutType = 'classic' | 'topLeft' | 'top' | 'cutMenu'
export type LegacyLayoutType = 'classic' | 'topLeft' | 'top' | 'cutMenu'
export type VbenLayoutType =
| 'header-mixed-nav'
| 'header-nav'
| 'header-sidebar-nav'
| 'mixed-nav'
| 'sidebar-mixed-nav'
| 'sidebar-nav'
export type LayoutType = LegacyLayoutType | VbenLayoutType
export type LayoutRenderMode = LegacyLayoutType

65
src/utils/layout.ts Normal file
View File

@ -0,0 +1,65 @@
import type { LayoutRenderMode, LayoutType, VbenLayoutType } from '@/types/layout'
const DEFAULT_LAYOUT: VbenLayoutType = 'sidebar-nav'
const LEGACY_LAYOUT_MAP: Record<string, VbenLayoutType> = {
classic: 'sidebar-nav',
cutMenu: 'sidebar-mixed-nav',
top: 'header-nav',
topLeft: 'header-sidebar-nav'
}
const RENDER_MODE_MAP: Record<VbenLayoutType, LayoutRenderMode> = {
'header-mixed-nav': 'cutMenu',
'header-nav': 'top',
'header-sidebar-nav': 'topLeft',
'mixed-nav': 'topLeft',
'sidebar-mixed-nav': 'cutMenu',
'sidebar-nav': 'classic'
}
const VBEN_LAYOUTS = Object.keys(RENDER_MODE_MAP)
export const normalizeLayout = (layout?: LayoutType | string | null): VbenLayoutType => {
if (!layout) {
return DEFAULT_LAYOUT
}
if (VBEN_LAYOUTS.includes(layout)) {
return layout as VbenLayoutType
}
return LEGACY_LAYOUT_MAP[layout] || DEFAULT_LAYOUT
}
export const getLayoutRenderMode = (layout?: LayoutType | string | null): LayoutRenderMode => {
return RENDER_MODE_MAP[normalizeLayout(layout)]
}
export const isHeaderNavLayout = (layout?: LayoutType | string | null): boolean => {
return normalizeLayout(layout) === 'header-nav'
}
export const isHorizontalMenuLayout = (layout?: LayoutType | string | null): boolean => {
const normalized = normalizeLayout(layout)
return normalized === 'header-nav' || normalized === 'mixed-nav' || normalized === 'header-mixed-nav'
}
export const isMixedNavLayout = (layout?: LayoutType | string | null): boolean => {
return normalizeLayout(layout) === 'mixed-nav'
}
export const isHeaderMixedNavLayout = (layout?: LayoutType | string | null): boolean => {
return normalizeLayout(layout) === 'header-mixed-nav'
}
export const isTwoColumnLayout = (layout?: LayoutType | string | null): boolean => {
const normalized = normalizeLayout(layout)
return normalized === 'sidebar-mixed-nav' || normalized === 'header-mixed-nav'
}
export const getRootPath = (path = '/'): string => {
if (!path || path === '/') {
return '/'
}
const segments = path.split('/').filter(Boolean)
return segments.length ? `/${segments[0]}` : '/'
}

161
src/utils/routeParams.ts Normal file
View File

@ -0,0 +1,161 @@
import type { RouteLocationRaw } from 'vue-router'
import qs from 'qs'
type RouteMetaLike = {
hash?: string
iframeSrc?: string
link?: string
params?: Record<string, any>
query?: Record<string, any>
}
export interface ParsedRouteLocation {
hash?: string
iframe?: boolean
path: string
query?: Record<string, any>
}
const ROUTE_IFRAME_QUERY_KEY = '_iframe'
const EXTERNAL_LINK_ROUTE_PREFIX = '/external-link'
export const parseQueryString = (queryString = ''): Record<string, any> => {
return qs.parse(queryString.replace(/^\?/, '')) as Record<string, any>
}
export const splitRoutePath = (
rawPath: string | null | undefined
): ParsedRouteLocation => {
if (!rawPath) {
return { path: '' }
}
const hashIndex = rawPath.indexOf('#')
const pathWithQuery = hashIndex === -1 ? rawPath : rawPath.slice(0, hashIndex)
const hash = hashIndex === -1 ? undefined : rawPath.slice(hashIndex)
const questionMarkIndex = pathWithQuery.indexOf('?')
if (questionMarkIndex === -1) {
return {
...(hash ? { hash } : {}),
path: pathWithQuery
}
}
const path = pathWithQuery.slice(0, questionMarkIndex)
const query = parseQueryString(pathWithQuery.slice(questionMarkIndex + 1))
return {
...(hash ? { hash } : {}),
path,
...(Object.keys(query).length ? { query } : {})
}
}
export const parseRouteLocation = (rawPath: string | null | undefined): ParsedRouteLocation => {
return splitRoutePath(rawPath)
}
export const parseExternalRouteLocation = (rawPath: string): ParsedRouteLocation => {
try {
const url = new URL(rawPath)
if (!url.searchParams.has(ROUTE_IFRAME_QUERY_KEY)) {
return { path: rawPath }
}
url.searchParams.delete(ROUTE_IFRAME_QUERY_KEY)
return {
iframe: true,
path: url.toString()
}
} catch {
return { path: rawPath }
}
}
export const getExternalRoutePath = (id: number | string | undefined, name: string): string => {
return `${EXTERNAL_LINK_ROUTE_PREFIX}/${encodeURIComponent(String(id || name))}`
}
export const getDynamicPathParamNames = (path: string): string[] => {
const names: string[] = []
path.replace(/:([A-Za-z0-9_]+)(?:\([^/]*\))?[?+*]?/g, (_matched, key) => {
names.push(key)
return _matched
})
return names
}
export const splitDynamicRouteParams = (
path: string,
query?: Record<string, any>
): {
params?: Record<string, any>
query?: Record<string, any>
} => {
if (!query || !Object.keys(query).length) {
return {}
}
const paramNames = getDynamicPathParamNames(path)
if (!paramNames.length) {
return { query }
}
const nextQuery = { ...query }
const params: Record<string, any> = {}
paramNames.forEach((name) => {
const value = nextQuery[name]
if (value === undefined || value === null || value === '') {
return
}
params[name] = value
delete nextQuery[name]
})
return {
...(Object.keys(params).length ? { params } : {}),
...(Object.keys(nextQuery).length ? { query: nextQuery } : {})
}
}
const isRepeatableParam = (matched: string): boolean => matched.endsWith('*') || matched.endsWith('+')
const encodeRouteParam = (matched: string, value: any): string => {
if (Array.isArray(value)) {
const encodedSegments = value.map((item) => encodeURIComponent(String(item)))
return isRepeatableParam(matched)
? encodedSegments.join('/')
: encodeURIComponent(value.map((item) => String(item)).join(','))
}
return encodeURIComponent(String(value))
}
export const resolveDynamicPath = (path: string, params?: Record<string, any>): string => {
if (!params) {
return path
}
return path.replace(/:([A-Za-z0-9_]+)(?:\([^/]*\))?[?+*]?/g, (matched, key) => {
const value = params[key]
return value === undefined || value === null ? matched : encodeRouteParam(matched, value)
})
}
export const createRouteLocation = (
path: string,
meta?: RouteMetaLike,
routeName?: string
): RouteLocationRaw => {
const hash = meta?.hash
const params = meta?.params
const query = meta?.query
if (params && Object.keys(params).length > 0 && routeName) {
return {
...(hash ? { hash } : {}),
name: routeName,
params,
query
}
}
return {
...(hash ? { hash } : {}),
path: resolveDynamicPath(path, params),
query
}
}

View File

@ -3,7 +3,12 @@ import { defineComponent, h } from 'vue'
import { createRouter, createWebHashHistory, RouteRecordRaw, RouterView } from 'vue-router'
import { isUrl } from '@/utils/is'
import { cloneDeep, omit } from 'lodash-es'
import qs from 'qs'
import {
getExternalRoutePath,
parseExternalRouteLocation,
splitDynamicRouteParams,
splitRoutePath
} from '@/utils/routeParams'
const modules = import.meta.glob('../views/**/*.{vue,tsx}')
/**
@ -33,6 +38,8 @@ const ParentLayout = defineComponent({
export const getParentLayout = () => ParentLayout
export const IFrameView = () => import('@/views/IFrame/index.vue')
// 按照路由中meta下的rank等级升序来排序路由
export const ascending = (arr: any[]) => {
arr.forEach((v) => {
@ -68,6 +75,16 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
const res: AppRouteRecordRaw[] = []
const modulesRoutesKeys = Object.keys(modules)
for (const route of routes) {
const componentRoute = splitRoutePath(route.component)
const pathRoute = isUrl(route.path)
? parseExternalRouteLocation(route.path)
: splitRoutePath(route.path)
if (componentRoute.path) {
route.component = componentRoute.path
}
if (pathRoute.path) {
route.path = pathRoute.path
}
// 1. 生成 meta 菜单元数据
const meta = {
title: route.name,
@ -79,20 +96,31 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
route.children.length > 0 &&
(route.alwaysShow !== undefined ? route.alwaysShow : true)
} as any
// 特殊逻辑:如果后端配置的 MenuDO.component 包含 ?,则表示需要传递参数
// 此时,我们需要解析参数,并且将参数放到 meta.query 中
// 这样,后续在 Vue 文件中,可以通过 const { currentRoute } = useRouter() 中,通过 meta.query 获取到参数
if (route.component && route.component.indexOf('?') > -1) {
const query = route.component.split('?')[1]
route.component = route.component.split('?')[0]
meta.query = qs.parse(query)
// 后端 MenuDO.component 或 path 可通过 ?/# 携带菜单参数,对齐 vben 的 meta.query/hash/params 行为。
const routeQuery = {
...((route.meta?.query as Record<string, any> | undefined) || {}),
...(pathRoute.query || {}),
...(componentRoute.query || {})
}
const routeParams = splitDynamicRouteParams(pathRoute.path || route.path, routeQuery)
const metaParams = {
...((route.meta?.params as Record<string, any> | undefined) || {}),
...(routeParams.params || {})
}
if (routeParams.query) {
meta.query = routeParams.query
}
if (Object.keys(metaParams).length) {
meta.params = metaParams
}
if (route.meta?.hash || pathRoute.hash || componentRoute.hash) {
meta.hash = componentRoute.hash || pathRoute.hash || route.meta.hash
}
// 2. 生成 dataAppRouteRecordRaw
// 路由地址转首字母大写驼峰作为路由名称适配keepAlive
let data: AppRouteRecordRaw = {
path:
route.path.indexOf('?') > -1 && !isUrl(route.path) ? route.path.split('?')[0] : route.path, // 注意,需要排除 http 这种 url避免它带 ? 参数被截取掉
path: route.path,
name:
route.componentName && route.componentName.length > 0
? route.componentName
@ -131,13 +159,26 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
data.redirect = getRedirect(route.path, route.children)
// 外链
} else if (isUrl(route.path)) {
const externalPath = getExternalRoutePath(route.id, data.name)
const externalChild = {
...data,
path: '',
component: IFrameView,
meta: {
...meta,
iframeSrc: pathRoute.path,
...(pathRoute.iframe ? {} : { link: route.path })
}
} as AppRouteRecordRaw
data = {
path: '/external-link',
path: externalPath,
name: `${data.name}ExternalParent`,
component: Layout,
meta: {
name: route.name
...meta,
...(pathRoute.iframe ? { iframeSrc: pathRoute.path } : { link: route.path })
},
children: [data]
children: [externalChild]
} as AppRouteRecordRaw
// 菜单
} else {

View File

@ -0,0 +1,13 @@
<template>
<ContentWrap :bodyStyle="{ padding: '0px' }" class="!mb-0">
<IFrame :src="src" />
</ContentWrap>
</template>
<script lang="ts" setup>
defineOptions({ name: 'IFrameView' })
const route = useRoute()
const src = computed(() => (route.meta.iframeSrc || route.meta.link || '') as string)
</script>

6
types/router.d.ts vendored
View File

@ -48,6 +48,11 @@ declare module 'vue-router' {
noTagsView?: boolean
followAuth?: string
canTo?: boolean
hash?: string
iframeSrc?: string
link?: string
params?: Recordable
query?: Recordable
}
}
@ -73,6 +78,7 @@ declare global {
meta: RouteMeta
component: string
componentName?: string
id?: number | string
path: string
redirect: string
children?: AppCustomRouteRecordRaw[]