feat(layout): 对齐 Vben 5 布局与菜单路由行为
- 补齐六种 Vben 布局模式及设置面板入口 - 支持顶部根菜单、侧边 split 菜单、混合布局与双列菜单联动 - 支持菜单路由 query/hash/params、动态路径与登录重定向保参 - 外链路由唯一化,并支持 iframe 外链页面 - 调整设置入口、面包屑与折叠按钮展示逻辑 - 修复水平菜单更多弹层,仅展示溢出根菜单并避免原生弹层重复 - 新增布局路由与交互自测脚本master
parent
394a3d075a
commit
74aaa6605e
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
]"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import Setting from './src/Setting.vue'
|
||||
import { useSetting } from './src/useSetting'
|
||||
|
||||
export { Setting }
|
||||
export { useSetting }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import { ref } from 'vue'
|
||||
|
||||
const drawerVisible = ref(false)
|
||||
|
||||
export const useSetting = () => {
|
||||
const openSetting = () => {
|
||||
drawerVisible.value = true
|
||||
}
|
||||
|
||||
return {
|
||||
drawerVisible,
|
||||
openSetting
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 报错
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`) // 否则全部重定向到登录页
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]}` : '/'
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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. 生成 data(AppRouteRecordRaw)
|
||||
// 路由地址转首字母大写驼峰,作为路由名称,适配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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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[]
|
||||
|
|
|
|||
Loading…
Reference in New Issue