feat: 增强布局组件(同步GI-DEMO),支持更多布局选项并优化布局切换功能

Co-authored-by: kiki1373639299<zkai0106@163.com>



# message auto-generated for no-merge-commit merge:
!11 merge dev into dev

feat: 增强布局组件(同步GI-DEMO),支持更多布局选项并优化布局切换功能

Created-by: kiki1373639299
Commit-by: kiki1373639299
Merged-by: Charles_7c
Description: <!--
  非常感谢您的 PR!在提交之前,请务必确保您 PR 的代码经过了完整测试,并且通过了代码规范检查。
-->

<!-- 在 [] 中输入 x 来勾选) -->

## PR 类型

<!-- 您的 PR 引入了哪种类型的变更? -->
<!-- 只支持选择一种类型,如果有多种类型,可以在更新日志中增加 “类型” 列。 -->

- [X] 新 feature
- [ ] Bug 修复
- [ ] 功能增强
- [ ] 文档变更
- [ ] 代码样式变更
- [ ] 重构
- [ ] 性能改进
- [ ] 单元测试
- [ ] CI/CD
- [ ] 其他

## PR 目的
同步GI-DEMO系统布局,并自定义样式增强
<!-- 描述一下您的 PR 解决了什么问题。如果可以,请链接到相关 issues。 -->

## 解决方案

<!-- 详细描述您是如何解决的问题 -->

## PR 测试

<!-- 如果可以,请为您的 PR 添加或更新单元测试。 -->
<!-- 请描述一下您是如何测试 PR 的。例如:创建/更新单元测试或添加相关的截图。 -->

## Changelog

| 模块  | Changelog | Related issues |
|-----|-----------| -------------- |
|     |           |                |

<!-- 如果有多种类型的变更,可以在变更日志表中增加 “类型” 列,该列的值与上方 “PR 类型” 相同。 -->
<!-- Related issues 格式为 Closes #<issue号>,或者 Fixes #<issue号>,或者 Resolves #<issue号>。 -->

## 其他信息

<!-- 请描述一下还有哪些注意事项。例如:如果引入了一个不向下兼容的变更,请描述其影响。 -->

## 提交前确认

- [X] PR 代码经过了完整测试,并且通过了代码规范检查
- [ ] 已经完整填写 Changelog,并链接到了相关 issues
- [X] PR 代码将要提交到 dev 分支

See merge request: continew/continew-admin-ui!11
This commit is contained in:
kiki1373639299
2025-11-05 11:36:02 +08:00
committed by Charles_7c
parent 12edcec062
commit 704aacc38f
10 changed files with 602 additions and 78 deletions

View File

@@ -0,0 +1,208 @@
<template>
<div class="layout-columns">
<div v-show="isDesktop" class="layout-columns__left">
<div class="layout-columns__menu-wrapper">
<!-- 左侧一级菜单区域 -->
<OneLevelMenu :menus="oneLevelMenus" @menu-click="handleMenuClick"></OneLevelMenu>
<div class="layout-columns__right-menu" :class="{ collapsed: appStore.menuCollapse }">
<!-- 系统标题 -->
<div class="layout-columns__title" @click="toHome">
<span v-show="!appStore.menuCollapse" class="system-name gi_line_1">{{ title }}</span>
</div>
<!-- 左侧二级菜单区域 -->
<Menu
v-if="twoLevelMenus.length > 1" class="layout-columns__menu" :menus="twoLevelMenus"
:menu-style="menuStyle"
/>
</div>
</div>
</div>
<!-- 右侧内容区域 -->
<section class="layout-columns__content">
<Header />
<Tabs v-if="appStore.tab" />
<Main />
</section>
</div>
</template>
<script setup lang="ts">
import type { RouteRecordRaw } from 'vue-router'
import { findTree, mapTree } from 'xe-utils'
import Header from './components/Header/index.vue'
import Main from './components/Main.vue'
import Menu from './components/Menu/index.vue'
import OneLevelMenu from './components/OneLevelMenu/index.vue'
import Tabs from './components/Tabs/index.vue'
import { filterTree } from '@/utils'
import { useAppStore, useRouteStore } from '@/stores'
import { useDevice } from '@/hooks'
defineOptions({ name: 'LayoutColumns' })
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const routeStore = useRouteStore()
const { isDesktop } = useDevice()
// 系统标题和Logo
const title = computed(() => appStore.getTitle())
// 菜单样式 - 根据折叠状态动态调整宽度
const menuStyle = computed(() => {
return {
width: appStore.menuCollapse ? '48px' : '200px',
}
})
// 跳转首页
const toHome = () => {
router.push('/')
}
// 处理菜单路由数据
const cloneRoutes = JSON.parse(JSON.stringify(routeStore.routes)) as RouteRecordRaw[]
const showMenuList = filterTree(cloneRoutes, (i) => i.meta?.hidden === false) as RouteRecordRaw[]
// 一级菜单
const oneLevelMenus = ref<RouteRecordRaw[]>([])
function getOneLevelMenus() {
const cloneList = JSON.parse(JSON.stringify(routeStore.routes)) as RouteRecordRaw[]
const formatList = mapTree(cloneList, (i) => {
if (i?.children?.length === 1 && i?.meta?.alwaysShow !== true) {
return i.children?.[0]
}
return i
})
const arr = formatList.filter((i) => i.meta?.hidden === false)
return arr
}
oneLevelMenus.value = getOneLevelMenus()
// 二级菜单
const twoLevelMenus = computed(() => {
const obj = findTree(showMenuList, (i) => i.path === route.path)
return showMenuList?.[Number(obj.path[0])]?.children || []
})
function handleMenuClick(item: RouteRecordRaw) {
if (item.redirect === 'noRedirect') {
router.replace({ path: item.children?.[0]?.path })
return
}
router.replace({ path: (item.redirect as string) || item.path })
}
</script>
<style lang="scss" scoped>
.layout-columns {
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
&__left {
height: 100%;
background-color: var(--color-bg-1);
overflow: hidden;
display: flex;
flex-direction: column;
position: relative;
}
&__menu-wrapper {
flex: 1;
display: flex;
overflow: hidden;
}
&__right-menu {
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--color-border-2);
transition: width 0.2s;
width: 200px;
&.collapsed {
width: 48px;
}
}
&__title {
height: 56px;
padding: 0 12px;
color: var(--color-text-1);
font-size: 20px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-sizing: border-box;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
user-select: none;
transition: padding 0.2s;
.layout-columns__right-menu.collapsed & {
padding: 0;
}
.logo {
width: 32px;
height: 32px;
border-radius: 6px;
transition: all 0.2s;
overflow: hidden;
flex-shrink: 0;
}
.system-name {
padding-left: 6px;
white-space: nowrap;
transition: color 0.3s;
line-height: 1.5;
display: inline-flex;
align-items: center;
&:hover {
color: $color-theme !important;
cursor: pointer;
}
}
}
&__menu {
flex: 1;
overflow: hidden;
}
// 折叠状态下的菜单样式
:deep(.arco-menu.arco-menu-vertical.arco-menu-collapsed) {
.arco-menu-icon {
margin-right: 0;
padding: 10px 0;
}
.arco-menu-has-icon {
padding: 0;
justify-content: center;
}
.arco-menu-title {
display: none;
}
}
&__content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
</style>

51
src/layout/LayoutTop.vue Normal file
View File

@@ -0,0 +1,51 @@
<template>
<div class="layout-top">
<a-row align="center" class="layout-top__header">
<Logo></Logo>
<Menu class="layout-top__menu"></Menu>
<HeaderRightBar></HeaderRightBar>
</a-row>
<Tabs v-if="appStore.tab"></Tabs>
<Main></Main>
</div>
</template>
<script setup lang="ts">
import HeaderRightBar from './components/HeaderRightBar/index.vue'
import Logo from './components/Logo.vue'
import Main from './components/Main.vue'
import Menu from './components/Menu/index.vue'
import Tabs from './components/Tabs/index.vue'
import { useAppStore } from '@/stores'
defineOptions({ name: 'LayoutTop' })
const appStore = useAppStore()
</script>
<style lang="scss" scoped>
.layout-top {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
&__header {
width: 100%;
overflow: hidden;
box-sizing: border-box;
border-bottom: 1px solid var(--color-border);
background-color: var(--color-bg-1);
padding-right: 16px;
}
&__menu {
flex: 1;
overflow: hidden;
:deep(.arco-menu-inner) {
padding-left: 0;
}
}
}
</style>

View File

@@ -5,29 +5,10 @@
复制配置按钮并将配置粘贴到 src/config/settings.ts 文件中 复制配置按钮并将配置粘贴到 src/config/settings.ts 文件中
</a-alert> </a-alert>
<a-divider v-if="settingOpen" orientation="center">系统布局</a-divider> <a-divider v-if="settingOpen" orientation="center">系统布局</a-divider>
<a-row v-if="settingOpen" justify="center"> <a-row v-if="settingOpen" :gutter="[8, 8]">
<a-space> <a-col v-for="item in LAYOUT_OPTIONS" :key="item.value" :span="8">
<a-badge> <LayoutItem :mode="item.value" :name="item.label" @click="toggleLayout(item.value)" />
<template #content> </a-col>
<icon-check-circle-fill
v-if="appStore.layout === 'left'" style="color: rgb(var(--success-6))"
:size="16"
></icon-check-circle-fill>
</template>
<LayoutItem mode="left" @click="appStore.layout = 'left'"></LayoutItem>
<p class="layout-text">默认布局</p>
</a-badge>
<a-badge>
<template #content>
<icon-check-circle-fill
v-if="appStore.layout === 'mix'" :size="16"
style="color: rgb(var(--success-6))"
></icon-check-circle-fill>
</template>
<LayoutItem mode="mix" @click="appStore.layout = 'mix'"></LayoutItem>
<p class="layout-text">混合布局</p>
</a-badge>
</a-space>
</a-row> </a-row>
<a-divider orientation="center">系统主题</a-divider> <a-divider orientation="center">系统主题</a-divider>
@@ -111,6 +92,15 @@ defineOptions({ name: 'SettingDrawer' })
const appStore = useAppStore() const appStore = useAppStore()
const visible = ref(false) const visible = ref(false)
const settingOpen = JSON.parse(import.meta.env.VITE_APP_SETTING) const settingOpen = JSON.parse(import.meta.env.VITE_APP_SETTING)
interface LayoutItemProps { label: string, value: App.AppSettings['layout'] }
/** 布局选项 */
const LAYOUT_OPTIONS: LayoutItemProps[] = [
{ label: '默认布局', value: 'left' },
{ label: '混合布局', value: 'mix' },
{ label: '顶部布局', value: 'top' },
{ label: '双列布局', value: 'columns' },
]
const tabModeList: App.TabItem[] = [ const tabModeList: App.TabItem[] = [
{ label: '卡片', value: 'card' }, { label: '卡片', value: 'card' },
{ label: '间隔卡片', value: 'card-gutter' }, { label: '间隔卡片', value: 'card-gutter' },
@@ -190,6 +180,10 @@ const copySettings = () => {
Message.error({ content: '请检查浏览器权限是否开启' }) Message.error({ content: '请检查浏览器权限是否开启' })
} }
} }
/** 切换布局 */
const toggleLayout = (layout: App.AppSettings['layout']) => {
appStore.layout = layout
}
defineExpose({ open }) defineExpose({ open })
</script> </script>

View File

@@ -1,62 +1,191 @@
<!--
@file LayoutItem 组件
@description 布局切换选项组件支持左侧布局顶部布局和混合布局三种模式
-->
<template> <template>
<div class="layout-item" :class="`layout-item-${mode}`" @click="emit('click')"></div> <a-badge class="w-full">
<!-- 选中状态标记 -->
<template #content>
<icon-check-circle-fill
v-if="appStore.layout === props.mode" style="color: rgb(var(--success-6))"
:size="16"
></icon-check-circle-fill>
</template>
<!-- 布局预览 -->
<div class="layout-mode-item" :class="`layout-mode-item__${props.mode}`" @click="emit('click')">
<!-- 左侧布局 -->
<template v-if="props.mode === 'left'">
<div class="block-left"></div>
<div class="block-right"></div>
</template>
<!-- 顶部布局 -->
<template v-if="props.mode === 'top'">
<div class="block-top"></div>
<div class="block-bottom"></div>
</template>
<!-- 混合布局 -->
<template v-if="props.mode === 'mix'">
<div class="block-top"></div>
<div class="block-main">
<div class="block-left"></div>
<div class="block-right"></div>
</div>
</template>
<!-- 双列布局 -->
<template v-if="props.mode === 'columns'">
<div class="block-left block-column"></div>
<div class="block-left"></div>
<div class="block-right"></div>
</template>
</div>
<!-- 布局名称 -->
<p class="layout-mode-item__text">{{ props.name }}</p>
</a-badge>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAppStore } from '@/stores'
defineOptions({
name: 'LayoutModeItem',
})
/** Props 默认值 */
const props = withDefaults(defineProps<Props>(), {
mode: 'left',
})
/** Emits 定义 */
const emit = defineEmits<{
(e: 'click'): void
}>()
/** 布局模式类型 */
type LayoutMode = 'left' | 'top' | 'mix' | 'columns'
/** Props 类型定义 */
interface Props { interface Props {
mode: 'left' | 'top' | 'mix' /** 布局模式 */
mode?: LayoutMode
/** 布局名称 */
name: string
} }
withDefaults(defineProps<Props>(), {}) /** 应用状态 */
const appStore = useAppStore()
const emit = defineEmits(['click'])
</script> </script>
<style scoped lang="scss"> <style lang="scss" scoped>
.layout-item { // 布局项基础样式
width: 60px; .layout-mode-item {
width: 100%;
height: 50px; height: 50px;
background-color: var(--color-fill-3); padding: 4px;
border-radius: 3px; display: flex;
cursor: pointer;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
box-sizing: border-box; box-sizing: border-box;
position: relative; border-radius: 2px;
&::before { background-color: var(--color-bg-5);
content: ''; border: 1px solid var(--color-border-2);
width: 12px; box-shadow: 0 1px 2px -2px rgba(0, 0, 0, .08),
height: 100%; 0 3px 6px 0 rgba(0, 0, 0, .06),
background-color: rgb(var(--gray-9)); 0 5px 12px 4px rgba(0, 0, 0, .04);
position: absolute;
top: 0; &__text {
left: 0; font-size: 12px;
display: none; margin-top: 5px;
} text-align: center;
&::after { color: var(--color-text-2);
content: '';
width: 100%;
height: 10px;
background-color: rgb(var(--gray-9));
position: absolute;
top: 0;
left: 0;
display: none;
}
&-left {
&::before {
display: block;
} }
} }
&-top {
&::after { .block-left,
display: block; .block-right,
.block-top,
.block-bottom {
border-radius: 2px;
}
// 左侧布局样式
.layout-mode-item__left {
.block-left {
width: 10px;
background-color: $color-theme;
}
.block-right {
flex: 1;
margin-left: 4px;
background-color: var(--color-fill-3);
} }
} }
&-mix {
&::before, // 顶部布局样式
&::after { .layout-mode-item__top {
display: block; flex-direction: column;
.block-top {
height: 8px;
background-color: $color-theme;
} }
.block-bottom {
flex: 1;
margin-top: 4px;
background-color: var(--color-fill-3);
}
}
// 混合布局样式
.layout-mode-item__mix {
flex-direction: column;
.block-top {
height: 8px;
margin-bottom: 3px;
background-color: $color-theme;
}
.block-main {
flex: 1;
display: flex;
.block-left {
width: 10px;
background-color: $color-theme;
}
.block-right {
flex: 1;
margin-left: 3px;
background-color: var(--color-fill-3);
}
}
}
// 双列布局样式
.layout-mode-item__columns {
.block-left {
width: 10px;
background-color: $color-theme;
}
.block-right {
flex: 1;
margin-left: 4px;
background-color: var(--color-fill-3);
}
.block-column {
margin-right: 4px;
} }
} }
</style> </style>

View File

@@ -2,7 +2,7 @@
<section class="system-logo" :class="{ collapsed: props.collapsed }" @click="toHome"> <section class="system-logo" :class="{ collapsed: props.collapsed }" @click="toHome">
<img v-if="logo" class="logo" :src="logo" alt="logo" /> <img v-if="logo" class="logo" :src="logo" alt="logo" />
<img v-else class="logo" src="/logo.svg" alt="logo" /> <img v-else class="logo" src="/logo.svg" alt="logo" />
<span class="system-name gi_line_1">{{ title }}</span> <span v-if="!props.hideName" class="system-name gi_line_1">{{ title }}</span>
</section> </section>
</template> </template>
@@ -11,13 +11,19 @@ import { useAppStore } from '@/stores'
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
collapsed: false, collapsed: false,
hideName: false,
}) })
const appStore = useAppStore() const appStore = useAppStore()
const title = computed(() => appStore.getTitle()) const title = computed(() => appStore.getTitle())
const logo = computed(() => appStore.getLogo()) const logo = computed(() => appStore.getLogo())
/** Props 类型定义 */
interface Props { interface Props {
/** 是否折叠状态 */
collapsed?: boolean collapsed?: boolean
/** 是否隐藏名称 */
hideName?: boolean
} }
const router = useRouter() const router = useRouter()
// 跳转首页 // 跳转首页

View File

@@ -6,7 +6,7 @@
:accordion="appStore.menuAccordion" :accordion="appStore.menuAccordion"
:breakpoint="appStore.layout === 'mix' ? 'xl' : undefined" :breakpoint="appStore.layout === 'mix' ? 'xl' : undefined"
:trigger-props="{ animationName: 'slide-dynamic-origin' }" :trigger-props="{ animationName: 'slide-dynamic-origin' }"
:collapsed="!isDesktop ? false : appStore.menuCollapse" :collapsed="mode === 'vertical' && isDesktop ? appStore.menuCollapse : false"
:style="menuStyle" :style="menuStyle"
@menu-item-click="onMenuItemClick" @menu-item-click="onMenuItemClick"
@collapse="onCollapse" @collapse="onCollapse"
@@ -44,11 +44,7 @@ const sidebarRoutes = computed(() => (props.menus ? props.menus : routeStore.rou
// 菜单垂直模式/水平模式 // 菜单垂直模式/水平模式
const mode = computed(() => { const mode = computed(() => {
if (!['left', 'mix'].includes(appStore.layout)) { return ['left', 'mix', 'columns'].includes(appStore.layout) ? 'vertical' : 'horizontal'
return 'horizontal'
} else {
return 'vertical'
}
}) })
// 是否默认展开选中的菜单 // 是否默认展开选中的菜单

View File

@@ -0,0 +1,15 @@
<template>
<GiSvgIcon v-if="props.svgIcon" :name="props.svgIcon" :size="24"></GiSvgIcon>
<component :is="props.icon" v-else-if="props.icon"></component>
</template>
<script lang="ts" setup>
interface Props {
svgIcon?: string
icon?: string
}
const props = defineProps<Props>()
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,104 @@
<template>
<div class="one-level-menu">
<Logo class="one-level-menu__logo" hide-name></Logo>
<div class="one-level-menu__wrap">
<a-scrollbar style="height:100%;overflow: auto;" :outer-style="{ width: '100%' }">
<ul class="one-level-menu__list">
<li
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)"
>
<GiSvgIcon :name="item?.meta?.icon || ''"></GiSvgIcon>
<p class="one-level-menu__item__title gi_line_1" :title="item?.meta?.title">{{ item?.meta?.title }}</p>
</li>
</ul>
</a-scrollbar>
</div>
</div>
</template>
<script setup lang="ts">
import type { RouteRecordRaw } from 'vue-router'
import Logo from '../Logo.vue'
import MenuIcon from './MenuIcon.vue'
interface Props {
menus?: RouteRecordRaw[]
}
const props = withDefaults(defineProps<Props>(), {
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 !== '/'
}
</script>
<style lang="scss" scoped>
:deep(.arco-scrollbar-track) {
display: none;
}
.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;
&__logo {
justify-content: center;
border-bottom: 1px solid var(--color-border-2);
:deep(.logo) {
width: 40px;
height: 40px;
}
}
&__wrap {
flex: 1;
display: flex;
overflow: hidden;
}
&__list {
padding: 4px 4px 0;
box-sizing: border-box;
}
&__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 0;
margin-bottom: 4px;
border-radius: 4px;
cursor: pointer;
&--active {
background-color: var(--color-primary-light-2);
color: rgb(var(--primary-6))
}
&:not(.one-level-menu__item--active):hover {
background-color: var(--color-fill-2);
}
&__title {
font-size: 12px;
margin-top: 8px;
line-height: 1;
padding: 0 8px;
box-sizing: border-box;
}
}
}
</style>

View File

@@ -1,15 +1,36 @@
<!--
@file Layout 组件
@description 布局根组件支持默认布局混合布局和顶部布局三种模式
-->
<template> <template>
<LayoutMix v-if="appStore.layout === 'mix'"></LayoutMix> <component :is="currentLayout" />
<LayoutDefault v-else></LayoutDefault>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import LayoutDefault from './LayoutDefault.vue'
import LayoutMix from './LayoutMix.vue'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
/** 组件名称 */
defineOptions({ name: 'Layout' }) defineOptions({ name: 'Layout' })
const LayoutDefault = defineAsyncComponent(() => import('./LayoutDefault.vue'))
const LayoutColumns = defineAsyncComponent(() => import('./LayoutColumns.vue'))
const LayoutMix = defineAsyncComponent(() => import('./LayoutMix.vue'))
const LayoutTop = defineAsyncComponent(() => import('./LayoutTop.vue'))
/** 状态管理 */
const appStore = useAppStore() const appStore = useAppStore()
/** 布局组件映射 */
const layoutMap = {
mix: LayoutMix,
top: LayoutTop,
default: LayoutDefault,
columns: LayoutColumns,
} as const
/** 当前布局组件 */
const currentLayout = computed(() =>
layoutMap[appStore.layout as keyof typeof layoutMap] || layoutMap.default,
)
</script> </script>
<style scoped lang="scss"></style> <style lang="scss" scoped></style>

2
src/types/app.d.ts vendored
View File

@@ -10,7 +10,7 @@ declare namespace App {
menuAccordion: boolean menuAccordion: boolean
menuDark: boolean menuDark: boolean
copyrightDisplay: boolean copyrightDisplay: boolean
layout: 'left' | 'mix' layout: 'left' | 'mix' | 'columns' | 'top'
isOpenWatermark?: boolean isOpenWatermark?: boolean
watermark?: string watermark?: string
enableColorWeaknessMode?: boolean enableColorWeaknessMode?: boolean