Explorar o código

feat: tagView 通过 el-tabs 重构
feat: 多页签主题配置 tagsView 更新跳转 fullPath 等参数 更新
feat: 菜单嵌套测试
feat: Tabs 快捷下拉操作 多语言转换 & 新增当前页 全屏功能

lanceJiang hai 1 ano
pai
achega
ecb2f54818
Modificáronse 35 ficheiros con 1429 adicións e 460 borrados
  1. 1 0
      package.json
  2. 2 2
      src/api/system/menu.ts
  3. 1 0
      src/assets/icons/back.svg
  4. 1 1
      src/assets/icons/exit-fullscreen.svg
  5. 0 1
      src/assets/icons/fullscreen.svg
  6. 15 2
      src/lang/lance-element/en.ts
  7. 14 2
      src/lang/lance-element/zh-cn.ts
  8. 66 66
      src/layout/LayoutLeft/index.scss
  9. 11 4
      src/layout/LayoutLeft/index.vue
  10. 114 111
      src/layout/LayoutLeftMix/index.scss
  11. 19 13
      src/layout/LayoutLeftMix/index.vue
  12. 62 62
      src/layout/LayoutTop/index.scss
  13. 5 7
      src/layout/LayoutTop/index.vue
  14. 9 9
      src/layout/LayoutTopMix/index.scss
  15. 11 4
      src/layout/LayoutTopMix/index.vue
  16. 3 0
      src/layout/RouteView.vue
  17. 20 6
      src/layout/components/AppMain.vue
  18. 3 20
      src/layout/components/Header/components/Breadcrumb.vue
  19. 38 0
      src/layout/components/MaximizeQuit.vue
  20. 6 11
      src/layout/components/Menu/SubMenu.vue
  21. 33 5
      src/layout/components/Settings/index.vue
  22. 699 0
      src/layout/components/Tabs/index.vue
  23. 130 94
      src/router/index.ts
  24. 8 2
      src/settings.ts
  25. 9 5
      src/store/modules/permission.ts
  26. 42 1
      src/store/modules/tagsView.ts
  27. 14 0
      src/styles/lance-element-vue.scss
  28. 12 21
      src/types/global.d.ts
  29. 19 11
      src/types/store.d.ts
  30. 11 0
      src/views/menuNested/menu1/index.vue
  31. 10 0
      src/views/menuNested/menu2/menu21/index.vue
  32. 12 0
      src/views/menuNested/menu2/menu22/menu221/index.vue
  33. 11 0
      src/views/menuNested/menu2/menu22/menu222/index.vue
  34. 11 0
      src/views/menuNested/menu2/menu23/index.vue
  35. 7 0
      src/views/menuNested/menu3/index.vue

+ 1 - 0
package.json

@@ -51,6 +51,7 @@
     "@types/js-md5": "^0.7.0",
     "@types/nprogress": "^0.2.0",
     "@types/path-browserify": "^1.0.0",
+		"@types/sortablejs": "^1.15.3",
     "@types/vue-ls": "^3.2.3",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.6",

+ 2 - 2
src/api/system/menu.ts

@@ -22,8 +22,8 @@ export function getMenuList(): AxiosPromise {
 		url: '/sys/resource/list-menu-permissions',
 		method: 'get',
 		extraConfig: { showFullscreenLoading: true }
-	}).then(res => {
-		console.error(JSON.stringify(res), 'res...')
+	}).then((res: any) => {
+		// console.error(JSON.stringify(res), 'res...')
 		// res['menu'] = [res.menu[0]/*, res.menu[1]*/]
 		res['menu'].unshift(...localDemoRoutes)
 		return res

+ 1 - 0
src/assets/icons/back.svg

@@ -0,0 +1 @@
+<svg t="1697434924975" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="34536" width="48" height="48"><path d="M814.321078 638.500924s-6.288729 18.237314-14.464076 0c0 0-91.815441-320.096296-314.43644-239.600567v99.361915s-3.773237 58.485178-47.794339 20.123932L217.520714 298.909569s-46.536593-29.557025 0-69.80489L442.028333 8.370298s33.330263-27.670407 41.50561 17.608441v107.537262s416.94272 23.268297 330.158262 503.098305z m0 0" fill="" p-id="34537"></path></svg>

+ 1 - 1
src/assets/icons/exit-fullscreen.svg

@@ -1 +1 @@
-<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M49.217 41.329l-.136-35.24c-.06-2.715-2.302-4.345-5.022-4.405h-3.65c-2.712-.06-4.866 2.303-4.806 5.016l.152 19.164-24.151-23.79a6.698 6.698 0 0 0-9.499 0 6.76 6.76 0 0 0 0 9.526l23.93 23.713-18.345.074c-2.712-.069-5.228 1.813-5.64 5.02v3.462c.069 2.721 2.31 4.97 5.022 5.03l35.028-.207c.052.005.087.025.133.025l2.457.054a4.626 4.626 0 0 0 3.436-1.38c.88-.874 1.205-2.096 1.169-3.462l-.262-2.465c0-.048.182-.081.182-.136h.002zm52.523 51.212l18.32-.073c2.713.06 5.224-1.609 5.64-4.815v-3.462c-.068-2.722-2.317-4.97-5.021-5.04l-34.58.21c-.053 0-.086-.021-.138-.021l-2.451-.06a4.64 4.64 0 0 0-3.445 1.381c-.885.868-1.201 2.094-1.174 3.46l.27 2.46c.005.06-.177.095-.177.141l.141 34.697c.069 2.713 2.31 4.338 5.022 4.397l3.45.006c2.705.062 4.867-2.31 4.8-5.026l-.153-18.752 24.151 23.946a6.69 6.69 0 0 0 9.494 0 6.747 6.747 0 0 0 0-9.523L101.74 92.54v.001zM48.125 80.662a4.636 4.636 0 0 0-3.437-1.382l-2.457.06c-.05 0-.082.022-.137.022l-35.025-.21c-2.712.07-4.957 2.318-5.022 5.04v3.462c.409 3.206 2.925 4.874 5.633 4.814l18.554.06-24.132 23.928c-2.62 2.626-2.62 6.89 0 9.524a6.694 6.694 0 0 0 9.496 0l24.155-23.79-.155 18.866c-.06 2.722 2.094 5.093 4.801 5.025h3.65c2.72-.069 4.962-1.685 5.022-4.406l.141-34.956c0-.05-.182-.082-.182-.136l.262-2.46c.03-1.366-.286-2.592-1.166-3.46h-.001zM80.08 47.397a4.62 4.62 0 0 0 3.443 1.374l2.45-.054c.055 0 .088-.02.143-.028l35.08.21c2.712-.062 4.953-2.312 5.021-5.033l.009-3.463c-.417-3.211-2.937-5.084-5.64-5.025l-18.615-.073 23.917-23.715c2.63-2.623 2.63-6.879.008-9.513a6.691 6.691 0 0 0-9.494 0L92.251 26.016l.155-19.312c.065-2.713-2.097-5.085-4.802-5.025h-3.45c-2.713.069-4.954 1.693-5.022 4.406l-.139 35.247c0 .054.18.088.18.136l-.267 2.465c-.028 1.366.288 2.588 1.174 3.463v.001z"/></svg>
+<svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" width="200" height="200"><path d="M313.6 358.4H177.066667c-17.066667 0-32 14.933333-32 32s14.933333 32 32 32h213.333333c4.266667 0 8.533333 0 10.666667-2.133333 8.533333-4.266667 14.933333-8.533333 17.066666-17.066667 2.133333-4.266667 2.133333-8.533333 2.133334-10.666667v-213.333333c0-17.066667-14.933333-32-32-32s-32 14.933333-32 32v136.533333L172.8 125.866667c-12.8-12.8-32-12.8-44.8 0-12.8 12.8-12.8 32 0 44.8l185.6 187.733333zM695.466667 650.666667H832c17.066667 0 32-14.933333 32-32s-14.933333-32-32-32H618.666667c-4.266667 0-8.533333 0-10.666667 2.133333-8.533333 4.266667-14.933333 8.533333-17.066667 17.066667-2.133333 4.266667-2.133333 8.533333-2.133333 10.666666v213.333334c0 17.066667 14.933333 32 32 32s32-14.933333 32-32v-136.533334l200.533333 200.533334c6.4 6.4 14.933333 8.533333 23.466667 8.533333s17.066667-2.133333 23.466667-8.533333c12.8-12.8 12.8-32 0-44.8l-204.8-198.4zM435.2 605.866667c-4.266667-8.533333-8.533333-14.933333-17.066667-17.066667-4.266667-2.133333-8.533333-2.133333-10.666666-2.133333H192c-17.066667 0-32 14.933333-32 32s14.933333 32 32 32h136.533333L128 851.2c-12.8 12.8-12.8 32 0 44.8 6.4 6.4 14.933333 8.533333 23.466667 8.533333s17.066667-2.133333 23.466666-8.533333l200.533334-200.533333V832c0 17.066667 14.933333 32 32 32s32-14.933333 32-32V618.666667c-2.133333-4.266667-2.133333-8.533333-4.266667-12.8zM603.733333 403.2c4.266667 8.533333 8.533333 14.933333 17.066667 17.066667 4.266667 2.133333 8.533333 2.133333 10.666667 2.133333h213.333333c17.066667 0 32-14.933333 32-32s-14.933333-32-32-32h-136.533333L896 170.666667c12.8-12.8 12.8-32 0-44.8-12.8-12.8-32-12.8-44.8 0l-187.733333 187.733333V177.066667c0-17.066667-14.933333-32-32-32s-32 14.933333-32 32v213.333333c2.133333 4.266667 2.133333 8.533333 4.266666 12.8z" fill="currentColor"></path></svg>

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 1
src/assets/icons/fullscreen.svg


+ 15 - 2
src/lang/lance-element/en.ts

@@ -73,7 +73,14 @@ export default {
 		tabs: {
 			tab: 'Tab',
 			all: 'All',
-			tabSetting: 'Tab Setting'
+			tabSetting: 'Tab Setting',
+			opts: {
+				contentMax: 'Maximize Content',
+				closeOther: 'Close Other',
+				closeLeft: 'Close Left',
+				closeRight: 'Close Right',
+				closeAll: 'Close All'
+			}
 		},
 		columnsPop: {
 			title: 'Select option to display on the table',
@@ -134,7 +141,13 @@ export default {
 				footerVisible: 'Show Bottom',
 				crumbVisible: 'Crumb',
 				crumbIconVisible: 'Crumb Icon',
-				multipleTabsVisible: 'Multiple Tabs',
+				tabsVisible: 'Multiple Tabs',
+				tabsIcon: 'Multiple Tabs Icon',
+				// tabsIcon: 'Multi-tab  Icon',
+				tabsMode: 'Multi-tab Style',
+				tabsMode_chrome: 'Google Style',
+				tabsMode_card: 'Card Style',
+				tabsMode_rectangle: 'Rectangle',
 				pageAnimate: 'Switch Animation',
 				pageAnimateMode: 'Animation Type',
 				animate_fade: 'Fade Away',

+ 14 - 2
src/lang/lance-element/zh-cn.ts

@@ -72,7 +72,14 @@ export default {
 		tabs: {
 			tab: '标签',
 			all: '全部',
-			tabSetting: '标签设置'
+			tabSetting: '标签设置',
+			opts: {
+				contentMax: '内容最大化',
+				closeOther: '关闭其它',
+				closeLeft: '关闭左侧',
+				closeRight: '关闭右侧',
+				closeAll: '关闭全部'
+			}
 		},
 		columnsPop: {
 			title: '选择需要在列表展示的列',
@@ -133,7 +140,12 @@ export default {
 				footerVisible: '显示底部',
 				crumbVisible: '面包屑',
 				crumbIconVisible: '面包屑图标',
-				multipleTabsVisible: '多页签',
+				tabsVisible: '多页签',
+				tabsIcon: '页签图标',
+				tabsMode: '多页签风格',
+				tabsMode_chrome: '谷歌风格',
+				tabsMode_card: '卡片风格',
+				tabsMode_rectangle: '矩形风格',
 				pageAnimate: '页面切换动画',
 				pageAnimateMode: '页面切换动画类型',
 				animate_fade: '消退',

+ 66 - 66
src/layout/LayoutLeft/index.scss

@@ -1,70 +1,70 @@
 @import "../layout_common";
-.layout-wrap--left {
-	width: 100%;
-	height: 100%;
-	.el-aside {
-		width: auto;
-		background-color: var(--el-menu-bg-color);
-		border-right: 1px solid var(--el-aside-border-color);
-		//box-shadow: 1px 0 2px var(--el-aside-border-color);
-		//box-shadow: 1px 0 4px -1px var(--el-aside-border-color);
-		//z-index: $header_index + 1;
-		//box-shadow: 2px 0 8px #1d23290d;
-		.aside-box {
-			display: flex;
-			flex-direction: column;
-			height: 100%;
-			transition: width 0.3s ease;
-			.el-scrollbar {
-				height: calc(100% - $header_height);
-				.layout-menu-wrap {
-					width: 100%;
-					overflow-x: hidden;
-					border-right: none;
-					// 折叠
-					/*&--collapse {
-						//background-color: transparent !important;
-						.le-pick-icon {
-							margin-right: 0;
-						}
-					}*/
-				}
-			}
-			.logo {
-				display: flex;
-				align-items: center;
-				justify-content: center;
-				box-sizing: border-box;
-				height: $header_height; // todo  55-1 ???
-				.logo-img { // 样式提取 todo...
-					//width: 28px;
-					width: 36px;
-					height: 36px;
-					object-fit: contain;
-					//margin-right: 6px;
-					margin: 0 6px;
-				}
-				.logo-text {
-					font-size: 15px;
-					font-weight: bold;
-					color: var(--el-color-primary);
-					white-space: nowrap;
-				}
-			}
-		}
-	}
-	.el-header {
-		box-sizing: border-box;
-		display: flex;
-		align-items: center;
-		justify-content: space-between;
-		height: $header_height;
-		padding: 0;
-		background-color: var(--el-header-bg-color);
-		border-bottom: 1px solid var(--el-header-border-color);
-		//box-shadow: 0 1px 4px -1px var(--el-header-border-color);
-		//z-index: $header_index;
-	}
+.#{$prefix}layout-wrap--left {
+  width: 100%;
+  height: 100%;
+  .#{$prefix}layout-aside {
+    width: auto;
+    background-color: var(--el-menu-bg-color);
+    border-right: 1px solid var(--el-aside-border-color);
+    //box-shadow: 1px 0 2px var(--el-aside-border-color);
+    //box-shadow: 1px 0 4px -1px var(--el-aside-border-color);
+    //z-index: $header_index + 1;
+    //box-shadow: 2px 0 8px #1d23290d;
+    .aside-box {
+      display: flex;
+      flex-direction: column;
+      height: 100%;
+      transition: width 0.3s ease;
+      .el-scrollbar {
+        height: calc(100% - $header_height);
+        .layout-menu-wrap {
+          width: 100%;
+          overflow-x: hidden;
+          border-right: none;
+          // 折叠
+          /*&--collapse {
+            //background-color: transparent !important;
+            .#{$prefix}pick-icon {
+              margin-right: 0;
+            }
+          }*/
+        }
+      }
+      .logo {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        box-sizing: border-box;
+        height: $header_height; // todo  55-1 ???
+        .logo-img { // 样式提取 todo...
+          //width: 28px;
+          width: 36px;
+          height: 36px;
+          object-fit: contain;
+          //margin-right: 6px;
+          margin: 0 6px;
+        }
+        .logo-text {
+          font-size: 15px;
+          font-weight: bold;
+          color: var(--el-color-primary);
+          white-space: nowrap;
+        }
+      }
+    }
+  }
+  .el-header {
+    box-sizing: border-box;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: $header_height;
+    padding: 0;
+    background-color: var(--el-header-bg-color);
+    //border-bottom: 1px solid var(--el-header-border-color);
+		box-shadow: 0 1px 4px -1px var(--el-header-border-color);
+		z-index: $header_index;
+  }
 	.app-main {
 		min-height: 0;
 	}

+ 11 - 4
src/layout/LayoutLeft/index.vue

@@ -1,7 +1,7 @@
 <!-- 左侧菜单模式:left -->
 <template>
-	<el-container class="layout-wrap--left">
-		<el-aside>
+	<el-container class="le-layout-wrap--left">
+		<el-aside class="le-layout-aside">
 			<div class="aside-box" :style="{ width: isCollapse ? '65px' : '210px' }">
 				<div class="logo">
 					<!--          <SvgIcon class="logo-img sidebar-logo" icon-class="logo" />-->
@@ -9,14 +9,21 @@
 					<span v-show="!isCollapse" class="text-overflow_ellipsis logo-text" :title="title">{{ title }}</span>
 				</div>
 				<el-scrollbar>
-					<el-menu class="layout-menu-wrap" :router="false" :default-active="activeMenu" :collapse="isCollapse" :unique-opened="accordion" :collapse-transition="false">
+					<el-menu
+						class="layout-menu-wrap"
+						:router="false"
+						:default-active="activeMenu"
+						:collapse="isCollapse"
+						:unique-opened="accordion"
+						:collapse-transition="false"
+					>
 						<SubMenu :menu-list="menuList" />
 					</el-menu>
 				</el-scrollbar>
 			</div>
 		</el-aside>
 		<el-container>
-			<el-header>
+			<el-header class="le-layout-header">
 				<ToolBarLeft />
 				<ToolBarRight />
 			</el-header>

+ 114 - 111
src/layout/LayoutLeftMix/index.scss

@@ -1,35 +1,35 @@
 @import "../layout_common";
-.layout-wrap--leftMix {
-	width: 100%;
-	height: 100%;
-	.aside-split {
-		display: flex;
-		flex-direction: column;
-		flex-shrink: 0;
-		width: 70px;
-		height: 100%;
-		background-color: var(--el-menu-bg-color);
-		border-right: 1px solid var(--el-aside-border-color);
-		//box-shadow: 1px 0 2px var(--el-aside-border-color);
-		//z-index: $header_index + 3;
-		.logo {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			box-sizing: border-box;
-			height: 55px;
-			.logo-img {
-				//width: 32px;
-				width: 36px;
-				height: 36px;
-				object-fit: contain;
-			}
-		}
-		.el-scrollbar {
-			height: calc(100% - 55px);
-			.split-list {
-				flex: 1;
-				.split-item {
+.#{$prefix}layout-wrap--leftMix {
+  width: 100%;
+  height: 100%;
+  .#{$prefix}layout-aside-split {
+    display: flex;
+    flex-direction: column;
+    flex-shrink: 0;
+    width: 70px;
+    height: 100%;
+    background-color: var(--el-menu-bg-color);
+    border-right: 1px solid var(--el-aside-border-color);
+    //box-shadow: 1px 0 2px var(--el-aside-border-color);
+    //z-index: $header_index + 3;
+    .logo {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-sizing: border-box;
+      height: 55px;
+      .logo-img {
+        //width: 32px;
+        width: 36px;
+        height: 36px;
+        object-fit: contain;
+      }
+    }
+    .el-scrollbar {
+      height: calc(100% - 55px);
+      .split-list {
+        flex: 1;
+        .split-item {
 					&::before {
 						left: 2px;
 						right: 2px;
@@ -40,29 +40,32 @@
 							background-color: inherit;
 						}
 					}
-					display: flex;
-					flex-direction: column;
-					align-items: center;
-					justify-content: center;
-					height: 70px;
+          display: flex;
+          flex-direction: column;
+          align-items: center;
+          justify-content: center;
+          height: 70px;
 					line-height: unset;
-					cursor: pointer;
-					transition: all 0.3s ease;
-					.le-pick-icon {
+          cursor: pointer;
+          transition: all 0.3s ease;
+					.#{$prefix}pick-icon {
 						//font-size: 18px;
 						margin-right: 0;
 					}
-					.title {
-						margin-top: 6px;
-						font-size: 12px;
+          .title {
+            margin-top: 6px;
+            font-size: 12px;
 						//color: inherit;
+          }
+          /*.#{$prefix}pick-icon,
+          .title {
+            color: var(--el-menu-text-color);
+          }*/
+					.#{$prefix}pick-icon {
+						font-size: 18px;
 					}
-					/*.le-pick-icon,
-					.title {
-						color: var(--el-menu-text-color);
-					}*/
-				}
-				.split-active {
+        }
+        .split-active {
 					&:hover {
 						&::before {
 							background-color: var(--el-menu-active-color);
@@ -71,68 +74,68 @@
 					&::before {
 						background-color: var(--el-menu-active-color);
 					}
-					.le-pick-icon,
-					.title {
+          .#{$prefix}pick-icon,
+          .title {
 						color: var(--el-menu-active-color);
-					}
-				}
-			}
-		}
-	}
-	.not-aside {
-		width: 0 !important;
-		border-right: none !important;
-	}
-	.el-aside {
-		display: flex;
-		flex-direction: column;
-		height: 100%;
-		overflow: hidden;
-		background-color: var(--el-menu-bg-color);
-		border-right: 1px solid var(--el-aside-border-color);
-		//z-index: $header_index + 2;
-		//box-shadow: 1px 0 2px var(--el-aside-border-color);
-		//box-shadow: 2px 0 8px #1d23290d;
-		//box-shadow: 1px 0 4px -1px var(--el-aside-border-color);
-		transition: width 0.3s ease;
-		.el-scrollbar {
-			height: calc(100% - 55px);
-			.layout-menu-wrap {
-				width: 100%;
-				overflow-x: hidden;
-				border-right: none;
-			}
-		}
-		.logo {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			box-sizing: border-box;
-			padding: 0 6px;
-			height: 55px;
-			.logo-text {
-				//font-size: 24px;
-				font-size: 15px;
-				font-weight: bold;
-				color: var(--el-color-primary);
-				white-space: nowrap;
-			}
-		}
-	}
-	.el-header {
-		box-sizing: border-box;
-		display: flex;
-		align-items: center;
-		justify-content: space-between;
-		height: 55px;
-		//padding: 0 15px;
-		padding: 0;
-		background-color: var(--el-header-bg-color);
-		//border-bottom: 1px solid var(--el-border-color-light);
-		box-shadow: 0 1px 4px -1px var(--el-header-border-color);
-		z-index: $header_index;
-	}
-	.app-main {
-		min-height: 0;
-	}
+          }
+        }
+      }
+    }
+  }
+  .not-aside {
+    width: 0 !important;
+    border-right: none !important;
+  }
+  .el-aside {
+    display: flex;
+    flex-direction: column;
+    height: 100%;
+    overflow: hidden;
+    background-color: var(--el-menu-bg-color);
+    border-right: 1px solid var(--el-aside-border-color);
+    //z-index: $header_index + 2;
+    //box-shadow: 1px 0 2px var(--el-aside-border-color);
+    //box-shadow: 2px 0 8px #1d23290d;
+    //box-shadow: 1px 0 4px -1px var(--el-aside-border-color);
+    transition: width 0.3s ease;
+    .el-scrollbar {
+      height: calc(100% - 55px);
+      .layout-menu-wrap {
+        width: 100%;
+        overflow-x: hidden;
+        border-right: none;
+      }
+    }
+    .logo {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      box-sizing: border-box;
+      padding: 0 6px;
+      height: 55px;
+      .logo-text {
+        //font-size: 24px;
+        font-size: 15px;
+        font-weight: bold;
+        color: var(--el-color-primary);
+        white-space: nowrap;
+      }
+    }
+  }
+  .el-header {
+    box-sizing: border-box;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: 55px;
+    //padding: 0 15px;
+    padding: 0;
+    background-color: var(--el-header-bg-color);
+    border-bottom: 1px solid var(--el-border-color-light);
+    //box-shadow: 0 1px 4px -1px var(--el-header-border-color);
+    //z-index: $header_index;
+  }
+  .app-main {
+    min-height: 0;
+  }
 }

+ 19 - 13
src/layout/LayoutLeftMix/index.vue

@@ -1,9 +1,8 @@
 <!-- 左侧菜单混合模式:leftMix -->
 <template>
-	<el-container class="layout-wrap--leftMix">
-		<div class="aside-split">
+	<el-container class="le-layout-wrap--leftMix">
+		<div class="le-layout-aside-split">
 			<div class="logo">
-				<!--				<img class="logo-img" src="@/assets/images/logo.svg" alt="logo" />-->
 				<!--          <SvgIcon class="logo-img sidebar-logo" icon-class="logo" />-->
 				<img class="logo-img" src="@/assets/icons/logo.svg" alt="logo" />
 			</div>
@@ -17,26 +16,30 @@
 						@click="changeSubMenu(item)"
 					>
 						<PickerIcon v-if="item.meta?.icon" :icon-class="item.meta.icon"></PickerIcon>
-						<!--						<el-icon>
-													<component :is="item.meta.icon"></component>
-												</el-icon>-->
 						<span class="title">{{ generateTitle(item.meta.title) }}</span>
 					</div>
 				</div>
 			</el-scrollbar>
 		</div>
-		<el-aside :class="{ 'not-aside': !subMenuList.length }" :style="{ width: isCollapse ? '65px' : '210px' }">
+		<el-aside class="le-layout-aside" :class="{ 'not-aside': !subMenuList.length }" :style="{ width: isCollapse ? '65px' : '210px' }">
 			<div class="logo">
 				<span v-show="subMenuList.length" class="text-overflow_ellipsis logo-text" :title="title">{{ title }}</span>
 			</div>
 			<el-scrollbar>
-				<el-menu class="layout-menu-wrap" :router="false" :default-active="activeMenu" :collapse="isCollapse" :unique-opened="accordion" :collapse-transition="false">
+				<el-menu
+					class="layout-menu-wrap"
+					:router="false"
+					:default-active="activeMenu"
+					:collapse="isCollapse"
+					:unique-opened="accordion"
+					:collapse-transition="false"
+				>
 					<SubMenu :menu-list="subMenuList" />
 				</el-menu>
 			</el-scrollbar>
 		</el-aside>
 		<el-container>
-			<el-header>
+			<el-header class="le-layout-header">
 				<ToolBarLeft />
 				<ToolBarRight />
 			</el-header>
@@ -45,7 +48,7 @@
 	</el-container>
 </template>
 
-<script setup lang="ts" name="layoutLeftMix">
+<script setup lang="ts" name="layoutTopMix">
 import { ref, computed, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import AppMain from '@/layout/components/AppMain.vue'
@@ -55,6 +58,8 @@ import SubMenu from '@/layout/components/Menu/SubMenu.vue'
 import PickerIcon from '@/components/IconPicker/PickerIcon.vue'
 import useStore from '@/store'
 import { generateTitle } from '@/utils/i18n'
+import { isExternal } from '@/utils/validate'
+// import { AppRouteRecordRaw } from '@/router/types'
 const title = import.meta.env.VITE_APP_TITLE
 
 const route = useRoute()
@@ -65,7 +70,7 @@ const isCollapse = computed(() => setting.isCollapse)
 const menuList = computed(() => permission.showMenuList)
 const activeMenu = computed(() => (route.meta?.activeMenu ? route.meta.activeMenu : route.path) as string)
 
-const subMenuList = ref<Menu.MenuOptions[]>([])
+const subMenuList = ref<RouteMenu.Item[]>([])
 const splitActive = ref('')
 watch(
 	() => [menuList, route],
@@ -73,7 +78,7 @@ watch(
 		// 当前菜单没有数据直接 return
 		if (!menuList.value.length) return
 		splitActive.value = route.path
-		const menuItem = menuList.value.filter((item: Menu.MenuOptions) => {
+		const menuItem = menuList.value.filter((item: RouteMenu.Item) => {
 			return route.path === item.path || `/${route.path.split('/')[1]}` === item.path
 			// const pathArr = route.path.split('/')
 			// const childPath = pathArr.length > 1 ? `/${pathArr[1]}` : pathArr[0]
@@ -89,10 +94,11 @@ watch(
 )
 
 // change SubMenu
-const changeSubMenu = (item: Menu.MenuOptions) => {
+const changeSubMenu = (item: RouteMenu.Item) => {
 	splitActive.value = item.path
 	if (item.children?.length) return (subMenuList.value = item.children)
 	subMenuList.value = []
+	if (isExternal(item.path)) return window.open(item.path, '_blank')
 	router.push(item.path)
 }
 </script>

+ 62 - 62
src/layout/LayoutTop/index.scss

@@ -1,48 +1,48 @@
 @import "../layout_common";
-.layout-wrap--top {
-	width: 100%;
-	height: 100%;
-	.el-header {
-		box-sizing: border-box;
-		display: flex;
-		align-items: center;
-		justify-content: space-between;
-		height: $header_height;
-		padding: 0;
-		background-color: var(--el-header-bg-color);
-		border-bottom: 1px solid var(--el-header-border-color);
-		//box-shadow: 0 1px 4px -1px var(--el-header-border-color);
-		//z-index: $header_index;
-		.logo {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			width: 210px;
-			margin-right: 10px;
-			.logo-img {
-				//width: 28px;
-				width: 36px;
-				height: 36px;
-				object-fit: contain;
-				margin: 0 6px;
-			}
-			.logo-text {
-				font-size: 15px;
-				font-weight: bold;
-				color: var(--el-color-primary);
-				white-space: nowrap;
-			}
-		}
-		.layout-menu-wrap {
-			flex: 1;
-			height: 100%;
-			overflow: hidden;
-			border-bottom: none;
+.#{$prefix}layout-wrap--top {
+  width: 100%;
+  height: 100%;
+	.#{$prefix}layout-header {
+    box-sizing: border-box;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    height: $header_height;
+    padding: 0;
+    background-color: var(--el-header-bg-color);
+    border-bottom: 1px solid var(--el-header-border-color);
+    //box-shadow: 0 1px 4px -1px var(--el-header-border-color);
+    //z-index: $header_index;
+    .logo {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 210px;
+      margin-right: 10px;
+      .logo-img {
+        //width: 28px;
+        width: 36px;
+        height: 36px;
+        object-fit: contain;
+        margin: 0 6px;
+      }
+      .logo-text {
+        font-size: 15px;
+        font-weight: bold;
+        color: var(--el-color-primary);
+        white-space: nowrap;
+      }
+    }
+    .layout-menu-wrap {
+      flex: 1;
+      height: 100%;
+      overflow: hidden;
+      border-bottom: none;
 			margin-top: 0;
-			.el-sub-menu__hide-arrow {
-				width: 65px;
-				//height: $header_height - 1px;
-			}
+      .el-sub-menu__hide-arrow {
+        width: 65px;
+        //height: $header_height - 1px;
+      }
 			.el-menu-item,
 			.el-sub-menu__title {
 				margin-top: 0;
@@ -51,26 +51,26 @@
 					right: 0;
 				}
 			}
-			.is-active {
-				border-bottom-color: var(--el-color-primary) !important;
-				/*&::before {
-					width: 0;
-				}*/
-				.el-sub-menu__title {
-					border-bottom-color: var(--el-color-primary) !important;
-				}
-			}
-		}
-	}
+      .is-active {
+        border-bottom-color: var(--el-color-primary) !important;
+        /*&::before {
+          width: 0;
+        }*/
+        .el-sub-menu__title {
+          border-bottom-color: var(--el-color-primary) !important;
+        }
+      }
+    }
+  }
 
-	@media screen and (width <= 730px) {
-		.logo {
-			display: none !important;
-		}
-	}
-	.app-main {
-		min-height: 0;
-	}
+  @media screen and (width <= 730px) {
+    .logo {
+      display: none !important;
+    }
+  }
+  .app-main {
+    min-height: 0;
+  }
 }
 .layout-menu-popper-wrap.el-menu--horizontal {
 	--el-menu-horizontal-sub-item-height: 42px;

+ 5 - 7
src/layout/LayoutTop/index.vue

@@ -1,7 +1,7 @@
 <!-- 顶部菜单模式:top -->
 <template>
-	<el-container class="layout-wrap--top">
-		<el-header>
+	<el-container class="le-layout-wrap--top">
+		<el-header class="le-layout-header">
 			<div class="logo">
 				<!--          <SvgIcon class="logo-img sidebar-logo" icon-class="logo" />-->
 				<img class="logo-img" src="@/assets/icons/logo.svg" alt="logo" />
@@ -17,9 +17,6 @@
 						:index="subItem.path + 'el-sub-menu'"
 					>
 						<template #title>
-							<!--							<el-icon>
-								<component :is="subItem.meta.icon"></component>
-							</el-icon>-->
 							<PickerIcon v-if="subItem.meta.icon" :icon-class="subItem.meta.icon" />
 							<span>{{ generateTitle(subItem.meta?.title) }}</span>
 						</template>
@@ -55,15 +52,16 @@ import PickerIcon from '@/components/IconPicker/PickerIcon.vue'
 import useStore from '@/store'
 import { generateTitle } from '@/utils/i18n'
 import { isExternal } from '@/utils/validate.ts'
+// import { AppRouteRecordRaw } from '@/router/types'
 
 const title = import.meta.env.VITE_APP_TITLE
 const { permission, setting, app } = useStore()
 const route = useRoute()
 const router = useRouter()
 const menuList = computed(() => permission.showMenuList)
-const activeMenu = computed(() => (route.meta.activeMenu ? route.meta.activeMenu : route.path) as string)
+const activeMenu = computed(() => (route.meta?.activeMenu ? route.meta.activeMenu : route.path) as string)
 
-const handleClickMenu = (subItem: Menu.MenuOptions) => {
+const handleClickMenu = (subItem: RouteMenu.Item) => {
 	if (isExternal(subItem.path)) return window.open(subItem.path, '_blank')
 	router.push(subItem.path)
 }

+ 9 - 9
src/layout/LayoutTopMix/index.scss

@@ -1,5 +1,5 @@
 @import "../layout_common";
-.layout-wrap--topMix {
+.#{$prefix}layout-wrap--topMix {
   width: 100%;
   height: 100%;
 	.el-header {
@@ -51,15 +51,15 @@
   .topMix-content {
     display: flex;
     height: calc(100% - $header_height);
-    .aside-box {
+    .#{$prefix}layout-aside {
       width: auto;
       background-color: var(--el-menu-bg-color);
-			//border-right: 1px solid var(--el-aside-border-color);
-			z-index: $header_index;
-			//box-shadow: 2px 0 8px #1d23290d;
-			//box-shadow: 2px 0 8px var(--el-aside-border-color);
-			box-shadow: 1px 0 2px var(--el-aside-border-color);
-			////box-shadow: 1px 1px 8px var(--el-color-primary);
+      border-right: 1px solid var(--el-aside-border-color);
+      //z-index: $header_index;
+      //box-shadow: 2px 0 8px #1d23290d;
+      //box-shadow: 2px 0 8px var(--el-aside-border-color);
+      //box-shadow: 1px 0 2px var(--el-aside-border-color);
+      ////box-shadow: 1px 1px 8px var(--el-color-primary);
 			//box-shadow: 1px 1px 16px -10px var(--el-color-primary);
       display: flex;
       flex-direction: column;
@@ -72,7 +72,7 @@
         // 折叠
         /*&--collapse {
           //background-color: transparent !important;
-					.le-pick-icon {
+					.#{$prefix}pick-icon {
 						margin-right: 0;
 					}
         }*/

+ 11 - 4
src/layout/LayoutTopMix/index.vue

@@ -1,7 +1,7 @@
 <!-- 顶部菜单混合模式:topMix -->
 <template>
-	<el-container class="layout-wrap--topMix">
-		<el-header>
+	<el-container class="le-layout-wrap--topMix">
+		<el-header class="le-layout-header">
 			<div class="header-lf mask-image">
 				<div class="logo">
 					<!--          <SvgIcon class="logo-img sidebar-logo" icon-class="logo" />-->
@@ -13,9 +13,16 @@
 			<ToolBarRight class="header-ri" />
 		</el-header>
 		<el-container class="topMix-content">
-			<el-aside class="aside-box" :style="{ width: isCollapse ? '65px' : '210px' }">
+			<el-aside class="le-layout-aside" :style="{ width: isCollapse ? '65px' : '210px' }">
 				<el-scrollbar>
-					<el-menu class="layout-menu-wrap" :router="false" :default-active="activeMenu" :collapse="isCollapse" :unique-opened="accordion" :collapse-transition="false">
+					<el-menu
+						class="layout-menu-wrap"
+						:router="false"
+						:default-active="activeMenu"
+						:collapse="isCollapse"
+						:unique-opened="accordion"
+						:collapse-transition="false"
+					>
 						<SubMenu :menu-list="menuList" />
 					</el-menu>
 				</el-scrollbar>

+ 3 - 0
src/layout/RouteView.vue

@@ -0,0 +1,3 @@
+<template>
+	<router-view></router-view>
+</template>

+ 20 - 6
src/layout/components/AppMain.vue

@@ -1,6 +1,7 @@
 <template>
 	<section class="app-main">
-		<TagsView v-show="needTagsView" />
+		<MaximizeQuit v-show="setting.contentMaximize" />
+		<Tabs v-show="tabsVisible" />
 		<router-view v-slot="{ Component, route }">
 			<transition :name="pageAnimateMode" mode="out-in">
 				<keep-alive :include="cachedViews">
@@ -8,21 +9,33 @@
 				</keep-alive>
 			</transition>
 		</router-view>
-		<div class="layout-footer" v-show="setting.footer">
+		<div v-show="setting.footer" class="le-layout-footer">
 			<Footer />
 		</div>
 	</section>
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, watch } from 'vue'
 import useStore from '@/store'
 import Footer from '@/layout/components/Footer/index.vue'
-import TagsView from '@/layout/components/TagsView/index.vue'
+import Tabs from '@/layout/components/Tabs/index.vue'
+import MaximizeQuit from './MaximizeQuit.vue'
 const { tagsView, setting } = useStore()
-const needTagsView = computed(() => setting.tagsView)
+const tabsVisible = computed(() => setting.tabsVisible)
 const cachedViews = computed(() => tagsView.cachedViews)
-const pageAnimateMode = computed(() => setting.animate ? setting.animateMode : undefined)
+const pageAnimateMode = computed(() => (setting.animate ? setting.animateMode : undefined))
+watch(
+	() => setting.contentMaximize,
+	() => {
+		const app = document.querySelector('#app') as HTMLElement
+		if (setting.contentMaximize) app.classList.add('le-app-maximize')
+		else app.classList.remove('le-app-maximize')
+	},
+	{
+		immediate: true
+	}
+)
 </script>
 
 <style lang="scss" scoped>
@@ -37,6 +50,7 @@ const pageAnimateMode = computed(() => setting.animate ? setting.animateMode : u
 	position: relative;
 	//z-index: 0; // 不能有
 	overflow: hidden;
+	transition: all var(--el-transition-duration);
 }
 
 .fixed-header + .app-main {

+ 3 - 20
src/layout/components/Header/components/Breadcrumb.vue

@@ -5,10 +5,7 @@
 				<!--				<el-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">-->
 				<el-breadcrumb-item v-for="(item, index) in breadcrumbs" :key="item.path">
 					<div class="el-breadcrumb__inner is-link" @click="onBreadcrumbClick(item, index)">
-						<PickerIcon v-if="setting.breadcrumbIcon && item.meta?.icon" :icon-class="item.meta.icon" class="breadcrumb-icon"/>
-						<!--						<el-icon v-show="item.meta?.icon && setting.breadcrumbIcon" class="breadcrumb-icon">
-							<component :is="item.meta.icon"></component>
-						</el-icon>-->
+						<PickerIcon v-if="setting.breadcrumbIcon && item.meta?.icon" :icon-class="item.meta.icon" class="breadcrumb-icon" />
 						<span class="breadcrumb-title">{{ generateTitle(item.meta.title) }}</span>
 					</div>
 				</el-breadcrumb-item>
@@ -21,28 +18,14 @@
 import { computed, onBeforeMount, ref, watch } from 'vue'
 // import { HOME_URL } from '@/config'
 import { RouteLocationMatched, useRoute, useRouter } from 'vue-router'
-import { ArrowRight } from '@element-plus/icons-vue'
-// import { useAuthStore } from '@/stores/modules/auth'
-// import { useGlobalStore } from '@/stores/modules/global'
 import useStore from '@/store'
 // import router from '@/router'
 import { generateTitle } from '@/utils/i18n'
 import PickerIcon from '@/components/IconPicker/PickerIcon.vue'
-
+// import { AppRouteRecordRaw } from '@/router/types'
 const route = useRoute()
 const router = useRouter()
-// const authStore = useAuthStore()
-// const globalStore = useGlobalStore()
 const { setting } = useStore()
-
-/*const breadcrumbList = computed(() => {
-	let breadcrumbData = authStore.breadcrumbListGet[route.matched[route.matched.length - 1].path] ?? []
-	// 🙅‍♀️不需要首页面包屑可删除以下判断
-	if (breadcrumbData[0].path !== HOME_URL) {
-		breadcrumbData = [{ path: HOME_URL, meta: { icon: 'HomeFilled', title: '首页' } }, ...breadcrumbData]
-	}
-	return breadcrumbData
-})*/
 const currentRoute = useRoute()
 
 const breadcrumbs = ref([] as Array<RouteLocationMatched>)
@@ -81,7 +64,7 @@ onBeforeMount(() => {
 })
 
 // Click Breadcrumb
-const onBreadcrumbClick = (item: Menu.MenuOptions, index: number) => {
+const onBreadcrumbClick = (item: RouteMenu.Item, index: number) => {
 	if (item.redirect === 'noredirect' || index !== breadcrumbs.value.length - 1) router.push(item.path)
 	// if (item.redirect === 'noredirect' || index !== breadcrumbList.value.length - 1) router.push(item.path)
 }

+ 38 - 0
src/layout/components/MaximizeQuit.vue

@@ -0,0 +1,38 @@
+<template>
+	<div class="le-maximize-quit" @click="maximizeQuit">
+		<LeIcon class="icon_quit" iconClass="icon-back"></LeIcon>
+	</div>
+</template>
+
+<script setup lang="ts">
+import useStore from '@/store'
+const { setting } = useStore()
+const maximizeQuit = () => {
+	setting.contentMaximize = false
+}
+</script>
+
+<style scoped lang="scss">
+.#{$prefix}maximize-quit {
+	position: fixed;
+	top: -30px;
+	right: -30px;
+	z-index: 999;
+	width: 60px;
+	height: 60px;
+	cursor: pointer;
+	background-color: var(--el-color-info-light-5);
+	border-radius: 50%;
+	opacity: 0.9;
+	&:hover {
+		background-color: var(--el-color-info-light-3);
+	}
+	.icon_quit {
+		position: relative;
+		top: 60%;
+		left: 16%;
+		font-size: 18px;
+		color: #fff;
+	}
+}
+</style>

+ 6 - 11
src/layout/components/Menu/SubMenu.vue

@@ -1,6 +1,6 @@
 <template>
 	<template v-for="subItem in menuList" :key="subItem.path">
-		<el-sub-menu v-if="subItem.children?.length" teleported popper-class="layout-menu-popper-wrap" :index="subItem.path">
+		<el-sub-menu v-if="subItem.children?.length" teleported popperClass="layout-menu-popper-wrap" :index="subItem.path">
 			<template #title>
 				<PickerIcon v-if="subItem.meta.icon" :icon-class="subItem.meta.icon" />
 				<!--				<el-icon v-if="subItem.meta.icon">
@@ -10,7 +10,7 @@
 			</template>
 			<SubMenu :menu-list="subItem.children" />
 		</el-sub-menu>
-		<el-menu-item v-else popper-class="layout-menu-popper-wrap" :index="subItem.path" @click="handleClickMenu(subItem)">
+		<el-menu-item v-else popperClass="layout-menu-popper-wrap" :index="subItem.path" @click="handleClickMenu(subItem)">
 			<PickerIcon v-if="subItem.meta.icon" :icon-class="subItem.meta.icon" />
 			<!--      <el-icon>
 				<component :is="subItem.meta.icon"></component>
@@ -28,10 +28,10 @@ import { generateTitle } from '@/utils/i18n'
 import PickerIcon from '@/components/IconPicker/PickerIcon.vue'
 import { isExternal } from '@/utils/validate'
 
-defineProps<{ menuList: Menu.MenuOptions[] }>()
+defineProps<{ menuList: RouteMenu.Item[] }>()
 
 const router = useRouter()
-const handleClickMenu = (subItem: Menu.MenuOptions) => {
+const handleClickMenu = (subItem: RouteMenu.Item) => {
 	if (isExternal(subItem.path)) return window.open(subItem.path, '_blank')
 	router.push(subItem.path)
 	// router.push({ name: subItem.name })
@@ -40,11 +40,6 @@ const handleClickMenu = (subItem: Menu.MenuOptions) => {
 </script>
 
 <style lang="scss">
-.el-sub-menu .el-sub-menu__title:hover {
-	// 来自 vertical 关闭
-	//color: var(--el-menu-hover-text-color) !important;
-	//background-color: transparent !important;
-}
 .el-menu--collapse {
 	.el-sub-menu__title {
 		display: flex;
@@ -61,9 +56,9 @@ const handleClickMenu = (subItem: Menu.MenuOptions) => {
 	}
 }
 .el-menu-item {
-	&:hover {
+	/*&:hover {
 		color: var(--el-menu-hover-text-color);
-	}
+	}*/
 	&.is-active {
 		color: var(--el-menu-active-color) !important;
 	}

+ 33 - 5
src/layout/components/Settings/index.vue

@@ -103,8 +103,20 @@
 			</div>
 
 			<div class="drawer-item">
-				<span>{{ $t('le.layout.setting.multipleTabsVisible') }}</span>
-				<el-switch v-model="tagsView" />
+				<span>{{ $t('le.layout.setting.tabsVisible') }}</span>
+				<el-switch v-model="tabsVisible" />
+			</div>
+
+			<div v-show="tabsVisible" class="drawer-item">
+				<span>{{ $t('le.layout.setting.tabsIcon') }}</span>
+				<el-switch v-model="tabsIcon" />
+			</div>
+
+			<div v-show="tabsVisible" class="drawer-item">
+				<span>{{ $t('le.layout.setting.tabsMode') }}</span>
+				<el-select v-model="tabsMode" style="width: 130px">
+					<el-option v-for="v of tabsModeList" :key="v.value" :value="v.value" :label="$t(v.label)" />
+				</el-select>
 			</div>
 
 			<div class="drawer-item">
@@ -176,6 +188,20 @@ const animateList = [
 		value: 'zoom-out'
 	}
 ]
+const tabsModeList = [
+	{
+		label: `${prefix}tabsMode_chrome`,
+		value: 'chrome'
+	},
+	{
+		label: `${prefix}tabsMode_card`,
+		value: 'card'
+	},
+	{
+		label: `${prefix}tabsMode_rectangle`,
+		value: 'rectangle'
+	}
+]
 const {
 	isDark,
 	isGrey,
@@ -187,7 +213,9 @@ const {
 	footer,
 	breadcrumb,
 	breadcrumbIcon,
-	tagsView,
+	tabsVisible,
+	tabsIcon,
+	tabsMode,
 	animate,
 	animateMode
 } = storeToRefs(setting)
@@ -203,9 +231,9 @@ const setLayout = (val: LayoutType) => {
 }
 
 // watch(
-// 	() => state.tagsView,
+// 	() => state.tabsVisible,
 // 	value => {
-// 		setting.changeSetting('tagsView', value)
+// 		setting.changeSetting('tabsVisible', value)
 // 	}
 // )
 </script>

+ 699 - 0
src/layout/components/Tabs/index.vue

@@ -0,0 +1,699 @@
+<template>
+	<div class="le-tabs-menu-wrap">
+		<div class="tabs-menu">
+			<el-tabs v-model="tabsMenuValue" :class="tabsModeClass" type="card" @tab-click="tabClick" @tab-remove="tabRemove">
+				<el-tab-pane v-for="item in tabsMenuList" :key="item.path" :label="item.fullPath" :name="item.path" :item="item" :closable="!item.meta.affix">
+					<template #label>
+						<!--@click="tabClick2(item)"-->
+						<div class="le-tabs__item" @contextmenu.prevent="openDropMenu(item, $event)">
+							<PickerIcon v-if="tabsIcon && item.meta?.icon" class="tabs-icon" :icon-class="item.meta.icon" />
+							{{ generateTitle(item.title) }}
+						</div>
+					</template>
+				</el-tab-pane>
+			</el-tabs>
+			<ul v-show="dropVisible" ref="dropdownMenu" :style="`left: ${dropLeft}px; top: ${dropTop}px`" class="local-contextmenu el-dropdown-menu">
+				<li class="el-dropdown-menu__item" @click="contentMaximizeChange">
+					<le-icon icon-class="icon-fullscreen"></le-icon>
+					<span>{{ $t('le.tabs.opts.contentMax') }}</span>
+				</li>
+				<li class="el-dropdown-menu__item" @click="refreshSelectedTag(selectedTag)">
+					<le-icon icon-class="icon-refresh"></le-icon>
+					<span>{{ $t('le.refresh') }}</span>
+				</li>
+				<li role="separator" class="el-dropdown-menu__item--divided"></li>
+				<li class="el-dropdown-menu__item" @click="closeOtherTags">
+					<le-icon icon-class="icon-close_other" style="transform: rotate(90deg)"></le-icon>
+					<span>{{ $t('le.tabs.opts.closeOther') }}</span>
+				</li>
+				<li v-if="!isFirstView" class="el-dropdown-menu__item" @click="closeSideTags('left')">
+					<le-icon icon-class="icon-close_left"></le-icon>
+					<span>{{ $t('le.tabs.opts.closeOther') }}</span>
+				</li>
+				<li v-if="!isLastView" class="el-dropdown-menu__item" @click="closeSideTags('right')">
+					<le-icon icon-class="icon-close_right"></le-icon>
+					<span>{{ $t('le.tabs.opts.closeRight') }}</span>
+				</li>
+				<li class="el-dropdown-menu__item" @click="closeAllTags">
+					<le-icon icon-class="icon-close_all"></le-icon>
+					<span>{{ $t('le.tabs.opts.closeAll') }}</span>
+				</li>
+			</ul>
+
+			<el-dropdown popper-class="le-tabs-fast-dropdown-popper" trigger="hover" @visible-change="fastDropVisibleChange">
+				<div class="fast-drop-wrap">
+					<span class="fast-drop-button"><i class="box box-t"></i><i class="box box-b"></i></span>
+					<!--					<i :class="'iconfont icon-xiala'"></i>-->
+				</div>
+				<template #dropdown>
+					<el-dropdown-menu>
+						<el-dropdown-item @click="contentMaximizeChange">
+							<le-icon icon-class="icon-fullscreen"></le-icon>
+							<span>{{ $t('le.tabs.opts.contentMax') }}</span>
+						</el-dropdown-item>
+						<el-dropdown-item @click="refreshSelectedTag(selectedTag)">
+							<le-icon icon-class="icon-refresh"></le-icon>
+							<span>{{ $t('le.refresh') }}</span>
+						</el-dropdown-item>
+						<el-dropdown-item divided @click="closeOtherTags">
+							<le-icon icon-class="icon-close_other" style="transform: rotate(90deg)"></le-icon>
+							<span>{{ $t('le.tabs.opts.closeOther') }}</span>
+						</el-dropdown-item>
+						<el-dropdown-item v-if="!isFirstView" @click="closeSideTags('left')">
+							<le-icon icon-class="icon-close_left"></le-icon>
+							<span>{{ $t('le.tabs.opts.closeOther') }}</span>
+						</el-dropdown-item>
+						<el-dropdown-item v-if="!isLastView" @click="closeSideTags('right')">
+							<le-icon icon-class="icon-close_right"></le-icon>
+							<span>{{ $t('le.tabs.opts.closeRight') }}</span>
+						</el-dropdown-item>
+						<el-dropdown-item @click="closeAllTags">
+							<le-icon icon-class="icon-close_all"></le-icon>
+							<span>{{ $t('le.tabs.opts.closeAll') }}</span>
+						</el-dropdown-item>
+					</el-dropdown-menu>
+				</template>
+			</el-dropdown>
+		</div>
+	</div>
+</template>
+
+<script setup lang="ts">
+import Sortable from 'sortablejs'
+import { generateTitle } from '@/utils/i18n'
+import { ref, computed, watch, onMounted, getCurrentInstance, ComponentInternalInstance, nextTick } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import PickerIcon from '@/components/IconPicker/PickerIcon.vue'
+import { TabsPaneContext, TabPaneName } from 'element-plus'
+// import { AppRouteRecordRaw } from '@/router/types'
+import useStore from '@/store'
+import { TagView } from '@/types'
+
+const { proxy } = getCurrentInstance() as ComponentInternalInstance // 获取当前组件实例
+const route = useRoute()
+const router = useRouter()
+const { tagsView, setting, permission } = useStore()
+const tabsMenuValue = ref(route.fullPath)
+const tabsMenuList = computed(() => tagsView.visitedViews)
+const tabsIcon = computed(() => setting.tabsIcon)
+const tabsModeClass = computed(() => {
+	return `tabs-${setting.tabsMode}`
+})
+const selectedTag = ref<any>({})
+const dropVisible = ref(false)
+const dropLeft = ref(0)
+const dropTop = ref(0)
+const isAffix = (tag: TagView) => {
+	return tag.meta && tag.meta.affix
+}
+// todo TagView
+const isActive = (tag: TagView) => {
+	return tag.path === route.path
+}
+const isFirstView = computed(() => {
+	try {
+		const cur_path = selectedTag.value.path as string
+		const curIdx = tabsMenuList.value.findIndex(v => v.path === cur_path)
+		// console.error(cur_path, 'cur curIdx', curIdx)
+		return tabsMenuList.value.slice(0, curIdx).every(v => {
+			return v.meta?.affix
+		})
+	} catch (err) {
+		return false
+	}
+})
+
+const isLastView = computed(() => {
+	try {
+		const cur_path = selectedTag.value.path as string
+		return cur_path === tabsMenuList.value[tabsMenuList.value.length - 1].path
+	} catch (err) {
+		return false
+	}
+})
+// delAllViews
+const addTab = () => {
+	if (route.name) {
+		tagsView.addView(route)
+		tabsMenuValue.value = route.path
+	}
+}
+onMounted(() => {
+	tabsDrop()
+	initTabs()
+	addTab()
+})
+
+watch(route, () => {
+	addTab()
+	// console.error(route, 'cur route')
+})
+
+// 多页签初始化
+const initTabs = () => {
+	// 初始化: 将固定的 tabs(多页签)
+	// const affixTabs = []
+	permission.showMenuList.forEach(route => {
+		const meta = route.meta || {}
+		// affix 固定钉子
+		if (meta?.affix && !meta.hidden) {
+			const affixItem = {
+				fullPath: route.path,
+				path: route.path,
+				name: route.name,
+				meta: route.meta
+			}
+			tagsView.addView(affixItem)
+		}
+	})
+}
+
+// 标签 拖拽排序
+const tabsDrop = () => {
+	Sortable.create(document.querySelector('.el-tabs__nav') as HTMLElement, {
+		// draggable: '.el-tabs__item',
+		draggable: '.el-tabs__item.is-closable', // .is-closable 表示可以关闭(非affix)
+		animation: 200,
+		onEnd({ newIndex, oldIndex }) {
+			const tabsList = [...tagsView.visitedViews]
+			const currRow = tabsList.splice(oldIndex as number, 1)[0]
+			tabsList.splice(newIndex as number, 0, currRow)
+			tagsView.setViews(tabsList)
+		},
+		onMove(event: any) {
+			console.log(event.related, 'onMove', event.relatedRect)
+			return event.related.className.indexOf('is-closable') !== -1
+		}
+	})
+}
+window.router = router
+// Tab Click
+const tabClick = (tabItem: TabsPaneContext) => {
+	const fullPath = tabItem.props.label as string
+	router.push(fullPath)
+}
+
+/*const tabClick2 = (tabItem: any) => {
+	console.error(tabItem, 'tabClick2 tabItem')
+	// const fullPath = tabItem.props.name as string
+	const routeParams = {
+		path: tabItem.path,
+		fullPath: tabItem.fullPath,
+		query: tabItem.query
+	}
+	router.push(routeParams)
+}*/
+
+// Remove Tab
+const tabRemove = (path: TabPaneName) => {
+	console.error('tabRemove', path)
+	const curTab = tabsMenuList.value.find(v => v.path === path)
+	tagsView.delView(curTab).then((res: any) => {
+		// if (isActive(curTab)) {
+		if (path === route.path) {
+			toLastView(res.visitedViews, curTab)
+		}
+	})
+}
+
+function toLastView(visitedViews: TagView[], view?: any) {
+	const latestView = visitedViews.slice(-1)[0]
+	if (latestView && latestView.fullPath) {
+		router.push(latestView.fullPath)
+	} else {
+		// now the default is to redirect to the home page if there is no tags-view,
+		// you can adjust it according to your needs.
+		if (view.name === 'dashboard') {
+			// to reload home page
+			router.replace({ path: '/redirect' + view.fullPath })
+		} else {
+			router.push('/')
+		}
+	}
+}
+
+function openDropMenu(tag: TagView, e: MouseEvent) {
+	// console.error(tag, e, 'test openDropMenu')
+	const menuMinWidth = 107
+	const el_rect = proxy?.$el.getBoundingClientRect()
+	const offsetWidth = proxy?.$el.offsetWidth // container width
+	const maxLeft = offsetWidth - menuMinWidth // left boundary
+	const l = e.clientX - el_rect.left + 15 // 15: margin right
+	const t = e.clientY - el_rect.top + 8 // 8: margin top
+	if (l > maxLeft) {
+		dropLeft.value = maxLeft
+	} else {
+		dropLeft.value = l
+	}
+	dropTop.value = t
+	dropVisible.value = true
+	selectedTag.value = tag
+}
+const fastDropVisibleChange = (visible: boolean) => {
+	if (visible) {
+		const activeTag = tabsMenuList.value.find(v => v.fullPath === route.fullPath)
+		if (activeTag) {
+			selectedTag.value = activeTag
+		}
+		dropVisible.value = false
+	}
+}
+
+watch(dropVisible, value => {
+	if (value) {
+		document.body.addEventListener('click', closeDrop)
+	} else {
+		document.body.removeEventListener('click', closeDrop)
+	}
+})
+const closeDrop = () => {
+	dropVisible.value = false
+}
+
+// 刷新
+function refreshSelectedTag(view: TagView) {
+	tagsView.delCachedView(view)
+	const { fullPath } = view
+	nextTick(() => {
+		router.replace({ path: '/redirect' + fullPath }).catch(err => {
+			console.warn(err)
+		})
+	})
+}
+
+// 关闭其他
+function closeOtherTags() {
+	tagsView.delOtherViews(selectedTag.value).then(() => {
+		// moveToCurrentTag()
+		const remain = tagsView.visitedViews
+		const hasTab = remain.some(v => v.path === route.path)
+		if (!hasTab) {
+			toLastView(tagsView.visitedViews, selectedTag.value)
+			// const activePath = remain[remain.length - 1].fullPath
+			// router.push(activePath)
+		}
+	})
+}
+
+function closeSideTags(side: 'left' | 'right' = 'left') {
+	tagsView.closeSideTags(selectedTag.value, side).then((res: any) => {
+		// console.error(res.visitedViews, 'visitedViews')
+		if (!res.visitedViews.find((item: any) => item.path === route.path)) {
+			toLastView(res.visitedViews)
+		}
+	})
+}
+
+function closeAllTags() {
+	tagsView.delAllViews().then((res: any) => {
+		// console.error(res.visitedViews, 'visitedViews')
+		if (!res.visitedViews.find((item: any) => item.path === route.path)) {
+			toLastView(res.visitedViews)
+		}
+	})
+}
+
+const contentMaximizeChange = () => {
+	setting.contentMaximize = !setting.contentMaximize
+}
+</script>
+
+<style lang="scss">
+$transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border 0s, color 0.1s, font-size 0s;
+.#{$prefix}tabs-menu-wrap {
+	background-color: var(--el-bg-color);
+
+	.sortable-ghost {
+		box-shadow: inset 1px 1px 5px 2px rgba(0, 0, 0, 0.15);
+		transition: 0.18s ease;
+	}
+
+	.sortable-chosen {
+		//box-shadow: 1px 1px 5px 2px rgba(0,0,0,.15);
+		box-shadow: inset 0 0 6px -2px rgba(0, 0, 0, 0.15);
+	}
+
+	.tabs-menu {
+		position: relative;
+		width: 100%;
+
+		.el-dropdown {
+			position: absolute;
+			top: 0;
+			right: 0;
+			bottom: 0;
+			z-index: 1;
+
+			/*.fast-drop-button {
+				display: flex;
+				align-items: center;
+				justify-content: center;
+				width: 43px;
+				cursor: pointer;
+				border-left: 1px solid var(--el-border-color-light);
+				transition: all 0.3s;
+
+				&:hover {
+					background-color: var(--el-color-info-light-9);
+				}
+
+				.iconfont {
+					font-size: 12.5px;
+				}
+			}*/
+		}
+
+		.el-tabs {
+			.el-tabs__header {
+				box-sizing: border-box;
+				height: 40px;
+				padding: 0 10px;
+				//padding: 0;
+				margin: 0;
+				border-bottom: 0;
+				//box-shadow: 0 1px 4px -1px var(--el-border-color-light);
+				box-shadow: 0 -1px 0 2px var(--el-border-color-light);
+				z-index: 1;
+				/*.el-tabs__nav-next,
+        .el-tabs__nav-prev {
+          height: 40px;
+        }*/
+				.el-tabs__nav-wrap {
+					position: absolute;
+					width: calc(100% - 50px);
+
+					.el-tabs__nav {
+						display: flex;
+						border: none;
+
+						.el-tabs__item {
+							display: flex;
+							align-items: center;
+							justify-content: center;
+							color: var(--el-text-color-regular);
+							padding: 0 20px;
+							//padding: 0 !important;
+							border: 0;
+
+							.le-tabs__item {
+								height: 100%;
+								display: inline-flex;
+								align-items: center;
+								/* &:nth-child(2):not(.is-active).is-closable:hover {
+                  padding-left: 0;
+                }*/
+								//padding: 0 20px;
+								//margin: 0 -20px;
+							}
+
+							/*
+              .is-icon-close {
+                //right: -2px;
+                right: 18px;
+                //margin-left: -20px;
+              }*/
+							.tabs-icon {
+								margin: 1.5px 4px 0 0;
+								font-size: 15px;
+							}
+
+							.is-icon-close {
+								margin-top: 1px;
+							}
+
+							.is-icon-close {
+								&:hover {
+									background-color: var(--el-color-primary-light-3);
+								}
+							}
+
+							&:hover {
+								color: var(--el-color-primary);
+							}
+
+							&.is-active {
+								color: var(--el-color-primary) !important;
+								background: var(--el-color-primary-light-9);
+
+								.is-icon-close {
+									&:hover {
+										background-color: var(--el-color-primary);
+									}
+								}
+							}
+						}
+					}
+				}
+			}
+
+			// 风格1 谷歌风格
+			&.tabs-chrome {
+				.el-tabs__header {
+					/*.el-tabs__nav-next,
+          .el-tabs__nav-prev {
+            line-height: 52px;
+          }*/
+					.el-tabs__nav-wrap {
+						.el-tabs__nav {
+							.el-tabs__item {
+								margin-left: -12px;
+								/*border-bottom: 0;
+                &::before {
+                  display: none;
+                }*/
+								&:first-child {
+									margin-left: 0;
+								}
+
+								&:hover {
+									z-index: 1;
+									background: var(--el-border-color-light);
+									color: var(--el-color-primary-light-3);
+								}
+
+								margin-top: 6px;
+								//height: 36px;
+								height: calc(var(--el-tabs-header-height) - 6px);
+								mask: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAANoAAAAkCAYAAADvhSSyAAAACXBIWXMAAAsTAAALEwEAmpwYAAABbUlEQVR4nO3d4U3CUBSG4beJA9QNcAJhAtnAOoGOwCaOoBvoCDgBOIHdQDbAH7cJMaHtxcJB6/skJ6Thptw/H+cSktOCn5kCc+AWKJtraaxqYA28A8/N9cktgE9ga1n/tBYcqDhw/QtQHfoh0gitgdkpbrzg/N8klvWbKruz5Xa0CbAi/R6TtDMjdbdOF5k3qzBk0j4VRwzazbC9SKN1nbMo9+j4QTo+SvpuA1z2LcoN2nbYXqRR681R7tFR0gAGTQpg0KQABk0KYNCkAAZNCmDQpAAGTQpg0KQABk0KYNCkAAZNCmDQpAAGTQpg0KQABk0KYNCkAAZNGq4kjTRolRM0x31L3Sb0TMLKCZqTiaVu9/QErW+oyAQHp0p9NqRBqnXbgq6glcATdjQpRw1ctb3ZFrQSeAQejr8fabRegbvcxXPScfHcDxCwrL9YK/Y0qILdgwSnpNHfHhWl4ZbAW/O6LEgplHRC/mEtBfgClkhxraFbr7gAAAAASUVORK5CYII=);
+								//mask-size: 100% 100%;
+								mask-size: 100% calc(100% + 1px);
+								mask-repeat: no-repeat;
+								//mask-position: bottom;
+
+								&.is-active {
+									background: var(--el-color-primary-light-9);
+
+									.is-icon-close {
+										&:hover {
+											background-color: var(--el-color-primary);
+										}
+									}
+								}
+							}
+						}
+					}
+				}
+			}
+
+			// 风格2 卡片风格
+			&.tabs-card {
+				.el-tabs__header {
+					.el-tabs__nav-wrap {
+						.el-tabs__nav {
+							.el-tabs__item {
+								border: 1px solid var(--el-border-color);
+								border-radius: 4px;
+								margin-left: 4px;
+
+								&:first-child {
+									margin-left: 0;
+								}
+
+								&:hover {
+									color: var(--el-color-primary);
+									border-color: var(--el-color-primary-light-7);
+								}
+
+								margin-top: 6px;
+								//height: 36px;
+								height: calc(var(--el-tabs-header-height) - 12px);
+								padding: 0 4px;
+								/*.le-tabs__item {
+                  padding: 0 4px;
+                }*/
+								&.is-active {
+									background-color: var(--el-color-primary-light-9);
+
+									.is-icon-close {
+										&:hover {
+											background-color: var(--el-color-primary);
+										}
+									}
+
+									color: var(--el-color-primary);
+									border-color: var(--el-color-primary-light-5);
+								}
+							}
+						}
+					}
+				}
+			}
+
+			// 风格3 矩形
+			&.tabs-rectangle {
+				.el-tabs__header {
+					.el-tabs__nav-wrap {
+						.el-tabs__nav {
+							.el-tabs__item {
+								//margin-left: 2px;
+								transition: $transition;
+
+								&:first-child {
+									margin-left: 0;
+								}
+
+								&::before {
+									position: absolute;
+									bottom: 0;
+									left: 0;
+									width: 0;
+									height: 0;
+									content: '';
+									//border-bottom: 2px solid var(--el-color-primary-light-5);
+									border-bottom: 2px solid var(--el-color-primary);
+									transition: $transition;
+								}
+
+								&:hover {
+									&::before {
+										width: 100%;
+										transition: $transition;
+									}
+
+									//background: var(--el-color-primary-light-9);
+									background: var(--el-border-color-light);
+									//color: var(--el-color-primary-light-3);
+								}
+
+								&.is-active {
+									background: var(--el-color-primary-light-9);
+
+									&::before {
+										width: 100%;
+										border-bottom-color: var(--el-color-primary);
+									}
+
+									.is-icon-close {
+										&:hover {
+											background-color: var(--el-color-primary);
+										}
+									}
+
+									color: var(--el-color-primary);
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+
+	.local-contextmenu {
+		//--el-dropdown-menu-box-shadow: var(--el-box-shadow-light);
+		//box-shadow: var(--el-dropdown-menu-box-shadow);
+		box-shadow: var(--el-box-shadow-light);
+		position: absolute;
+		//position: fixed;
+		top: 0;
+		left: 0;
+		z-index: 10;
+
+		.el-dropdown-menu__item {
+			//padding: 4px 12px;
+			/*.le-icon {
+				margin-right: 5px;
+			}*/
+
+			&:hover {
+				color: var(--el-color-primary);
+				background-color: var(--el-color-primary-light-9);
+			}
+		}
+		.el-dropdown-menu__item {
+			//padding: 4px 12px;
+			.le-icon {
+				margin-right: 5px;
+			}
+		}
+		.el-dropdown-menu__item--divided {
+			margin: 2px 0;
+		}
+	}
+	.fast-drop-wrap {
+		position: relative;
+		width: 40px;
+		height: 40px;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		&:hover,
+		&[aria-expanded='true'] {
+			.fast-drop-button {
+				transform: rotate(90deg);
+				.box-t:before {
+					transform: rotate(45deg);
+				}
+				.box:before,
+				.box:after {
+					background: var(--el-color-primary);
+				}
+			}
+		}
+	}
+	.fast-drop-button {
+		display: inline-block;
+		color: var(--el-text-color-regular);
+		cursor: pointer;
+		transition: transform 0.3s ease-out;
+		.box {
+			position: relative;
+			display: block;
+			width: 14px;
+			height: 8px;
+			&::before {
+				position: absolute;
+				top: 2px;
+				left: 0;
+				width: 6px;
+				height: 6px;
+				content: '';
+				background: var(--el-text-color-regular);
+			}
+			&::after {
+				position: absolute;
+				top: 2px;
+				left: 8px;
+				width: 6px;
+				height: 6px;
+				content: '';
+				background: var(--el-text-color-regular);
+			}
+		}
+		.box-t::before {
+			transition: transform 0.3s ease-out 0.3s;
+		}
+	}
+}
+.le-tabs-fast-dropdown-popper {
+	.el-dropdown-menu__item {
+		//padding: 4px 12px;
+		.le-icon {
+			margin-right: 5px;
+		}
+	}
+	.el-dropdown-menu__item--divided {
+		margin: 2px 0;
+	}
+}
+</style>

+ 130 - 94
src/router/index.ts

@@ -178,6 +178,136 @@ export const localDemoRoutes = !!import.meta.env.DEV
 						meta: { title: 'demo_adminManage', icon: 'Setting' }
 					}
 				]
+			},
+			{
+				path: '/menuNested',
+				name: 'menuNested',
+				component: 'Layout',
+				redirect: '/menuNested/menu1',
+				meta: {
+					icon: 'List',
+					title: '菜单嵌套'
+				},
+				children: [
+					{
+						path: '/menuNested/menu1',
+						name: 'menu1',
+						component: 'menuNested/menu1/index',
+						meta: {
+							icon: 'Menu',
+							title: '菜单1'
+						}
+					},
+					{
+						path: '/menuNested/menu2',
+						name: 'menu2',
+						redirect: '/menuNested/menu2/menu21',
+						component: 'RouteView',
+						meta: {
+							icon: 'Menu',
+							title: '菜单2'
+							// parentName: 'mainLayout'
+						},
+						children: [
+							{
+								path: '/menuNested/menu2/menu21',
+								// path: 'menu21',
+								name: 'menu21',
+								component: 'menuNested/menu2/menu21/index',
+								meta: {
+									icon: 'Menu',
+									title: '菜单2-1'
+								}
+							},
+							...Array.from({ length: 50 }).map((_, i) => {
+								return {
+									path: '/menuNested/menu2/menu23' + i,
+									name: 'menu23_' + i,
+									component: 'menuNested/menu2/menu23/index',
+									meta: {
+										icon: 'Menu',
+										title: '菜单2-3_' + i
+									}
+								}
+							}),
+							{
+								path: '/menuNested/menu2/menu22',
+								// path: 'menu22',
+								name: 'menu22',
+								component: 'RouteView',
+								redirect: '/menuNested/menu2/menu22/menu221',
+								meta: {
+									icon: 'Menu',
+									title: '菜单2-2'
+								},
+								children: [
+									{
+										path: '/menuNested/menu2/menu22/menu221',
+										// path: 'menu221',
+										name: 'menu221',
+										component: 'menuNested/menu2/menu22/menu221/index',
+										meta: {
+											icon: 'Menu',
+											title: '菜单2-2-1'
+										}
+									},
+									{
+										path: '/menuNested/menu2/menu22/menu222',
+										// path: 'menu222',
+										name: 'menu222',
+										component: 'menuNested/menu2/menu22/menu222/index',
+										meta: {
+											icon: 'Menu',
+											title: '菜单2-2-2'
+										}
+									},
+									...Array.from({ length: 50 }).map((_, i) => {
+										return {
+											path: '/menuNested/menu2/menu22/menu222' + i,
+											// path: 'menu222',
+											name: 'menu222_' + i,
+											component: 'menuNested/menu2/menu22/menu222/index',
+											meta: {
+												icon: 'Menu',
+												title: '菜单2-2-2' + i
+											}
+										}
+									})
+								]
+							},
+							{
+								path: '/menuNested/menu2/menu23',
+								// path: 'menu23',
+								name: 'menu23',
+								component: 'menuNested/menu2/menu23/index',
+								meta: {
+									icon: 'Menu',
+									title: '菜单2-3'
+								}
+							} /*,
+						...Array.from({length: 50}).map((_, i) => {
+							return {
+								path: '/menuNested/menu2/menu23' + i,
+								name: 'menu23' + i,
+								component: 'menuNested/menu2/menu23/index',
+								meta: {
+									icon: 'Menu',
+									title: '菜单2-3' + i
+								}
+							}
+						})*/
+						]
+					},
+					{
+						path: '/menuNested/menu3',
+						name: 'menu3',
+						component: 'menuNested/menu3/index',
+						meta: {
+							icon: 'Menu',
+							title: '菜单3'
+						}
+					}
+				]
 			}
 	  ]
 	: []
@@ -376,100 +506,6 @@ export const local_permissionsMenuList: Array<AppRouteRecordRaw> = [
 				meta: { title: '流程模型', icon: '' }
 			}
 		]
-	},
-	{
-		path: '/menu',
-		name: 'menu_test',
-		component: 'Layout',
-		redirect: '/menu/menu1',
-		meta: {
-			icon: 'List',
-			title: '菜单嵌套'
-		},
-		children: [
-			{
-				path: '/menu/menu1',
-				name: 'menu1',
-				component: 'menu/menu1/index',
-				meta: {
-					icon: 'Menu',
-					title: '菜单1'
-				}
-			},
-			{
-				path: '/menu/menu2',
-				name: 'menu2',
-				redirect: '/menu/menu2/menu21',
-				component: '',
-				meta: {
-					icon: 'Menu',
-					title: '菜单2'
-				},
-				children: [
-					{
-						path: '/menu/menu2/menu21',
-						// path: 'menu21',
-						name: 'menu21',
-						component: 'menu/menu2/menu21/index',
-						meta: {
-							icon: 'Menu',
-							title: '菜单2-1'
-						}
-					},
-					{
-						path: '/menu/menu2/menu22',
-						// path: 'menu22',
-						name: 'menu22',
-						redirect: '/menu/menu2/menu22/menu221',
-						meta: {
-							icon: 'Menu',
-							title: '菜单2-2'
-						},
-						children: [
-							{
-								path: '/menu/menu2/menu22/menu221',
-								// path: 'menu221',
-								name: 'menu221',
-								component: 'menu/menu2/menu22/menu221/index',
-								meta: {
-									icon: 'Menu',
-									title: '菜单2-2-1'
-								}
-							},
-							{
-								path: '/menu/menu2/menu22/menu222',
-								// path: 'menu222',
-								name: 'menu222',
-								component: 'menu/menu2/menu22/menu222/index',
-								meta: {
-									icon: 'Menu',
-									title: '菜单2-2-2'
-								}
-							}
-						]
-					},
-					{
-						path: '/menu/menu2/menu23',
-						// path: 'menu23',
-						name: 'menu23',
-						component: 'menu/menu2/menu23/index',
-						meta: {
-							icon: 'Menu',
-							title: '菜单2-3'
-						}
-					}
-				]
-			},
-			{
-				path: '/menu/menu3',
-				name: 'menu3',
-				component: 'menu/menu3/index',
-				meta: {
-					icon: 'Menu',
-					title: '菜单3'
-				}
-			}
-		]
 	}
 	// todo 请添加相关新路由描述
 ]

+ 8 - 2
src/settings.ts

@@ -33,8 +33,14 @@ export const defaultSettingState: SettingState = {
 	animateMode: 'fade-slide',
 	// 折叠菜单
 	isCollapse: false,
-	// 标签展示
-	tagsView: true,
+	// 多页签展示
+	tabsVisible: true,
+	// 多页签Icon
+	tabsIcon: true,
+	// 多页签风格
+	tabsMode: 'rectangle',
+	// 页面内容全屏
+	contentMaximize: false,
 	/*// todo???
 	fixedHeader: true,
 	// 是否显示Logo

+ 9 - 5
src/store/modules/permission.ts

@@ -48,12 +48,16 @@ export const filterAsyncRoutes = (routes: AppRouteRecordRaw[], roles: string[],
 		} else*/ if (!tmp.component || tmp.component === 'Layout') {
 			tmp.component = Layout
 		} else if (typeof tmp.component === 'string') {
-			const component = modules[`/src/views/${tmp.component}.vue`] as any
-			console.error(component, 'component')
-			if (component) {
-				tmp.component = component
+			if (tmp.component === 'RouteView') {
+				tmp.component = RouteView
 			} else {
-				tmp.component = modules[`/src/views/error-page/404.vue`]
+				const component = modules[`/src/views/${tmp.component}.vue`] as any
+				// console.error(component, 'component')
+				if (component) {
+					tmp.component = component
+				} else {
+					tmp.component = modules[`/src/views/error-page/404.vue`]
+				}
 			}
 		}
 		res.push(tmp)

+ 42 - 1
src/store/modules/tagsView.ts

@@ -9,7 +9,16 @@ const useTagsViewStore = defineStore({
 	}),
 	actions: {
 		addVisitedView(view: any) {
-			if (this.visitedViews.some(v => v.path === view.path)) return
+			const idx = this.visitedViews.findIndex(v => v.path === view.path)
+			if (idx !== -1) {
+				return this.visitedViews.splice(
+					idx,
+					1,
+					Object.assign({}, view, {
+						title: view.meta?.title || 'no-name'
+					})
+				)
+			}
 			this.visitedViews.push(
 				Object.assign({}, view, {
 					title: view.meta?.title || 'no-name'
@@ -71,6 +80,14 @@ const useTagsViewStore = defineStore({
 				}
 			}
 		},
+		setViews(tabsList: any[]) {
+			this.visitedViews = tabsList
+			/*this.cachedViews = tabsList
+				.filter(v => {
+					return !v.meta.noCache
+				})
+				.map(v => v.name)*/
+		},
 		addView(view: any) {
 			this.addVisitedView(view)
 			this.addCachedView(view)
@@ -95,6 +112,30 @@ const useTagsViewStore = defineStore({
 				})
 			})
 		},
+		closeSideTags(view: any, side: 'left' | 'right' = 'left') {
+			return new Promise(resolve => {
+				const currIndex = this.visitedViews.findIndex(v => v.path === view.path)
+				if (currIndex === -1) {
+					return
+				}
+				const [leftI, rightI] = side === 'left' ? [0, currIndex] : [currIndex + 1, this.visitedViews.length]
+				this.visitedViews = this.visitedViews.filter((item, index) => {
+					// affix:true 固定tag,例如“首页”
+					if (index < leftI || index >= rightI || (item.meta && item.meta.affix)) {
+						return true
+					}
+
+					const cacheIndex = this.cachedViews.indexOf(item.name as string)
+					if (cacheIndex > -1) {
+						this.cachedViews.splice(cacheIndex, 1)
+					}
+					return false
+				})
+				resolve({
+					visitedViews: [...this.visitedViews]
+				})
+			})
+		},
 		delLeftViews(view: any) {
 			return new Promise(resolve => {
 				const currIndex = this.visitedViews.findIndex(v => v.path === view.path)

+ 14 - 0
src/styles/lance-element-vue.scss

@@ -123,6 +123,20 @@
 		background: $le-color-warning;
 	}
 }
+// 页面内容全屏样式限制
+.#{$prefix}app-maximize {
+	.#{$prefix}layout-aside-split, // leftMix
+	.#{$prefix}layout-aside,
+	.#{$prefix}layout-header,
+	.#{$prefix}layout-footer,
+	.#{$prefix}tabs-menu-wrap {
+		//display: none !important;
+		width: 0 !important;
+		height: 0 !important;
+		overflow: hidden !important;
+		opacity: 0;
+	}
+}
 
 .#{$prefix}test {}
 /* --------引用 lance-element-ui 根据ui/产品 用于项目的默认样式 End-------- */

+ 12 - 21
src/types/global.d.ts

@@ -1,26 +1,12 @@
-/* Menu */
-declare namespace Menu {
-	interface MenuOptions {
-		path: string
-		name: string
+import { AppRouteRecordRaw, MetaProps } from '@/router/types'
+
+/* RouteMenu */
+declare namespace RouteMenu {
+	interface Item extends AppRouteRecordRaw {
 		component?: string | (() => Promise<unknown>)
-		redirect?: string
-		meta: MetaProps
-		children?: MenuOptions[]
 	}
-	interface MetaProps {
-		// 关于icon 描述:
-		// 1.来自本地src/assets/icons 的svg: 'icon-[dir]-[name]'
-		// 2.le-iconfont svg 链接: 'le-[name]'
-		// 3. 匹配不到icon- & le- 默认element
-		icon: string
-		title: string
-		activeMenu?: string
-		isLink?: string
-		isHide: boolean
-		isFull: boolean
-		isAffix: boolean
-		isKeepAlive: boolean
+	interface Meta extends MetaProps {
+		test?: any
 	}
 }
 
@@ -98,3 +84,8 @@ declare module 'virtual:*' {
 	const result: any
 	export default result
 }
+
+interface Window {
+	// requestIdleCallback?: Function;
+	[key: string]: any
+}

+ 19 - 11
src/types/store.d.ts

@@ -1,5 +1,5 @@
 import { /*RouteRecordRaw,*/ RouteLocationNormalized } from 'vue-router'
-import { AppRouteRecordRaw } from '@/router/types'
+// import { AppRouteRecordRaw } from '@/router/types'
 
 /**
  * 用户状态类型声明
@@ -18,7 +18,7 @@ export interface AppState {
  * 权限类型声明
  */
 export interface PermissionState {
-	menuList: AppRouteRecordRaw[]
+	menuList: RouteMenu.Item[]
 }
 
 /**
@@ -48,7 +48,17 @@ export interface SettingState {
 	// 底部深色
 	footerInverted: boolean
 	// 多页签
-	tagsView: boolean
+	tabsVisible: boolean
+	// 多页签Icon
+	tabsIcon: boolean
+	// 多页签风格
+	tabsMode: string
+	// 页面内容全屏
+	contentMaximize: boolean
+	// // 固定 Header
+	// fixedHeader: boolean
+	// // 侧边栏 Logo
+	// sidebarLogo: boolean
 	// 手风琴
 	accordion: boolean
 	// 面包屑
@@ -70,10 +80,13 @@ export interface SettingState {
 	animateMode: 'fade' | 'fade-slide' | 'fade-bottom' | 'fade-scale' | 'zoom-fade' | 'zoom-out'
 }
 /**
- * 签状态类型声明
+ * 多页签状态类型声明
  */
 export interface TagView extends Partial<RouteLocationNormalized> {
 	title?: string
+	meta?: {
+		[key: string]: any
+	}
 }
 
 export interface TagsViewState {
@@ -86,13 +99,8 @@ export interface TagsViewState {
  */
 export interface UserState {
 	token: string
-	userInfo: {
-		userId: string
-		userName: string
-	}
-	cur_userInfo: Recordable
-	// nickname: string
-	// avatar: string
+	nickname: string
+	avatar: string
 	roles: string[]
 	perms: string[]
 	loginQuery: object

+ 11 - 0
src/views/menuNested/menu1/index.vue

@@ -0,0 +1,11 @@
+<template>
+	<div class="flex-column-page-wrap">
+		<span class="text">我是menu1测试</span>
+		<el-input v-model="value" placeholder="test缓存"></el-input>
+	</div>
+</template>
+
+<script setup lang="ts" name="menu1">
+import { ref } from 'vue'
+const value = ref<string>('')
+</script>

+ 10 - 0
src/views/menuNested/menu2/menu21/index.vue

@@ -0,0 +1,10 @@
+<template>
+	<div class="flex-column-page-wrap">
+		<span class="text">我是menu2-1 </span>
+	</div>
+</template>
+
+<script setup lang="ts" name="menu21">
+import { ref } from 'vue'
+const value = ref<string>('')
+</script>

+ 12 - 0
src/views/menuNested/menu2/menu22/menu221/index.vue

@@ -0,0 +1,12 @@
+<template>
+	<div class="flex-column-page-wrap">
+		<span class="text">我是menu2-2-1</span>
+		<el-alert :closable="false" title="菜单2级">
+			route.name: {{ $route.name }} <br />
+			route.path: {{ $route.path }}
+		</el-alert>
+	</div>
+</template>
+
+<script setup lang="ts" name="menu221">
+</script>

+ 11 - 0
src/views/menuNested/menu2/menu22/menu222/index.vue

@@ -0,0 +1,11 @@
+<template>
+	<div class="flex-column-page-wrap">
+		<span class="text">我是menu2-2-2 </span>
+		<el-alert :closable="false" title="菜单2级">
+			route.name: {{ $route.name }} <br />
+			route.path: {{ $route.path }}
+		</el-alert>
+	</div>
+</template>
+
+<script setup lang="ts" name="menu222"></script>

+ 11 - 0
src/views/menuNested/menu2/menu23/index.vue

@@ -0,0 +1,11 @@
+<template>
+	<div class="flex-column-page-wrap">
+		<span class="text">我是menu2-3 </span>
+		<el-alert :closable="false" title="菜单2级">
+			route.name: {{ $route.name }} <br />
+			route.path: {{ $route.path }}
+		</el-alert>
+	</div>
+</template>
+
+<script setup lang="ts" name="menu23"></script>

+ 7 - 0
src/views/menuNested/menu3/index.vue

@@ -0,0 +1,7 @@
+<template>
+	<div class="flex-column-page-wrap">
+		<span class="text">我是menu3</span>
+	</div>
+</template>
+
+<script setup lang="ts" name="menu3"></script>

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio