From e5c9d2f12f1a76e7b5186c59f0accbc774c63509 Mon Sep 17 00:00:00 2001 From: kiki1373639299 Date: Mon, 10 Nov 2025 20:38:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E6=A0=B7=E5=BC=8F(=E5=90=8C=E6=AD=A5gi-demo)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: kiki1373639299 # message auto-generated for no-merge-commit merge: !14 merge type-fix into dev feat: 优化布局样式(同步gi-demo) Created-by: kiki1373639299 Commit-by: kiki1373639299 Merged-by: Charles_7c Description: ## PR 类型 - [X] 新 feature - [ ] Bug 修复 - [ ] 功能增强 - [ ] 文档变更 - [ ] 代码样式变更 - [ ] 重构 - [ ] 性能改进 - [ ] 单元测试 - [ ] CI/CD - [ ] 其他 ## PR 目的 ## 解决方案 ## PR 测试 ## Changelog | 模块 | Changelog | Related issues | |-----|-----------| -------------- | | | | | ## 其他信息 ## 提交前确认 - [X] PR 代码经过了完整测试,并且通过了代码规范检查 - [ ] 已经完整填写 Changelog,并链接到了相关 issues - [X] PR 代码将要提交到 dev 分支 See merge request: continew/continew-admin-ui!14 --- src/hooks/index.ts | 1 + src/hooks/modules/useRouteListener.ts | 92 +++++++++++ src/layout/LayoutColumns.vue | 161 ++++--------------- src/layout/LayoutMix.vue | 161 +++++-------------- src/layout/components/OneLevelMenu/index.vue | 35 ++-- src/layout/hooks/useLevelMenu.ts | 95 +++++++++++ src/layout/hooks/useMenus.ts | 52 ++++++ 7 files changed, 329 insertions(+), 268 deletions(-) create mode 100644 src/hooks/modules/useRouteListener.ts create mode 100644 src/layout/hooks/useLevelMenu.ts create mode 100644 src/layout/hooks/useMenus.ts diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c6b9267..ab6fb2e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -8,3 +8,4 @@ export * from './modules/useBreakpoint' export * from './modules/useDownload' export * from './modules/useResetReactive' export * from './modules/useMultipartUploader' +export * from './modules/useRouteListener' diff --git a/src/hooks/modules/useRouteListener.ts b/src/hooks/modules/useRouteListener.ts new file mode 100644 index 0000000..a9eb8eb --- /dev/null +++ b/src/hooks/modules/useRouteListener.ts @@ -0,0 +1,92 @@ +/** + * 路由变化监听 Hook + * @description 使用发布订阅模式管理路由变化,避免重复监听导致的性能问题 + */ + +import type { Handler } from 'mitt' +import type { RouteLocationNormalized } from 'vue-router' +import mitt from 'mitt' +import { onUnmounted, ref } from 'vue' + +// 事件类型定义 +interface RouteChangeEvent { + to: RouteLocationNormalized + from?: RouteLocationNormalized +} + +// 事件键定义 +const ROUTE_CHANGE_KEY = Symbol('ROUTE_CHANGE') + +// 事件发射器 +const emitter = mitt<{ + [ROUTE_CHANGE_KEY]: RouteChangeEvent +}>() + +// 最新路由状态 +const latestRoute = ref(null) + +/** + * 设置路由变化事件 + * @param to 目标路由 + * @param from 来源路由 + */ +export function setRouteEmitter(to: RouteLocationNormalized, from?: RouteLocationNormalized) { + const event: RouteChangeEvent = { to, from } + emitter.emit(ROUTE_CHANGE_KEY, event) + latestRoute.value = to +} + +/** + * 路由监听 Hook + * @returns 路由监听相关方法 + */ +export function useRouteListener() { + // 监听器列表 + const listeners = new Set>() + + /** + * 监听路由变化 + * @param handler 处理函数 + * @param immediate 是否立即执行 + */ + function listenerRouteChange( + handler: (event: RouteChangeEvent) => void, + immediate = true, + ) { + emitter.on(ROUTE_CHANGE_KEY, handler) + listeners.add(handler) + + if (immediate && latestRoute.value) { + handler({ to: latestRoute.value }) + } + + return handler + } + + /** + * 移除路由监听器 + * @param handler 要移除的处理函数 + */ + function removeRouteListener(handler?: Handler) { + if (handler) { + emitter.off(ROUTE_CHANGE_KEY, handler) + listeners.delete(handler) + } else { + // 移除所有监听器 + listeners.forEach((listener) => { + emitter.off(ROUTE_CHANGE_KEY, listener) + }) + listeners.clear() + } + } + + // 组件卸载时清理 + onUnmounted(() => { + removeRouteListener() + }) + + return { + listenerRouteChange, + removeRouteListener, + } +} diff --git a/src/layout/LayoutColumns.vue b/src/layout/LayoutColumns.vue index a6efbb3..8844a94 100644 --- a/src/layout/LayoutColumns.vue +++ b/src/layout/LayoutColumns.vue @@ -1,20 +1,16 @@ diff --git a/src/layout/components/OneLevelMenu/index.vue b/src/layout/components/OneLevelMenu/index.vue index 460f68e..7e20709 100644 --- a/src/layout/components/OneLevelMenu/index.vue +++ b/src/layout/components/OneLevelMenu/index.vue @@ -8,7 +8,7 @@ v-for="(item) in props.menus" :key="item.path" class="one-level-menu__item" :class="{ 'one-level-menu__item--active': calcIsActive(item) }" @click="emits('menu-click', item)" > - +

{{ item?.meta?.title }}

@@ -25,15 +25,18 @@ import MenuIcon from './MenuIcon.vue' interface Props { menus?: RouteRecordRaw[] } + const props = withDefaults(defineProps(), { menus: () => [], }) + const emits = defineEmits<{ (e: 'menu-click', item: RouteRecordRaw): void }>() + const route = useRoute() const calcIsActive = (item: RouteRecordRaw) => { - return route.path.startsWith(item.path) && item.path !== '/' + return (route.path.startsWith(item.path) && item.path !== '/') || item.redirect === route.path } @@ -43,14 +46,14 @@ const calcIsActive = (item: RouteRecordRaw) => { } .one-level-menu { - width: 68px; - height: 100%; - background-color: var(--color-bg-1); - border-right: 1px solid var(--color-border-2); box-sizing: border-box; - overflow: hidden; display: flex; flex-direction: column; + width: 68px; + height: 100%; + overflow: hidden; + background-color: var(--color-bg-1); + border-right: 1px solid var(--color-border-2); &__logo { justify-content: center; @@ -63,14 +66,14 @@ const calcIsActive = (item: RouteRecordRaw) => { } &__wrap { - flex: 1; display: flex; + flex: 1; overflow: hidden; } &__list { - padding: 4px 4px 0; box-sizing: border-box; + padding: 4px 4px 0; } &__item { @@ -80,12 +83,12 @@ const calcIsActive = (item: RouteRecordRaw) => { justify-content: center; padding: 8px 0; margin-bottom: 4px; - border-radius: 4px; cursor: pointer; + border-radius: 4px; &--active { - background-color: var(--color-primary-light-2); - color: rgb(var(--primary-6)) + color: rgb(var(--primary-6)); + background-color: var(--color-primary-light-2) } &:not(.one-level-menu__item--active):hover { @@ -93,11 +96,11 @@ const calcIsActive = (item: RouteRecordRaw) => { } &__title { - font-size: 12px; - margin-top: 8px; - line-height: 1; - padding: 0 8px; box-sizing: border-box; + padding: 0 8px; + margin-top: 8px; + font-size: 12px; + line-height: 1; } } } diff --git a/src/layout/hooks/useLevelMenu.ts b/src/layout/hooks/useLevelMenu.ts new file mode 100644 index 0000000..994fa6d --- /dev/null +++ b/src/layout/hooks/useLevelMenu.ts @@ -0,0 +1,95 @@ +import type { RouteRecordRaw } from 'vue-router' +import { eachTree } from 'xe-utils' +import { useRouteListener } from '@/hooks' +import { useRouteStore } from '@/stores' +import { filterTree } from '@/utils' +import { isExternal } from '@/utils/validate' + +/** 获取一级菜单,二级菜单的hooks */ +export function useLevelMenu() { + const route = useRoute() + const router = useRouter() + const routeStore = useRouteStore() + const { listenerRouteChange } = useRouteListener() + + // 克隆一份路由,避免直接操作原始路由 + const cloneRoutes = JSON.parse(JSON.stringify(routeStore.routes)) as RouteRecordRaw[] + const showMenuList = filterTree(cloneRoutes, (i) => i.meta?.hidden === false) as RouteRecordRaw[] + + // 一级菜单 + const oneLevelMenus = ref([]) + + // 二级菜单 + const twoLevelMenus = computed(() => { + const path = route.matched[0].path + return showMenuList.find((i) => i.path === path)?.children || [] + }) + + // 一级菜单选中的路由 + const oneLevelMenuActiveRoute = ref(null) + + const getOneLevelMenuActiveRoute = (path: string) => { + return oneLevelMenus.value.find((i) => i.path === path) as RouteRecordRaw + } + + listenerRouteChange(({ to }) => { + oneLevelMenuActiveRoute.value = getOneLevelMenuActiveRoute(to.matched?.[0]?.path) + }) + + // 获取一级菜单 + function getOneLevelMenus() { + const cloneRoutes = JSON.parse(JSON.stringify(routeStore.routes)) as RouteRecordRaw[] + const showMenuList = filterTree(cloneRoutes, (i) => i.meta?.hidden === false) as RouteRecordRaw[] + eachTree(showMenuList, (i) => { + if (i?.children?.length === 1 && i?.meta?.alwaysShow !== true) { + if (i.meta) { + i.meta.title = i.meta?.title || i.children?.[0]?.meta?.title + i.meta.svgIcon = i.meta?.svgIcon || i.children?.[0]?.meta?.svgIcon + i.meta.icon = i.meta?.icon || i.children?.[0]?.meta?.icon + } + delete i.children + } + }) + oneLevelMenus.value = showMenuList + } + + // 菜单点击事件 + function handleMenuItemClickByItem(item: RouteRecordRaw) { + let path = (item.redirect as string) || item.path + if ((!item.redirect && item?.children?.length) || (item.redirect === 'noRedirect' && item?.children?.length)) { + path = item.children?.[0]?.path + } + if (isExternal(path)) { + window.open(path) + return + } + if (item.redirect === 'noRedirect') { + router.replace({ path }) + return + } + router.replace({ path }) + } + + // 菜单点击事件 + function handleMenuItemClickByPath(path: string) { + if (isExternal(path)) { + window.open(path) + return + } + const obj = oneLevelMenus.value.find((i) => i.path === path) + if (obj?.redirect === 'noRedirect') { + router.push({ path: obj.children?.[0]?.path }) + return + } + router.push({ path: (obj?.redirect as string) || path }) + } + + return { + oneLevelMenus, + twoLevelMenus, + oneLevelMenuActiveRoute, + getOneLevelMenus, + handleMenuItemClickByItem, + handleMenuItemClickByPath, + } +} diff --git a/src/layout/hooks/useMenus.ts b/src/layout/hooks/useMenus.ts new file mode 100644 index 0000000..653e38f --- /dev/null +++ b/src/layout/hooks/useMenus.ts @@ -0,0 +1,52 @@ +import type { RouteRecordRaw } from 'vue-router' +import { computed } from 'vue' +import { eachTree } from 'xe-utils' +import { useRouteStore } from '@/stores' +import { filterTree } from '@/utils' + +/** + * 菜单管理 Hooks + * 用于获取和处理应用程序的菜单列表 + * 提供菜单的过滤、展平处理等功能 + * @returns {object} 包含处理后菜单列表的响应式对象 + */ +export function useMenu() { + // 路由存储实例,用于获取原始路由配置 + const routeStore = useRouteStore() + + /** + * 处理后的菜单列表 + * 响应式计算属性,当路由配置变化时自动更新, + * > hidden:false那么代表这个路由项显示在左侧菜单栏中 + * > 子项chidren只有一个hidden:false的子元素时, 且alwaysShow为false, 那么这个子项会被展平到父项中 + * 包含以下处理逻辑: + * 1. 深拷贝原始路由配置避免直接修改 + * 2. 过滤掉设置为隐藏的菜单项 + * 3. 展平只有一个子项的菜单项(提升用户体验) + */ + const menuList = computed(() => { + // 深拷贝路由配置,防止修改原始数据 + const cloneRoutes = JSON.parse(JSON.stringify(routeStore.routes)) as RouteRecordRaw[] + + // 过滤出非隐藏的菜单(meta.hidden !== false) + const showMenuList = filterTree(cloneRoutes, (i) => i.meta?.hidden === false) as RouteRecordRaw[] + + // 遍历处理菜单树,展平只有一个子项的菜单项 + eachTree(showMenuList, (i) => { + if (i?.children?.length === 1 && i?.meta?.alwaysShow !== true) { + if (i.meta) { + i.meta.title = i.meta?.title || i.children?.[0]?.meta?.title + i.meta.svgIcon = i.meta?.svgIcon || i.children?.[0]?.meta?.svgIcon + i.meta.icon = i.meta?.icon || i.children?.[0]?.meta?.icon + } + i.path = i.children?.[0]?.path + delete i.children + } + }) + return showMenuList + }) + + return { + menuList, + } +}