feat(layout): 对齐 Vben 5 布局与菜单路由行为
- 补齐六种 Vben 布局模式及设置面板入口 - 支持顶部根菜单、侧边 split 菜单、混合布局与双列菜单联动 - 支持菜单路由 query/hash/params、动态路径与登录重定向保参 - 外链路由唯一化,并支持 iframe 外链页面 - 调整设置入口、面包屑与折叠按钮展示逻辑 - 修复水平菜单更多弹层,仅展示溢出根菜单并避免原生弹层重复 - 新增布局路由与交互自测脚本pull/884/head
parent
394a3d075a
commit
74aaa6605e
|
|
@ -7,6 +7,7 @@ import { useWindowSize } from '@vueuse/core'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import { setCssVar } from '@/utils'
|
import { setCssVar } from '@/utils'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
import { useDesign } from '@/hooks/web/useDesign'
|
||||||
|
import { normalizeLayout } from '@/utils/layout'
|
||||||
|
|
||||||
const { variables } = useDesign()
|
const { variables } = useDesign()
|
||||||
|
|
||||||
|
|
@ -33,7 +34,9 @@ watch(
|
||||||
!appStore.getMobile ? appStore.setMobile(true) : undefined
|
!appStore.getMobile ? appStore.setMobile(true) : undefined
|
||||||
setCssVar('--left-menu-min-width', '0')
|
setCssVar('--left-menu-min-width', '0')
|
||||||
appStore.setCollapse(true)
|
appStore.setCollapse(true)
|
||||||
appStore.getLayout !== 'classic' ? appStore.setLayout('classic') : undefined
|
normalizeLayout(appStore.getLayout) !== 'sidebar-nav'
|
||||||
|
? appStore.setLayout('sidebar-nav')
|
||||||
|
: undefined
|
||||||
} else {
|
} else {
|
||||||
appStore.getMobile ? appStore.setMobile(false) : undefined
|
appStore.getMobile ? appStore.setMobile(false) : undefined
|
||||||
setCssVar('--left-menu-min-width', '64px')
|
setCssVar('--left-menu-min-width', '64px')
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ const props = defineProps({
|
||||||
src: propTypes.string.def('')
|
src: propTypes.string.def('')
|
||||||
})
|
})
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const frameRef = ref<HTMLElement | null>(null)
|
const frameRef = ref<HTMLIFrameElement | null>(null)
|
||||||
const init = () => {
|
const init = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
@ -20,6 +20,11 @@ const init = () => {
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
init()
|
init()
|
||||||
})
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!frameRef.value) return
|
||||||
|
frameRef.value.onload = null
|
||||||
|
frameRef.value.src = 'about:blank'
|
||||||
|
})
|
||||||
watch(
|
watch(
|
||||||
() => props.src,
|
() => props.src,
|
||||||
() => {
|
() => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { Backtop } from '@/components/Backtop'
|
||||||
import { Setting } from '@/layout/components/Setting'
|
import { Setting } from '@/layout/components/Setting'
|
||||||
import { useRenderLayout } from './components/useRenderLayout'
|
import { useRenderLayout } from './components/useRenderLayout'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
import { useDesign } from '@/hooks/web/useDesign'
|
||||||
|
import { getLayoutRenderMode } from '@/utils/layout'
|
||||||
|
|
||||||
const { getPrefixCls } = useDesign()
|
const { getPrefixCls } = useDesign()
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ const handleClickOutside = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderLayout = () => {
|
const renderLayout = () => {
|
||||||
switch (unref(layout)) {
|
switch (getLayoutRenderMode(unref(layout))) {
|
||||||
case 'classic':
|
case 'classic':
|
||||||
const { renderClassic } = useRenderLayout()
|
const { renderClassic } = useRenderLayout()
|
||||||
return renderClassic()
|
return renderClassic()
|
||||||
|
|
@ -47,7 +48,14 @@ export default defineComponent({
|
||||||
name: 'Layout',
|
name: 'Layout',
|
||||||
setup() {
|
setup() {
|
||||||
return () => (
|
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 ? (
|
{mobile.value && !collapse.value ? (
|
||||||
<div
|
<div
|
||||||
class="absolute left-0 top-0 z-99 h-full w-full bg-[var(--el-color-black)] opacity-30"
|
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 { computed, onMounted, ref, unref, watch } from 'vue'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
import { useDesign } from '@/hooks/web/useDesign'
|
||||||
|
import { getLayoutRenderMode, isHeaderNavLayout } from '@/utils/layout'
|
||||||
|
|
||||||
defineOptions({ name: 'Logo' })
|
defineOptions({ name: 'Logo' })
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ onMounted(() => {
|
||||||
watch(
|
watch(
|
||||||
() => collapse.value,
|
() => collapse.value,
|
||||||
(collapse: boolean) => {
|
(collapse: boolean) => {
|
||||||
if (unref(layout) === 'topLeft' || unref(layout) === 'cutMenu') {
|
if (getLayoutRenderMode(unref(layout)) === 'topLeft' || getLayoutRenderMode(unref(layout)) === 'cutMenu') {
|
||||||
show.value = true
|
show.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +44,8 @@ watch(
|
||||||
watch(
|
watch(
|
||||||
() => layout.value,
|
() => layout.value,
|
||||||
(layout) => {
|
(layout) => {
|
||||||
if (layout === 'top' || layout === 'cutMenu') {
|
const renderMode = getLayoutRenderMode(layout)
|
||||||
|
if (renderMode === 'top' || renderMode === 'cutMenu') {
|
||||||
show.value = true
|
show.value = true
|
||||||
} else {
|
} else {
|
||||||
if (unref(collapse)) {
|
if (unref(collapse)) {
|
||||||
|
|
@ -61,7 +63,7 @@ watch(
|
||||||
<router-link
|
<router-link
|
||||||
:class="[
|
:class="[
|
||||||
prefixCls,
|
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'
|
'flex !h-[var(--logo-height)] items-center cursor-pointer pl-8px relative decoration-none overflow-hidden'
|
||||||
]"
|
]"
|
||||||
to="/"
|
to="/"
|
||||||
|
|
@ -75,9 +77,11 @@ watch(
|
||||||
:class="[
|
:class="[
|
||||||
'ml-10px text-16px font-700',
|
'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)]':
|
'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">
|
<script lang="tsx">
|
||||||
import { PropType } from 'vue'
|
import { PropType, nextTick, onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { ElMenu, ElScrollbar } from 'element-plus'
|
import { ElMenu, ElScrollbar } from 'element-plus'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import { usePermissionStore } from '@/store/modules/permission'
|
import { usePermissionStore } from '@/store/modules/permission'
|
||||||
import { useRenderMenuItem } from './components/useRenderMenuItem'
|
import { useRenderMenuItem } from './components/useRenderMenuItem'
|
||||||
|
import { hasOneShowingChild } from './helper'
|
||||||
import { isUrl } from '@/utils/is'
|
import { isUrl } from '@/utils/is'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
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()
|
const { getPrefixCls } = useDesign()
|
||||||
|
|
||||||
|
|
@ -15,9 +29,29 @@ const prefixCls = getPrefixCls('menu')
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'Menu',
|
name: 'Menu',
|
||||||
props: {
|
props: {
|
||||||
|
mode: {
|
||||||
|
type: String as PropType<'horizontal' | 'vertical'>,
|
||||||
|
default: undefined
|
||||||
|
},
|
||||||
menuSelect: {
|
menuSelect: {
|
||||||
type: Function as PropType<(index: string) => void>,
|
type: Function as PropType<(index: string) => void>,
|
||||||
default: undefined
|
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) {
|
setup(props) {
|
||||||
|
|
@ -25,39 +59,316 @@ export default defineComponent({
|
||||||
|
|
||||||
const layout = computed(() => appStore.getLayout)
|
const layout = computed(() => appStore.getLayout)
|
||||||
|
|
||||||
const { push, currentRoute } = useRouter()
|
const { push, currentRoute, resolve } = useRouter()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const permissionStore = usePermissionStore()
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
const menuMode = computed((): 'vertical' | 'horizontal' => {
|
const menuWrapRef = ref<HTMLElement>()
|
||||||
// 竖
|
|
||||||
const vertical: LayoutType[] = ['classic', 'topLeft', 'cutMenu']
|
|
||||||
|
|
||||||
if (vertical.includes(unref(layout))) {
|
const menuRef = ref<InstanceType<typeof ElMenu>>()
|
||||||
return 'vertical'
|
|
||||||
} else {
|
|
||||||
return 'horizontal'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const routers = computed(() =>
|
const menuMode = computed((): 'vertical' | 'horizontal' =>
|
||||||
unref(layout) === 'cutMenu' ? permissionStore.getMenuTabRouters : permissionStore.getRouters
|
props.mode || (isHorizontalMenuLayout(unref(layout)) ? 'horizontal' : 'vertical')
|
||||||
)
|
)
|
||||||
|
|
||||||
const collapse = computed(() => appStore.getCollapse)
|
const collapse = computed(() => appStore.getCollapse)
|
||||||
|
|
||||||
const uniqueOpened = computed(() => appStore.getUniqueOpened)
|
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 activeMenu = computed(() => {
|
||||||
const { meta, path } = unref(currentRoute)
|
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 set path, the sidebar will highlight the path you set
|
||||||
if (meta.activeMenu) {
|
if (meta.activeMenu) {
|
||||||
return meta.activeMenu as string
|
return normalizeMenuTargetPath(meta.activeMenu as string)
|
||||||
}
|
}
|
||||||
return path
|
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) => {
|
const menuSelect = (index: string) => {
|
||||||
|
horizontalOverflowOpened.value = false
|
||||||
if (props.menuSelect) {
|
if (props.menuSelect) {
|
||||||
props.menuSelect(index)
|
props.menuSelect(index)
|
||||||
}
|
}
|
||||||
|
|
@ -65,12 +376,34 @@ export default defineComponent({
|
||||||
if (isUrl(index)) {
|
if (isUrl(index)) {
|
||||||
window.open(index)
|
window.open(index)
|
||||||
} else {
|
} 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 = () => {
|
const renderMenuWrap = () => {
|
||||||
if (unref(layout) === 'top') {
|
if (isHeaderNavLayout(unref(layout)) || unref(menuMode) === 'horizontal') {
|
||||||
return renderMenu()
|
return renderMenu()
|
||||||
} else {
|
} else {
|
||||||
return <ElScrollbar>{renderMenu()}</ElScrollbar>
|
return <ElScrollbar>{renderMenu()}</ElScrollbar>
|
||||||
|
|
@ -80,25 +413,31 @@ export default defineComponent({
|
||||||
const renderMenu = () => {
|
const renderMenu = () => {
|
||||||
return (
|
return (
|
||||||
<ElMenu
|
<ElMenu
|
||||||
|
ref={menuRef}
|
||||||
defaultActive={unref(activeMenu)}
|
defaultActive={unref(activeMenu)}
|
||||||
|
defaultOpeneds={unref(defaultOpeneds)}
|
||||||
mode={unref(menuMode)}
|
mode={unref(menuMode)}
|
||||||
|
menuTrigger={props.rootOnly && unref(menuMode) === 'horizontal' ? 'click' : 'hover'}
|
||||||
collapse={
|
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)}
|
uniqueOpened={unref(menuMode) === 'horizontal' ? false : unref(uniqueOpened)}
|
||||||
backgroundColor="var(--left-menu-bg-color)"
|
backgroundColor={unref(menuBgColor)}
|
||||||
textColor="var(--left-menu-text-color)"
|
ellipsis={unref(menuMode) === 'horizontal' ? true : undefined}
|
||||||
activeTextColor="var(--left-menu-text-active-color)"
|
textColor={unref(menuTextColor)}
|
||||||
|
activeTextColor={unref(menuActiveTextColor)}
|
||||||
popperClass={
|
popperClass={
|
||||||
unref(menuMode) === 'vertical'
|
unref(menuMode) === 'vertical'
|
||||||
? `${prefixCls}-popper--vertical`
|
? `${prefixCls}-popper--vertical ${prefixCls}-popper--${props.theme}`
|
||||||
: `${prefixCls}-popper--horizontal`
|
: `${prefixCls}-popper--horizontal ${prefixCls}-popper--${props.theme}`
|
||||||
}
|
}
|
||||||
onSelect={menuSelect}
|
onSelect={menuSelect}
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
default: () => {
|
default: () => {
|
||||||
const { renderMenuItem } = useRenderMenuItem(unref(menuMode))
|
const { renderMenuItem } = useRenderMenuItem()
|
||||||
return renderMenuItem(unref(routers))
|
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 () => (
|
return () => (
|
||||||
<div
|
<div
|
||||||
id={prefixCls}
|
id={prefixCls}
|
||||||
|
ref={menuWrapRef}
|
||||||
class={[
|
class={[
|
||||||
`${prefixCls} ${prefixCls}__${unref(menuMode)}`,
|
`${prefixCls} ${prefixCls}__${unref(menuMode)}`,
|
||||||
|
`${prefixCls}--${props.theme}`,
|
||||||
'h-[100%] overflow-hidden flex-col bg-[var(--left-menu-bg-color)]',
|
'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-min-width)]':
|
||||||
'w-[var(--left-menu-max-width)]': !unref(collapse) && unref(layout) !== 'cutMenu'
|
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()}
|
{renderMenuWrap()}
|
||||||
|
{renderHorizontalOverflowFallback()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -207,10 +638,15 @@ $prefix-cls: #{$namespace}-menu;
|
||||||
// 水平菜单
|
// 水平菜单
|
||||||
&__horizontal {
|
&__horizontal {
|
||||||
height: calc(var(--top-tool-height)) !important;
|
height: calc(var(--top-tool-height)) !important;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: hidden;
|
||||||
|
|
||||||
:deep(.#{$elNamespace}-menu--horizontal) {
|
:deep(.#{$elNamespace}-menu--horizontal) {
|
||||||
height: calc(var(--top-tool-height));
|
height: calc(var(--top-tool-height));
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
|
min-width: 100%;
|
||||||
// 重新设置底部高亮颜色
|
// 重新设置底部高亮颜色
|
||||||
& > .#{$elNamespace}-sub-menu.is-active {
|
& > .#{$elNamespace}-sub-menu.is-active {
|
||||||
.#{$elNamespace}-sub-menu__title {
|
.#{$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>
|
</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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,43 @@ import { hasOneShowingChild } from '../helper'
|
||||||
import { isUrl } from '@/utils/is'
|
import { isUrl } from '@/utils/is'
|
||||||
import { useRenderMenuTitle } from './useRenderMenuTitle'
|
import { useRenderMenuTitle } from './useRenderMenuTitle'
|
||||||
import { pathResolve } from '@/utils/routerHelper'
|
import { pathResolve } from '@/utils/routerHelper'
|
||||||
|
import { resolveDynamicPath } from '@/utils/routeParams'
|
||||||
|
|
||||||
const { renderMenuTitle } = useRenderMenuTitle()
|
const { renderMenuTitle } = useRenderMenuTitle()
|
||||||
|
|
||||||
export const useRenderMenuItem = () =>
|
export const useRenderMenuItem = () =>
|
||||||
// allRouters: AppRouteRecordRaw[] = [],
|
// allRouters: AppRouteRecordRaw[] = [],
|
||||||
{
|
{
|
||||||
const renderMenuItem = (routers: AppRouteRecordRaw[], parentPath = '/') => {
|
const renderMenuItem = (routers: AppRouteRecordRaw[] = [], parentPath = '/') => {
|
||||||
return routers
|
return routers
|
||||||
.filter((v) => !v.meta?.hidden)
|
.filter((v) => !v.meta?.hidden)
|
||||||
.map((v) => {
|
.map((v) => {
|
||||||
const meta = v.meta ?? {}
|
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 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 (
|
if (
|
||||||
|
!children.length ||
|
||||||
oneShowingChild &&
|
oneShowingChild &&
|
||||||
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
|
(!onlyOneChild?.children || onlyOneChild?.noShowingChildren) &&
|
||||||
!meta?.alwaysShow
|
!meta?.alwaysShow
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
index={onlyOneChild ? pathResolve(fullPath, onlyOneChild.path) : fullPath}
|
index={resolvedOnlyOneChildPath}
|
||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
|
default: () => renderMenuTitle(onlyOneChild ? onlyOneChild?.meta : meta)
|
||||||
|
|
@ -33,10 +48,10 @@ export const useRenderMenuItem = () =>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<ElSubMenu index={fullPath}>
|
<ElSubMenu index={resolvedFullPath}>
|
||||||
{{
|
{{
|
||||||
title: () => renderMenuTitle(meta),
|
title: () => renderMenuTitle(meta),
|
||||||
default: () => renderMenuItem(v.children!, fullPath)
|
default: () => renderMenuItem(children, resolvedFullPath)
|
||||||
}}
|
}}
|
||||||
</ElSubMenu>
|
</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 Setting from './src/Setting.vue'
|
||||||
|
import { useSetting } from './src/useSetting'
|
||||||
|
|
||||||
export { Setting }
|
export { Setting }
|
||||||
|
export { useSetting }
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { ElMessage } from 'element-plus'
|
||||||
import { useClipboard, useCssVar } from '@vueuse/core'
|
import { useClipboard, useCssVar } from '@vueuse/core'
|
||||||
|
|
||||||
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
|
import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
|
||||||
|
|
||||||
import { setCssVar, trim } from '@/utils'
|
import { setCssVar, trim } from '@/utils'
|
||||||
import { colorIsDark, hexToRGB, lighten } from '@/utils/color'
|
import { colorIsDark, hexToRGB, lighten } from '@/utils/color'
|
||||||
|
|
@ -12,16 +11,15 @@ import { ThemeSwitch } from '@/layout/components/ThemeSwitch'
|
||||||
import ColorRadioPicker from './components/ColorRadioPicker.vue'
|
import ColorRadioPicker from './components/ColorRadioPicker.vue'
|
||||||
import InterfaceDisplay from './components/InterfaceDisplay.vue'
|
import InterfaceDisplay from './components/InterfaceDisplay.vue'
|
||||||
import LayoutRadioPicker from './components/LayoutRadioPicker.vue'
|
import LayoutRadioPicker from './components/LayoutRadioPicker.vue'
|
||||||
|
import { isHeaderNavLayout } from '@/utils/layout'
|
||||||
|
import { useSetting } from './useSetting'
|
||||||
|
|
||||||
defineOptions({ name: 'Setting' })
|
defineOptions({ name: 'Setting' })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const { getPrefixCls } = useDesign()
|
|
||||||
const prefixCls = getPrefixCls('setting')
|
|
||||||
const layout = computed(() => appStore.getLayout)
|
const layout = computed(() => appStore.getLayout)
|
||||||
const drawer = ref(false)
|
const { drawerVisible } = useSetting()
|
||||||
|
|
||||||
// 主题色相关
|
// 主题色相关
|
||||||
const systemTheme = ref(appStore.getTheme.elColorPrimary)
|
const systemTheme = ref(appStore.getTheme.elColorPrimary)
|
||||||
|
|
@ -30,7 +28,7 @@ const setSystemTheme = (color: string) => {
|
||||||
setCssVar('--el-color-primary', color)
|
setCssVar('--el-color-primary', color)
|
||||||
appStore.setTheme({ elColorPrimary: color })
|
appStore.setTheme({ elColorPrimary: color })
|
||||||
const leftMenuBgColor = useCssVar('--left-menu-bg-color', document.documentElement)
|
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,
|
topHeaderHoverColor: textHoverColor,
|
||||||
topToolBorderColor
|
topToolBorderColor
|
||||||
})
|
})
|
||||||
if (unref(layout) === 'top') {
|
if (isHeaderNavLayout(unref(layout))) {
|
||||||
setMenuTheme(color)
|
setMenuTheme(color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -71,11 +69,11 @@ const setMenuTheme = (color: string) => {
|
||||||
// 左侧菜单选中背景颜色
|
// 左侧菜单选中背景颜色
|
||||||
leftMenuBgActiveColor: isDarkColor
|
leftMenuBgActiveColor: isDarkColor
|
||||||
? 'var(--el-color-primary)'
|
? 'var(--el-color-primary)'
|
||||||
: hexToRGB(unref(primaryColor), 0.1),
|
: hexToRGB(unref(primaryColor) || '#409eff', 0.1),
|
||||||
// 左侧菜单收起选中背景颜色
|
// 左侧菜单收起选中背景颜色
|
||||||
leftMenuCollapseBgActiveColor: isDarkColor
|
leftMenuCollapseBgActiveColor: isDarkColor
|
||||||
? 'var(--el-color-primary)'
|
? 'var(--el-color-primary)'
|
||||||
: hexToRGB(unref(primaryColor), 0.1),
|
: hexToRGB(unref(primaryColor) || '#409eff', 0.1),
|
||||||
// 左侧菜单字体颜色
|
// 左侧菜单字体颜色
|
||||||
leftMenuTextColor: isDarkColor ? '#bfcbd9' : '#333',
|
leftMenuTextColor: isDarkColor ? '#bfcbd9' : '#333',
|
||||||
// 左侧菜单选中字体颜色
|
// 左侧菜单选中字体颜色
|
||||||
|
|
@ -88,7 +86,7 @@ const setMenuTheme = (color: string) => {
|
||||||
appStore.setTheme(theme)
|
appStore.setTheme(theme)
|
||||||
appStore.setCssVarTheme()
|
appStore.setCssVarTheme()
|
||||||
}
|
}
|
||||||
if (layout.value === 'top' && !appStore.getIsDark) {
|
if (isHeaderNavLayout(layout.value) && !appStore.getIsDark) {
|
||||||
headerTheme.value = '#fff'
|
headerTheme.value = '#fff'
|
||||||
setHeaderTheme('#fff')
|
setHeaderTheme('#fff')
|
||||||
}
|
}
|
||||||
|
|
@ -97,7 +95,7 @@ if (layout.value === 'top' && !appStore.getIsDark) {
|
||||||
watch(
|
watch(
|
||||||
() => layout.value,
|
() => layout.value,
|
||||||
(n) => {
|
(n) => {
|
||||||
if (n === 'top' && !appStore.getIsDark) {
|
if (isHeaderNavLayout(n) && !appStore.getIsDark) {
|
||||||
headerTheme.value = '#fff'
|
headerTheme.value = '#fff'
|
||||||
setHeaderTheme('#fff')
|
setHeaderTheme('#fff')
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -201,15 +199,7 @@ const clear = () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<ElDrawer v-model="drawerVisible" :z-index="4000" direction="rtl" size="350px">
|
||||||
: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">
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="text-16px font-700">{{ t('setting.projectSetting') }}</span>
|
<span class="text-16px font-700">{{ t('setting.projectSetting') }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -258,7 +248,7 @@ const clear = () => {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 菜单主题 -->
|
<!-- 菜单主题 -->
|
||||||
<template v-if="layout !== 'top'">
|
<template v-if="!isHeaderNavLayout(layout)">
|
||||||
<ElDivider>{{ t('setting.menuTheme') }}</ElDivider>
|
<ElDivider>{{ t('setting.menuTheme') }}</ElDivider>
|
||||||
<ColorRadioPicker
|
<ColorRadioPicker
|
||||||
v-model="menuTheme"
|
v-model="menuTheme"
|
||||||
|
|
@ -292,12 +282,3 @@ const clear = () => {
|
||||||
</div>
|
</div>
|
||||||
</ElDrawer>
|
</ElDrawer>
|
||||||
</template>
|
</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 { useDesign } from '@/hooks/web/useDesign'
|
||||||
import { useWatermark } from '@/hooks/web/useWatermark'
|
import { useWatermark } from '@/hooks/web/useWatermark'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { isHeaderNavLayout } from '@/utils/layout'
|
||||||
|
|
||||||
defineOptions({ name: 'InterfaceDisplay' })
|
defineOptions({ name: 'InterfaceDisplay' })
|
||||||
|
|
||||||
|
|
@ -139,7 +140,7 @@ const layout = computed(() => appStore.getLayout)
|
||||||
watch(
|
watch(
|
||||||
() => layout.value,
|
() => layout.value,
|
||||||
(n) => {
|
(n) => {
|
||||||
if (n === 'top') {
|
if (isHeaderNavLayout(n)) {
|
||||||
appStore.setCollapse(false)
|
appStore.setCollapse(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -228,9 +229,9 @@ watch(
|
||||||
<ElSwitch v-model="fixedMenu" @change="fixedMenuChange" />
|
<ElSwitch v-model="fixedMenu" @change="fixedMenuChange" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between gap-10px">
|
||||||
<span class="text-14px">{{ t('watermark.watermark') }}</span>
|
<span class="shrink-0 whitespace-nowrap text-14px">{{ t('watermark.watermark') }}</span>
|
||||||
<ElInput v-model="water" class="right-1 w-20" @change="setWater()" />
|
<ElInput v-model="water" class="min-w-0 flex-1" @change="setWater()" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
import { useDesign } from '@/hooks/web/useDesign'
|
||||||
|
import type { VbenLayoutType } from '@/types/layout'
|
||||||
|
|
||||||
defineOptions({ name: 'LayoutRadioPicker' })
|
defineOptions({ name: 'LayoutRadioPicker' })
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const { getPrefixCls } = useDesign()
|
const { getPrefixCls } = useDesign()
|
||||||
|
|
||||||
const prefixCls = getPrefixCls('layout-radio-picker')
|
const prefixCls = getPrefixCls('layout-radio-picker')
|
||||||
|
|
@ -11,51 +13,68 @@ const prefixCls = getPrefixCls('layout-radio-picker')
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
const layout = computed(() => appStore.getLayout)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="prefixCls" class="flex flex-wrap space-x-14px">
|
<div :class="prefixCls" class="grid grid-cols-3 gap-14px">
|
||||||
<div
|
<div
|
||||||
:class="[
|
v-for="item in layouts"
|
||||||
`${prefixCls}__classic`,
|
:key="item.type"
|
||||||
'relative w-56px h-48px cursor-pointer bg-gray-300',
|
class="flex cursor-pointer flex-col items-center gap-6px"
|
||||||
{
|
@click="appStore.setLayout(item.type)"
|
||||||
'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')"
|
|
||||||
>
|
>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -64,7 +83,7 @@ const layout = computed(() => appStore.getLayout)
|
||||||
$prefix-cls: #{$namespace}-layout-radio-picker;
|
$prefix-cls: #{$namespace}-layout-radio-picker;
|
||||||
|
|
||||||
.#{$prefix-cls} {
|
.#{$prefix-cls} {
|
||||||
&__classic {
|
&__sidebar-nav {
|
||||||
border: 2px solid #e5e7eb;
|
border: 2px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
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: 2px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
|
|
@ -120,7 +140,7 @@ $prefix-cls: #{$namespace}-layout-radio-picker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__top {
|
&__header-nav {
|
||||||
border: 2px solid #e5e7eb;
|
border: 2px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
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: 2px solid #e5e7eb;
|
||||||
border-radius: 4px;
|
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 {
|
.is-acitve {
|
||||||
border-color: var(--el-color-primary);
|
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 { filterMenusPath, initTabMap, tabPathMap } from './helper'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
import { useDesign } from '@/hooks/web/useDesign'
|
||||||
import { isUrl } from '@/utils/is'
|
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()
|
const { getPrefixCls, variables } = useDesign()
|
||||||
|
|
||||||
|
|
@ -18,7 +24,7 @@ const prefixCls = getPrefixCls('tab-menu')
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'TabMenu',
|
name: 'TabMenu',
|
||||||
setup() {
|
setup() {
|
||||||
const { push, currentRoute } = useRouter()
|
const { push, currentRoute, resolve } = useRouter()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
@ -28,37 +34,47 @@ export default defineComponent({
|
||||||
|
|
||||||
const fixedMenu = computed(() => appStore.getFixedMenu)
|
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 permissionStore = usePermissionStore()
|
||||||
|
|
||||||
const routers = computed(() => permissionStore.getRouters)
|
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 = () => {
|
const setCollapse = () => {
|
||||||
appStore.setCollapse(!unref(collapse))
|
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(
|
watch(
|
||||||
() => routers.value,
|
() => routers.value,
|
||||||
(routers: AppRouteRecordRaw[]) => {
|
(routers: AppRouteRecordRaw[]) => {
|
||||||
|
|
@ -66,8 +82,7 @@ export default defineComponent({
|
||||||
filterMenusPath(routers, routers)
|
filterMenusPath(routers, routers)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
immediate: true,
|
immediate: true
|
||||||
deep: 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高亮
|
// tab高亮
|
||||||
const tabActive = ref('')
|
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点击事件
|
// tab点击事件
|
||||||
const tabClick = (item: AppRouteRecordRaw) => {
|
const tabClick = (item: AppRouteRecordRaw) => {
|
||||||
|
const link = item.meta?.link
|
||||||
|
if (typeof link === 'string') {
|
||||||
|
window.open(link)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isUrl(item.path)) {
|
if (isUrl(item.path)) {
|
||||||
window.open(item.path)
|
window.open(item.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const newPath = item.children ? item.path : item.path.split('/')[0]
|
const newPath = normalizeMenuTargetPath(item.path)
|
||||||
const oldPath = unref(tabActive)
|
const oldPath = unref(tabActive)
|
||||||
tabActive.value = item.children ? item.path : item.path.split('/')[0]
|
tabActive.value = newPath
|
||||||
if (item.children) {
|
const children = getVisibleChildren(item)
|
||||||
|
if (children.length) {
|
||||||
if (newPath === oldPath || !unref(showMenu)) {
|
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)) {
|
if (unref(showMenu)) {
|
||||||
permissionStore.setMenuTabRouters(
|
setExtraMenuRouters(buildExtraMenuRouters(children, unref(tabActive)))
|
||||||
cloneDeep(item.children).map((v) => {
|
|
||||||
v.path = pathResolve(unref(tabActive), v.path)
|
|
||||||
return v
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
push(item.path)
|
const targetLocation = createRouteLocation(item.path, item.meta, item.name)
|
||||||
permissionStore.setMenuTabRouters([])
|
if (resolve(targetLocation).fullPath !== unref(currentRoute).fullPath) {
|
||||||
|
push(targetLocation)
|
||||||
|
}
|
||||||
|
setExtraMenuRouters([])
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,14 +237,14 @@ export default defineComponent({
|
||||||
// 设置高亮
|
// 设置高亮
|
||||||
const isActive = (currentPath: string) => {
|
const isActive = (currentPath: string) => {
|
||||||
const { path } = unref(currentRoute)
|
const { path } = unref(currentRoute)
|
||||||
if (tabPathMap[currentPath].includes(path)) {
|
if (isCurrentRouteInTab(currentPath) || tabPathMap[currentPath]?.includes(path)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const mouseleave = () => {
|
const mouseleave = () => {
|
||||||
if (!unref(showMenu) || unref(fixedMenu)) return
|
if (!unref(showMenu) || unref(fixedMenu) || unref(headerMixed) || unref(twoColumn)) return
|
||||||
showMenu.value = false
|
showMenu.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,21 +265,15 @@ export default defineComponent({
|
||||||
<div>
|
<div>
|
||||||
{() => {
|
{() => {
|
||||||
return unref(tabRouters).map((v) => {
|
return unref(tabRouters).map((v) => {
|
||||||
const item = (
|
const fullTabPath = getFullTabPath(v)
|
||||||
v.meta?.alwaysShow || (v?.children?.length && v?.children?.length > 1)
|
const item = getTabItem(v)
|
||||||
? v
|
|
||||||
: {
|
|
||||||
...(v?.children && v?.children[0]),
|
|
||||||
path: pathResolve(v.path, (v?.children && v?.children[0])?.path as string)
|
|
||||||
}
|
|
||||||
) as AppRouteRecordRaw
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
`${prefixCls}__item`,
|
`${prefixCls}__item`,
|
||||||
'text-center text-12px relative py-12px cursor-pointer',
|
'text-center text-12px relative py-12px cursor-pointer',
|
||||||
{
|
{
|
||||||
'is-active': isActive(v.path)
|
'is-active': isActive(fullTabPath)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -193,18 +301,23 @@ export default defineComponent({
|
||||||
>
|
>
|
||||||
<Icon icon={unref(collapse) ? 'ep:d-arrow-right' : 'ep:d-arrow-left'}></Icon>
|
<Icon icon={unref(collapse) ? 'ep:d-arrow-right' : 'ep:d-arrow-left'}></Icon>
|
||||||
</div>
|
</div>
|
||||||
<Menu
|
{unref(hasExtraMenu) ? (
|
||||||
class={[
|
<Menu
|
||||||
'!absolute top-0 z-11',
|
split={false}
|
||||||
{
|
menus={permissionStore.getMenuTabRouters}
|
||||||
'!left-[var(--tab-menu-min-width)]': unref(collapse),
|
mode="vertical"
|
||||||
'!left-[var(--tab-menu-max-width)]': !unref(collapse),
|
class={[
|
||||||
'!w-[var(--left-menu-max-width)]': unref(showMenu) || unref(fixedMenu),
|
'!absolute top-0 z-11',
|
||||||
'!w-0': !unref(showMenu) && !unref(fixedMenu)
|
{
|
||||||
}
|
'!left-[var(--tab-menu-min-width)]': unref(collapse),
|
||||||
]}
|
'!left-[var(--tab-menu-max-width)]': !unref(collapse),
|
||||||
style="transition: width var(--transition-time-02), left var(--transition-time-02);"
|
'!w-[var(--left-menu-max-width)]': unref(showMenu) || unref(fixedMenu),
|
||||||
></Menu>
|
'!w-0': !unref(showMenu) && !unref(fixedMenu)
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
style="transition: width var(--transition-time-02), left var(--transition-time-02);"
|
||||||
|
></Menu>
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<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 type { RouteLocationNormalizedLoaded, RouterLinkProps } from 'vue-router'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { usePermissionStore } from '@/store/modules/permission'
|
import { usePermissionStore } from '@/store/modules/permission'
|
||||||
|
|
@ -139,10 +139,12 @@ const tagLinksRefs = useTemplateRefsList<RouterLinkProps>()
|
||||||
|
|
||||||
const moveToTarget = (currentTag: RouteLocationNormalizedLoaded) => {
|
const moveToTarget = (currentTag: RouteLocationNormalizedLoaded) => {
|
||||||
const wrap$ = unref(scrollbarRef)?.wrapRef
|
const wrap$ = unref(scrollbarRef)?.wrapRef
|
||||||
|
if (!wrap$) return
|
||||||
let firstTag: Nullable<RouterLinkProps> = null
|
let firstTag: Nullable<RouterLinkProps> = null
|
||||||
let lastTag: Nullable<RouterLinkProps> = null
|
let lastTag: Nullable<RouterLinkProps> = null
|
||||||
|
|
||||||
const tagList = unref(tagLinksRefs)
|
const tagList = unref(tagLinksRefs)
|
||||||
|
if (!tagList.length) return
|
||||||
// find first tag and last tag
|
// find first tag and last tag
|
||||||
if (tagList.length > 0) {
|
if (tagList.length > 0) {
|
||||||
firstTag = tagList[0]
|
firstTag = tagList[0]
|
||||||
|
|
@ -169,12 +171,14 @@ const moveToTarget = (currentTag: RouteLocationNormalizedLoaded) => {
|
||||||
} else {
|
} else {
|
||||||
// find preTag and nextTag
|
// find preTag and nextTag
|
||||||
const currentIndex: number = tagList.findIndex(
|
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 tgsRefs = document.getElementsByClassName(`${prefixCls}__item`)
|
||||||
|
|
||||||
const prevTag = tgsRefs[currentIndex - 1] as HTMLElement
|
const prevTag = tgsRefs[currentIndex - 1] as HTMLElement
|
||||||
const nextTag = tgsRefs[currentIndex + 1] as HTMLElement
|
const nextTag = tgsRefs[currentIndex + 1] as HTMLElement
|
||||||
|
if (!prevTag || !nextTag) return
|
||||||
|
|
||||||
// the tag's offsetLeft after of nextTag
|
// the tag's offsetLeft after of nextTag
|
||||||
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + 4
|
const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + 4
|
||||||
|
|
@ -245,16 +249,6 @@ const move = (to: number) => {
|
||||||
start()
|
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) => {
|
const closeTabOnMouseMidClick = (e: MouseEvent, item) => {
|
||||||
// 中键:button === 1
|
// 中键:button === 1
|
||||||
if (e.button === 1) {
|
if (e.button === 1) {
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@ import { SizeDropdown } from '@/layout/components/SizeDropdown'
|
||||||
import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
|
import { LocaleDropdown } from '@/layout/components/LocaleDropdown'
|
||||||
import RouterSearch from '@/components/RouterSearch/index.vue'
|
import RouterSearch from '@/components/RouterSearch/index.vue'
|
||||||
import TenantVisit from '@/layout/components/TenantVisit/index.vue'
|
import TenantVisit from '@/layout/components/TenantVisit/index.vue'
|
||||||
|
import { useSetting } from '@/layout/components/Setting'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
import { useDesign } from '@/hooks/web/useDesign'
|
||||||
import { Icon } from '@/components/Icon'
|
import { Icon } from '@/components/Icon'
|
||||||
import { checkPermi } from '@/utils/permission'
|
import { checkPermi } from '@/utils/permission'
|
||||||
|
import { isHorizontalMenuLayout, isMixedNavLayout, isTwoColumnLayout } from '@/utils/layout'
|
||||||
|
|
||||||
const { getPrefixCls, variables } = useDesign()
|
const { getPrefixCls, variables } = useDesign()
|
||||||
|
|
||||||
|
|
@ -61,6 +63,13 @@ const goToChat = () => {
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'ToolHeader',
|
name: 'ToolHeader',
|
||||||
setup() {
|
setup() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { openSetting } = useSetting()
|
||||||
|
const showSidebarControl = computed(
|
||||||
|
() => !isHorizontalMenuLayout(layout.value) || isMixedNavLayout(layout.value)
|
||||||
|
)
|
||||||
|
const showBreadcrumb = computed(() => !isHorizontalMenuLayout(layout.value))
|
||||||
|
|
||||||
return () => (
|
return () => (
|
||||||
<div
|
<div
|
||||||
id={`${variables.namespace}-tool-header`}
|
id={`${variables.namespace}-tool-header`}
|
||||||
|
|
@ -70,16 +79,21 @@ export default defineComponent({
|
||||||
'dark:bg-[var(--el-bg-color)]'
|
'dark:bg-[var(--el-bg-color)]'
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{layout.value !== 'top' ? (
|
{showSidebarControl.value || showBreadcrumb.value ? (
|
||||||
<div class="h-full flex items-center">
|
<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>
|
<Collapse class="custom-hover" color="var(--top-header-text-color)"></Collapse>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{breadcrumb.value ? <Breadcrumb class="lt-md:hidden"></Breadcrumb> : undefined}
|
{showBreadcrumb.value && breadcrumb.value ? (
|
||||||
|
<Breadcrumb class="lt-md:hidden"></Breadcrumb>
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<div class="h-full flex items-center">
|
<div class="h-full flex items-center">
|
||||||
{hasTenantVisitPermission.value ? <TenantVisit /> : undefined}
|
{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.value ? (
|
||||||
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
|
<Screenfull class="custom-hover" color="var(--top-header-text-color)"></Screenfull>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useAppStore } from '@/store/modules/app'
|
import { useAppStore } from '@/store/modules/app'
|
||||||
|
import { usePermissionStore } from '@/store/modules/permission'
|
||||||
import { Menu } from '@/layout/components/Menu'
|
import { Menu } from '@/layout/components/Menu'
|
||||||
import { TabMenu } from '@/layout/components/TabMenu'
|
import { TabMenu } from '@/layout/components/TabMenu'
|
||||||
import { TagsView } from '@/layout/components/TagsView'
|
import { TagsView } from '@/layout/components/TagsView'
|
||||||
|
|
@ -8,6 +9,11 @@ import AppView from './AppView.vue'
|
||||||
import ToolHeader from './ToolHeader.vue'
|
import ToolHeader from './ToolHeader.vue'
|
||||||
import { ElScrollbar } from 'element-plus'
|
import { ElScrollbar } from 'element-plus'
|
||||||
import { useDesign } from '@/hooks/web/useDesign'
|
import { useDesign } from '@/hooks/web/useDesign'
|
||||||
|
import {
|
||||||
|
isHeaderMixedNavLayout,
|
||||||
|
isMixedNavLayout,
|
||||||
|
isTwoColumnLayout
|
||||||
|
} from '@/utils/layout'
|
||||||
|
|
||||||
const { getPrefixCls } = useDesign()
|
const { getPrefixCls } = useDesign()
|
||||||
|
|
||||||
|
|
@ -15,6 +21,8 @@ const prefixCls = getPrefixCls('layout')
|
||||||
|
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
|
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
const pageLoading = computed(() => appStore.getPageLoading)
|
const pageLoading = computed(() => appStore.getPageLoading)
|
||||||
|
|
||||||
// 标签页
|
// 标签页
|
||||||
|
|
@ -35,6 +43,8 @@ const mobile = computed(() => appStore.getMobile)
|
||||||
// 固定菜单
|
// 固定菜单
|
||||||
const fixedMenu = computed(() => appStore.getFixedMenu)
|
const fixedMenu = computed(() => appStore.getFixedMenu)
|
||||||
|
|
||||||
|
const layout = computed(() => appStore.getLayout)
|
||||||
|
|
||||||
export const useRenderLayout = () => {
|
export const useRenderLayout = () => {
|
||||||
const renderClassic = () => {
|
const renderClassic = () => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -119,19 +129,33 @@ export const useRenderLayout = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderTopLeft = () => {
|
const renderTopLeft = () => {
|
||||||
|
const showHeaderMenu = isMixedNavLayout(layout.value)
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="relative flex items-center bg-[var(--top-header-bg-color)] layout-border__bottom dark:bg-[var(--el-bg-color)]">
|
<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>
|
||||||
<div class="absolute left-0 top-[var(--logo-height)] h-[calc(100%-var(--logo-height))] w-full flex">
|
<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
|
<div
|
||||||
class={[
|
class={[
|
||||||
`${prefixCls}-content`,
|
`${prefixCls}-content`,
|
||||||
'h-[100%]',
|
'h-[100%] flex-none',
|
||||||
{
|
{
|
||||||
'w-[calc(100%-var(--left-menu-min-width))] left-[var(--left-menu-min-width)]':
|
'w-[calc(100%-var(--left-menu-min-width))] left-[var(--left-menu-min-width)]':
|
||||||
collapse.value,
|
collapse.value,
|
||||||
|
|
@ -187,7 +211,11 @@ export const useRenderLayout = () => {
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{logo.value ? <Logo class="custom-hover"></Logo> : undefined}
|
{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>
|
<ToolHeader></ToolHeader>
|
||||||
</div>
|
</div>
|
||||||
<div class={[`${prefixCls}-content`, 'w-full h-[calc(100%-var(--top-tool-height))]']}>
|
<div class={[`${prefixCls}-content`, 'w-full h-[calc(100%-var(--top-tool-height))]']}>
|
||||||
|
|
@ -221,31 +249,43 @@ export const useRenderLayout = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderCutMenu = () => {
|
const renderCutMenu = () => {
|
||||||
|
const showHeaderMenu = isHeaderMixedNavLayout(layout.value)
|
||||||
|
const fixedTwoColumnMenu = fixedMenu.value || isTwoColumnLayout(layout.value)
|
||||||
|
const showTwoColumnExtraMenu = fixedTwoColumnMenu && permissionStore.getMenuTabRouters.length > 0
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div class="relative flex items-center bg-[var(--top-header-bg-color)] layout-border__bottom">
|
<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>
|
||||||
<div class="absolute left-0 top-[var(--logo-height)] h-[calc(100%-var(--logo-height))] w-full flex">
|
<div class="absolute left-0 top-[var(--logo-height)] h-[calc(100%-var(--logo-height))] w-full flex">
|
||||||
<TabMenu></TabMenu>
|
<TabMenu></TabMenu>
|
||||||
<div
|
<div
|
||||||
class={[
|
class={[
|
||||||
`${prefixCls}-content`,
|
`${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)]':
|
'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)]':
|
'w-[calc(100%-var(--tab-menu-max-width))] left-[var(--tab-menu-max-width)]':
|
||||||
!collapse.value && !fixedMenu.value,
|
!collapse.value && !showTwoColumnExtraMenu,
|
||||||
'w-[calc(100%-var(--tab-menu-min-width)-var(--left-menu-max-width))] ml-[var(--left-menu-max-width)]':
|
'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 && fixedMenu.value,
|
collapse.value && showTwoColumnExtraMenu,
|
||||||
'w-[calc(100%-var(--tab-menu-max-width)-var(--left-menu-max-width))] ml-[var(--left-menu-max-width)]':
|
'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 && fixedMenu.value
|
!collapse.value && showTwoColumnExtraMenu
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
style="transition: all var(--transition-time-02);"
|
style="transition: width var(--transition-time-02);"
|
||||||
>
|
>
|
||||||
<ElScrollbar
|
<ElScrollbar
|
||||||
v-loading={pageLoading.value}
|
v-loading={pageLoading.value}
|
||||||
|
|
@ -264,16 +304,20 @@ export const useRenderLayout = () => {
|
||||||
{
|
{
|
||||||
'!fixed top-0 left-0 z-10': fixedHeader.value,
|
'!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)]':
|
'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)]':
|
'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)]':
|
'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)]':
|
'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>
|
></TagsView>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,12 @@ export default {
|
||||||
fixedHeader: 'Fixed header',
|
fixedHeader: 'Fixed header',
|
||||||
headerTheme: 'Header theme',
|
headerTheme: 'Header theme',
|
||||||
cutMenu: 'Cut Menu',
|
cutMenu: 'Cut Menu',
|
||||||
|
vertical: 'Vertical',
|
||||||
|
twoColumn: 'Two-column',
|
||||||
|
horizontal: 'Horizontal',
|
||||||
|
headerSidebarNav: 'Sidebar nav',
|
||||||
|
mixedMenu: 'Mixed vertical',
|
||||||
|
headerTwoColumn: 'Mixed two-column',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
clearAndReset: 'Clear cache and reset',
|
clearAndReset: 'Clear cache and reset',
|
||||||
copySuccess: 'Copy success',
|
copySuccess: 'Copy success',
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,12 @@ export default {
|
||||||
fixedHeader: '固定头部',
|
fixedHeader: '固定头部',
|
||||||
headerTheme: '头部主题',
|
headerTheme: '头部主题',
|
||||||
cutMenu: '切割菜单',
|
cutMenu: '切割菜单',
|
||||||
|
vertical: '垂直',
|
||||||
|
twoColumn: '双列菜单',
|
||||||
|
horizontal: '水平',
|
||||||
|
headerSidebarNav: '侧边导航',
|
||||||
|
mixedMenu: '混合垂直',
|
||||||
|
headerTwoColumn: '混合双列',
|
||||||
copy: '拷贝',
|
copy: '拷贝',
|
||||||
clearAndReset: '清除缓存并且重置',
|
clearAndReset: '清除缓存并且重置',
|
||||||
copySuccess: '拷贝成功',
|
copySuccess: '拷贝成功',
|
||||||
|
|
|
||||||
|
|
@ -8,44 +8,12 @@ import { usePageLoading } from '@/hooks/web/usePageLoading'
|
||||||
import { useDictStoreWithOut } from '@/store/modules/dict'
|
import { useDictStoreWithOut } from '@/store/modules/dict'
|
||||||
import { useUserStoreWithOut } from '@/store/modules/user'
|
import { useUserStoreWithOut } from '@/store/modules/user'
|
||||||
import { usePermissionStoreWithOut } from '@/store/modules/permission'
|
import { usePermissionStoreWithOut } from '@/store/modules/permission'
|
||||||
|
import { parseRouteLocation } from '@/utils/routeParams'
|
||||||
|
|
||||||
const { start, done } = useNProgress()
|
const { start, done } = useNProgress()
|
||||||
|
|
||||||
const { loadStart, loadDone } = usePageLoading()
|
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 = [
|
const whiteList = [
|
||||||
'/login',
|
'/login',
|
||||||
|
|
@ -81,11 +49,14 @@ router.beforeEach(async (to, from, next) => {
|
||||||
permissionStore.getAddRouters.forEach((route) => {
|
permissionStore.getAddRouters.forEach((route) => {
|
||||||
router.addRoute(route as unknown as RouteRecordRaw) // 动态添加可访问路由表
|
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 redirect = typeof redirectPath === 'string' ? redirectPath : to.fullPath
|
||||||
const { paramsObject: query } = parseURL(redirect)
|
const redirectLocation = parseRouteLocation(redirect)
|
||||||
const nextData = to.path === redirect ? { ...to, replace: true } : { path: redirect, query }
|
const nextData =
|
||||||
|
to.fullPath === redirect
|
||||||
|
? { ...to, replace: true }
|
||||||
|
: { ...redirectLocation, replace: true }
|
||||||
next(nextData)
|
next(nextData)
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
|
|
@ -95,7 +66,7 @@ router.beforeEach(async (to, from, next) => {
|
||||||
if (whiteList.indexOf(to.path) !== -1) {
|
if (whiteList.indexOf(to.path) !== -1) {
|
||||||
next()
|
next()
|
||||||
} else {
|
} 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 { ThemeTypes } from '@/types/theme'
|
||||||
import { humpToUnderline, setCssVar } from '@/utils'
|
import { humpToUnderline, setCssVar } from '@/utils'
|
||||||
import { getCssColorVariable, hexToRGB, mix } from '@/utils/color'
|
import { getCssColorVariable, hexToRGB, mix } from '@/utils/color'
|
||||||
|
import { normalizeLayout } from '@/utils/layout'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { store } from '../index'
|
import { store } from '../index'
|
||||||
|
|
@ -68,7 +69,7 @@ export const useAppStore = defineStore('app', {
|
||||||
greyMode: false, // 是否开始灰色模式,用于特殊悼念日
|
greyMode: false, // 是否开始灰色模式,用于特殊悼念日
|
||||||
fixedMenu: wsCache.get('fixedMenu') || 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, // 是否是暗黑模式
|
isDark: wsCache.get(CACHE_KEY.IS_DARK) || false, // 是否是暗黑模式
|
||||||
currentSize: wsCache.get('default') || 'default', // 组件尺寸
|
currentSize: wsCache.get('default') || 'default', // 组件尺寸
|
||||||
theme: wsCache.get(CACHE_KEY.THEME) || {
|
theme: wsCache.get(CACHE_KEY.THEME) || {
|
||||||
|
|
@ -271,11 +272,12 @@ export const useAppStore = defineStore('app', {
|
||||||
this.pageLoading = pageLoading
|
this.pageLoading = pageLoading
|
||||||
},
|
},
|
||||||
setLayout(layout: LayoutType) {
|
setLayout(layout: LayoutType) {
|
||||||
if (this.mobile && layout !== 'classic') {
|
const normalizedLayout = normalizeLayout(layout)
|
||||||
|
if (this.mobile && normalizedLayout !== 'sidebar-nav') {
|
||||||
ElMessage.warning('移动端模式下不支持切换其他布局')
|
ElMessage.warning('移动端模式下不支持切换其他布局')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.layout = layout
|
this.layout = normalizedLayout
|
||||||
wsCache.set(CACHE_KEY.LAYOUT, this.layout)
|
wsCache.set(CACHE_KEY.LAYOUT, this.layout)
|
||||||
},
|
},
|
||||||
setTitle(title: string) {
|
setTitle(title: string) {
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,15 @@ export interface PermissionState {
|
||||||
routers: AppRouteRecordRaw[]
|
routers: AppRouteRecordRaw[]
|
||||||
addRouters: AppRouteRecordRaw[]
|
addRouters: AppRouteRecordRaw[]
|
||||||
menuTabRouters: AppRouteRecordRaw[]
|
menuTabRouters: AppRouteRecordRaw[]
|
||||||
|
menuRootPath: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const usePermissionStore = defineStore('permission', {
|
export const usePermissionStore = defineStore('permission', {
|
||||||
state: (): PermissionState => ({
|
state: (): PermissionState => ({
|
||||||
routers: [],
|
routers: [],
|
||||||
addRouters: [],
|
addRouters: [],
|
||||||
menuTabRouters: []
|
menuTabRouters: [],
|
||||||
|
menuRootPath: ''
|
||||||
}),
|
}),
|
||||||
getters: {
|
getters: {
|
||||||
getRouters(): AppRouteRecordRaw[] {
|
getRouters(): AppRouteRecordRaw[] {
|
||||||
|
|
@ -28,6 +30,9 @@ export const usePermissionStore = defineStore('permission', {
|
||||||
},
|
},
|
||||||
getMenuTabRouters(): AppRouteRecordRaw[] {
|
getMenuTabRouters(): AppRouteRecordRaw[] {
|
||||||
return this.menuTabRouters
|
return this.menuTabRouters
|
||||||
|
},
|
||||||
|
getMenuRootPath(): string {
|
||||||
|
return this.menuRootPath
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
|
|
@ -61,6 +66,9 @@ export const usePermissionStore = defineStore('permission', {
|
||||||
},
|
},
|
||||||
setMenuTabRouters(routers: AppRouteRecordRaw[]): void {
|
setMenuTabRouters(routers: AppRouteRecordRaw[]): void {
|
||||||
this.menuTabRouters = routers
|
this.menuTabRouters = routers
|
||||||
|
},
|
||||||
|
setMenuRootPath(path: string): void {
|
||||||
|
this.menuRootPath = path
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
persist: false
|
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 { createRouter, createWebHashHistory, RouteRecordRaw, RouterView } from 'vue-router'
|
||||||
import { isUrl } from '@/utils/is'
|
import { isUrl } from '@/utils/is'
|
||||||
import { cloneDeep, omit } from 'lodash-es'
|
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}')
|
const modules = import.meta.glob('../views/**/*.{vue,tsx}')
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,6 +38,8 @@ const ParentLayout = defineComponent({
|
||||||
|
|
||||||
export const getParentLayout = () => ParentLayout
|
export const getParentLayout = () => ParentLayout
|
||||||
|
|
||||||
|
export const IFrameView = () => import('@/views/IFrame/index.vue')
|
||||||
|
|
||||||
// 按照路由中meta下的rank等级升序来排序路由
|
// 按照路由中meta下的rank等级升序来排序路由
|
||||||
export const ascending = (arr: any[]) => {
|
export const ascending = (arr: any[]) => {
|
||||||
arr.forEach((v) => {
|
arr.forEach((v) => {
|
||||||
|
|
@ -68,6 +75,16 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
|
||||||
const res: AppRouteRecordRaw[] = []
|
const res: AppRouteRecordRaw[] = []
|
||||||
const modulesRoutesKeys = Object.keys(modules)
|
const modulesRoutesKeys = Object.keys(modules)
|
||||||
for (const route of routes) {
|
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 菜单元数据
|
// 1. 生成 meta 菜单元数据
|
||||||
const meta = {
|
const meta = {
|
||||||
title: route.name,
|
title: route.name,
|
||||||
|
|
@ -79,20 +96,31 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
|
||||||
route.children.length > 0 &&
|
route.children.length > 0 &&
|
||||||
(route.alwaysShow !== undefined ? route.alwaysShow : true)
|
(route.alwaysShow !== undefined ? route.alwaysShow : true)
|
||||||
} as any
|
} as any
|
||||||
// 特殊逻辑:如果后端配置的 MenuDO.component 包含 ?,则表示需要传递参数
|
// 后端 MenuDO.component 或 path 可通过 ?/# 携带菜单参数,对齐 vben 的 meta.query/hash/params 行为。
|
||||||
// 此时,我们需要解析参数,并且将参数放到 meta.query 中
|
const routeQuery = {
|
||||||
// 这样,后续在 Vue 文件中,可以通过 const { currentRoute } = useRouter() 中,通过 meta.query 获取到参数
|
...((route.meta?.query as Record<string, any> | undefined) || {}),
|
||||||
if (route.component && route.component.indexOf('?') > -1) {
|
...(pathRoute.query || {}),
|
||||||
const query = route.component.split('?')[1]
|
...(componentRoute.query || {})
|
||||||
route.component = route.component.split('?')[0]
|
}
|
||||||
meta.query = qs.parse(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)
|
// 2. 生成 data(AppRouteRecordRaw)
|
||||||
// 路由地址转首字母大写驼峰,作为路由名称,适配keepAlive
|
// 路由地址转首字母大写驼峰,作为路由名称,适配keepAlive
|
||||||
let data: AppRouteRecordRaw = {
|
let data: AppRouteRecordRaw = {
|
||||||
path:
|
path: route.path,
|
||||||
route.path.indexOf('?') > -1 && !isUrl(route.path) ? route.path.split('?')[0] : route.path, // 注意,需要排除 http 这种 url,避免它带 ? 参数被截取掉
|
|
||||||
name:
|
name:
|
||||||
route.componentName && route.componentName.length > 0
|
route.componentName && route.componentName.length > 0
|
||||||
? route.componentName
|
? route.componentName
|
||||||
|
|
@ -131,13 +159,26 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
|
||||||
data.redirect = getRedirect(route.path, route.children)
|
data.redirect = getRedirect(route.path, route.children)
|
||||||
// 外链
|
// 外链
|
||||||
} else if (isUrl(route.path)) {
|
} 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 = {
|
data = {
|
||||||
path: '/external-link',
|
path: externalPath,
|
||||||
|
name: `${data.name}ExternalParent`,
|
||||||
component: Layout,
|
component: Layout,
|
||||||
meta: {
|
meta: {
|
||||||
name: route.name
|
...meta,
|
||||||
|
...(pathRoute.iframe ? { iframeSrc: pathRoute.path } : { link: route.path })
|
||||||
},
|
},
|
||||||
children: [data]
|
children: [externalChild]
|
||||||
} as AppRouteRecordRaw
|
} as AppRouteRecordRaw
|
||||||
// 菜单
|
// 菜单
|
||||||
} else {
|
} 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
|
noTagsView?: boolean
|
||||||
followAuth?: string
|
followAuth?: string
|
||||||
canTo?: boolean
|
canTo?: boolean
|
||||||
|
hash?: string
|
||||||
|
iframeSrc?: string
|
||||||
|
link?: string
|
||||||
|
params?: Recordable
|
||||||
|
query?: Recordable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +78,7 @@ declare global {
|
||||||
meta: RouteMeta
|
meta: RouteMeta
|
||||||
component: string
|
component: string
|
||||||
componentName?: string
|
componentName?: string
|
||||||
|
id?: number | string
|
||||||
path: string
|
path: string
|
||||||
redirect: string
|
redirect: string
|
||||||
children?: AppCustomRouteRecordRaw[]
|
children?: AppCustomRouteRecordRaw[]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue