Эх сурвалжийг харах

feat(TagsView): Add TagsView component

feat(ContextMenu): Add ContextMenu component

feat(store): Add tagsView store
kailong321200875 3 жил өмнө
parent
commit
349ac9d398

+ 1 - 0
package.json

@@ -84,6 +84,7 @@
     "vite-plugin-purge-icons": "^0.7.0",
     "vite-plugin-style-import": "^1.4.1",
     "vite-plugin-svg-icons": "^1.1.0",
+    "vite-plugin-vue-setup-extend": "^0.3.0",
     "vite-plugin-windicss": "^1.6.2",
     "vue-tsc": "^0.30.2",
     "windicss": "^3.4.2",

+ 26 - 10
pnpm-lock.yaml

@@ -33,7 +33,6 @@ specifiers:
   lodash-es: ^4.17.21
   mockjs: ^1.1.0
   nprogress: ^0.2.0
-  path-to-regexp: ^6.2.0
   pinia: ^2.0.9
   postcss: ^8.4.5
   postcss-html: ^1.3.0
@@ -54,6 +53,7 @@ specifiers:
   vite-plugin-purge-icons: ^0.7.0
   vite-plugin-style-import: ^1.4.1
   vite-plugin-svg-icons: ^1.1.0
+  vite-plugin-vue-setup-extend: ^0.3.0
   vite-plugin-windicss: ^1.6.2
   vue: 3.2.26
   vue-i18n: 9.1.9
@@ -74,7 +74,6 @@ dependencies:
   lodash-es: registry.nlark.com/lodash-es/4.17.21
   mockjs: registry.npmmirror.com/mockjs/1.1.0
   nprogress: registry.npmmirror.com/nprogress/0.2.0
-  path-to-regexp: registry.npmmirror.com/path-to-regexp/6.2.0
   pinia: registry.npmmirror.com/pinia/2.0.9_typescript@4.5.4+vue@3.2.26
   qs: registry.npmmirror.com/qs/6.10.3
   vue: registry.npmmirror.com/vue/3.2.26
@@ -125,6 +124,7 @@ devDependencies:
   vite-plugin-purge-icons: registry.nlark.com/vite-plugin-purge-icons/0.7.0_vite@2.7.10
   vite-plugin-style-import: registry.npmmirror.com/vite-plugin-style-import/1.4.1_vite@2.7.10
   vite-plugin-svg-icons: registry.npmmirror.com/vite-plugin-svg-icons/1.1.0_vite@2.7.10
+  vite-plugin-vue-setup-extend: registry.npmmirror.com/vite-plugin-vue-setup-extend/0.3.0_vite@2.7.10
   vite-plugin-windicss: registry.npmmirror.com/vite-plugin-windicss/1.6.2_vite@2.7.10
   vue-tsc: registry.npmmirror.com/vue-tsc/0.30.2_typescript@4.5.4
   windicss: registry.npmmirror.com/windicss/3.4.2
@@ -1732,7 +1732,7 @@ packages:
       {
         integrity: sha1-0t5eA0JOcH3BDHQGjd7a5wh0Gyc=,
         registry: https://registry.npm.taobao.org/,
-        tarball: https://registry.nlark.com/eslint-utils/download/eslint-utils-2.1.0.tgz?cache=0&sync_timestamp=1631600361784&other_urls=https%3A%2F%2Fregistry.nlark.com%2Feslint-utils%2Fdownload%2Feslint-utils-2.1.0.tgz
+        tarball: https://registry.nlark.com/eslint-utils/download/eslint-utils-2.1.0.tgz
       }
     name: eslint-utils
     version: 2.1.0
@@ -4948,7 +4948,7 @@ packages:
       {
         integrity: sha1-qVT5Ma66UI0we78Gnv8MAclhFvc=,
         registry: https://registry.npm.taobao.org/,
-        tarball: https://registry.nlark.com/semver/download/semver-5.7.1.tgz
+        tarball: https://registry.nlark.com/semver/download/semver-5.7.1.tgz?cache=0&sync_timestamp=1631500167672&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fsemver%2Fdownload%2Fsemver-5.7.1.tgz
       }
     name: semver
     version: 5.7.1
@@ -4960,7 +4960,7 @@ packages:
       {
         integrity: sha1-7gpkyK9ejO6mdoexM3YeG+y9HT0=,
         registry: https://registry.npm.taobao.org/,
-        tarball: https://registry.nlark.com/semver/download/semver-6.3.0.tgz
+        tarball: https://registry.nlark.com/semver/download/semver-6.3.0.tgz?cache=0&sync_timestamp=1631500167672&other_urls=https%3A%2F%2Fregistry.nlark.com%2Fsemver%2Fdownload%2Fsemver-6.3.0.tgz
       }
     name: semver
     version: 6.3.0
@@ -7909,7 +7909,6 @@ packages:
       magic-string: registry.nlark.com/magic-string/0.25.7
       postcss: registry.npmmirror.com/postcss/8.4.5
       source-map: registry.nlark.com/source-map/0.6.1
-    dev: false
 
   registry.npmmirror.com/@vue/compiler-ssr/3.2.26:
     resolution:
@@ -7923,7 +7922,6 @@ packages:
     dependencies:
       '@vue/compiler-dom': registry.npmmirror.com/@vue/compiler-dom/3.2.26
       '@vue/shared': registry.npmmirror.com/@vue/shared/3.2.26
-    dev: false
 
   registry.npmmirror.com/@vue/devtools-api/6.0.0-beta.21.1:
     resolution:
@@ -7951,7 +7949,6 @@ packages:
       '@vue/shared': registry.npmmirror.com/@vue/shared/3.2.26
       estree-walker: registry.npmmirror.com/estree-walker/2.0.2
       magic-string: registry.nlark.com/magic-string/0.25.7
-    dev: false
 
   registry.npmmirror.com/@vue/reactivity/3.2.26:
     resolution:
@@ -9678,7 +9675,7 @@ packages:
       {
         integrity: sha1-MOvR73wv3/AcOk8VEESvJfqwUj4=,
         registry: https://registry.npm.taobao.org/,
-        tarball: https://registry.npmmirror.com/eslint-visitor-keys/download/eslint-visitor-keys-1.3.0.tgz?cache=0&sync_timestamp=1636378650851&other_urls=https%3A%2F%2Fregistry.npmmirror.com%2Feslint-visitor-keys%2Fdownload%2Feslint-visitor-keys-1.3.0.tgz
+        tarball: https://registry.npmmirror.com/eslint-visitor-keys/download/eslint-visitor-keys-1.3.0.tgz
       }
     name: eslint-visitor-keys
     version: 1.3.0
@@ -9690,7 +9687,7 @@ packages:
       {
         integrity: sha1-9lMoJZMFknOSyTjtROsKXJsr0wM=,
         registry: https://registry.npm.taobao.org/,
-        tarball: https://registry.npmmirror.com/eslint-visitor-keys/download/eslint-visitor-keys-2.1.0.tgz?cache=0&sync_timestamp=1636378650851&other_urls=https%3A%2F%2Fregistry.npmmirror.com%2Feslint-visitor-keys%2Fdownload%2Feslint-visitor-keys-2.1.0.tgz
+        tarball: https://registry.npmmirror.com/eslint-visitor-keys/download/eslint-visitor-keys-2.1.0.tgz
       }
     name: eslint-visitor-keys
     version: 2.1.0
@@ -11132,6 +11129,7 @@ packages:
       }
     name: path-to-regexp
     version: 6.2.0
+    dev: true
 
   registry.npmmirror.com/picocolors/1.0.0:
     resolution:
@@ -12350,6 +12348,24 @@ packages:
       - supports-color
     dev: true
 
+  registry.npmmirror.com/vite-plugin-vue-setup-extend/0.3.0_vite@2.7.10:
+    resolution:
+      {
+        integrity: sha512-9Nd7Bj4TftB2CoOAD2ZI4cHLW5zjKMF3LNihWbrnAPx3nuGBn33tM9SVUGBVjBB6uv1mGAPavwKCTU0xAD8qhw==,
+        registry: https://registry.npm.taobao.org/,
+        tarball: https://registry.npmmirror.com/vite-plugin-vue-setup-extend/download/vite-plugin-vue-setup-extend-0.3.0.tgz
+      }
+    id: registry.npmmirror.com/vite-plugin-vue-setup-extend/0.3.0
+    name: vite-plugin-vue-setup-extend
+    version: 0.3.0
+    peerDependencies:
+      vite: '>=2.0.0'
+    dependencies:
+      '@vue/compiler-sfc': registry.npmmirror.com/@vue/compiler-sfc/3.2.26
+      magic-string: registry.nlark.com/magic-string/0.25.7
+      vite: registry.npmmirror.com/vite/2.7.10_less@4.1.2
+    dev: true
+
   registry.npmmirror.com/vite-plugin-windicss/1.6.2_vite@2.7.10:
     resolution:
       {

+ 3 - 0
src/components/ContextMenu/index.ts

@@ -0,0 +1,3 @@
+import ContextMenu from './src/ContextMenu.vue'
+
+export { ContextMenu }

+ 52 - 0
src/components/ContextMenu/src/ContextMenu.vue

@@ -0,0 +1,52 @@
+<script setup lang="ts">
+import { ElDropdown, ElDropdownMenu, ElDropdownItem } from 'element-plus'
+import { PropType } from 'vue'
+import { useI18n } from '@/hooks/web/useI18n'
+
+const { t } = useI18n()
+
+defineProps({
+  schema: {
+    type: Array as PropType<contextMenuSchema[]>,
+    default: () => []
+  },
+  trigger: {
+    type: String as PropType<'click' | 'hover' | 'focus' | 'contextmenu'>,
+    default: 'contextmenu'
+  }
+})
+
+const command = (item: contextMenuSchema) => {
+  item.command && item.command(item)
+}
+</script>
+
+<template>
+  <ElDropdown
+    :trigger="trigger"
+    placement="bottom-start"
+    @command="command"
+    popper-class="v-context-menu-popper"
+  >
+    <slot></slot>
+    <template #dropdown>
+      <ElDropdownMenu>
+        <ElDropdownItem
+          v-for="(item, index) in schema"
+          :key="`dropdown${index}`"
+          :divided="item.divided"
+          :disabled="item.disabled"
+          :command="item"
+        >
+          <Icon :icon="item.icon" /> {{ t(item.label) }}
+        </ElDropdownItem>
+      </ElDropdownMenu>
+    </template>
+  </ElDropdown>
+</template>
+
+<style lang="less">
+.v-context-menu-popper {
+  min-width: 150px;
+}
+</style>

+ 6 - 6
src/components/Menu/src/Menu.vue

@@ -1,5 +1,5 @@
 <script lang="tsx">
-import { computed, defineComponent } from 'vue'
+import { computed, defineComponent, unref } from 'vue'
 import { ElMenu, ElScrollbar } from 'element-plus'
 import { useAppStore } from '@/store/modules/app'
 import { usePermissionStore } from '@/store/modules/permission'
@@ -33,7 +33,7 @@ export default defineComponent({
     const collapse = computed(() => appStore.getCollapse)
 
     const activeMenu = computed(() => {
-      const { meta, path } = currentRoute.value
+      const { meta, path } = unref(currentRoute)
       // if set path, the sidebar will highlight the path you set
       if (meta.activeMenu) {
         return meta.activeMenu as string
@@ -62,9 +62,9 @@ export default defineComponent({
       >
         <ElScrollbar>
           <ElMenu
-            defaultActive={activeMenu.value}
-            mode={menuMode.value}
-            collapse={collapse.value}
+            defaultActive={unref(activeMenu)}
+            mode={unref(menuMode)}
+            collapse={unref(collapse)}
             backgroundColor="var(--left-menu-bg-color)"
             textColor="var(--left-menu-text-color)"
             activeTextColor="var(--left-menu-text-active-color)"
@@ -72,7 +72,7 @@ export default defineComponent({
           >
             {{
               default: () => {
-                const { renderMenuItem } = useRenderMenuItem(routers.value, menuMode.value)
+                const { renderMenuItem } = useRenderMenuItem(unref(routers), unref(menuMode))
                 return renderMenuItem()
               }
             }}

+ 353 - 2
src/components/TagsView/src/TagsView.vue

@@ -1,5 +1,356 @@
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { onMounted, watch, computed, unref, ref, nextTick } from 'vue'
+import { useRouter } from 'vue-router'
+import type { RouteLocationNormalizedLoaded } from 'vue-router'
+import { usePermissionStore } from '@/store/modules/permission'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { useI18n } from '@/hooks/web/useI18n'
+import { filterAffixTags } from './helper'
+import { ContextMenu } from '@/components/ContextMenu'
+
+const { t } = useI18n()
+
+const { currentRoute, push, replace } = useRouter()
+
+const permissionStore = usePermissionStore()
+
+const routers = computed(() => permissionStore.getRouters)
+
+const tagsViewStore = useTagsViewStore()
+
+const visitedViews = computed(() => tagsViewStore.getVisitedViews)
+
+const affixTagArr = ref<RouteLocationNormalizedLoaded[]>([])
+
+// 初始化tag
+const initTags = () => {
+  affixTagArr.value = filterAffixTags(unref(routers))
+  for (const tag of unref(affixTagArr)) {
+    // Must have tag name
+    if (tag.name) {
+      tagsViewStore.addVisitedView(tag)
+    }
+  }
+}
+
+const selectedTag = ref<RouteLocationNormalizedLoaded>()
+
+// 新增tag
+const addTags = () => {
+  const { name } = unref(currentRoute)
+  if (name) {
+    selectedTag.value = unref(currentRoute)
+    tagsViewStore.addView(unref(currentRoute))
+  }
+  return false
+}
+
+// 关闭选中的tag
+const closeSelectedTag = (view: RouteLocationNormalizedLoaded) => {
+  if (view?.meta?.affix) return
+  tagsViewStore.delView(view)
+  if (isActive(view)) {
+    toLastView()
+  }
+}
+
+// 关闭全部
+const closeAllTags = () => {
+  tagsViewStore.delAllViews()
+  toLastView()
+}
+
+// 关闭其他
+const closeOthersTags = () => {
+  tagsViewStore.delOthersViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+}
+
+// 重新加载
+const refreshSelectedTag = async (view?: RouteLocationNormalizedLoaded) => {
+  if (!view) return
+  tagsViewStore.delCachedView()
+  const { fullPath } = view
+  await nextTick()
+  replace({
+    path: '/redirect' + fullPath
+  })
+}
+
+// 关闭左侧
+const closeLeftTags = () => {
+  tagsViewStore.delLeftViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+}
+
+// 关闭右侧
+const closeRightTags = () => {
+  tagsViewStore.delRightViews(unref(selectedTag) as RouteLocationNormalizedLoaded)
+}
+
+const toLastView = () => {
+  const visitedViews = tagsViewStore.getVisitedViews
+  const latestView = visitedViews.slice(-1)[0]
+  if (latestView) {
+    push(latestView)
+  } else {
+    if (
+      unref(currentRoute).path === permissionStore.getAddRouters[0].path ||
+      unref(currentRoute).path === permissionStore.getAddRouters[0].redirect
+    ) {
+      addTags()
+      return
+    }
+    // You can set another route
+    push(permissionStore.getAddRouters[0].path)
+  }
+}
+
+const isActive = (route: RouteLocationNormalizedLoaded): boolean => {
+  return route.path === unref(currentRoute).path
+}
+
+onMounted(() => {
+  initTags()
+  addTags()
+})
+
+watch(
+  () => currentRoute.value,
+  () => {
+    addTags()
+    // moveToCurrentTag()
+  }
+)
+</script>
 
 <template>
-  <div class="h-[var(--tags-view-height)]">tagsView</div>
+  <div class="v-tags-view h-[var(--tags-view-height)] flex w-full">
+    <span class="v-tags-view__tool w-[40px] h-[40px] text-center leading-[40px] cursor-pointer">
+      <Icon icon="ant-design:left-outlined" color="#333" />
+    </span>
+    <div class="overflow-hidden flex-1">
+      <ElScrollbar>
+        <div class="flex h-[var(--tags-view-height)]">
+          <ContextMenu
+            :schema="[
+              {
+                icon: 'ant-design:sync-outlined',
+                label: t('common.reload'),
+                disabled: selectedTag?.fullPath !== item.fullPath,
+                command: () => {
+                  refreshSelectedTag(item)
+                }
+              },
+              {
+                icon: 'ant-design:close-outlined',
+                label: t('common.closeTab'),
+                command: () => {
+                  closeSelectedTag(item)
+                }
+              },
+              {
+                divided: true,
+                icon: 'ant-design:vertical-right-outlined',
+                label: t('common.closeTheLeftTab'),
+                disabled:
+                  !!visitedViews?.length &&
+                  (item.fullPath === visitedViews[0].fullPath ||
+                    selectedTag?.fullPath !== item.fullPath),
+                command: () => {
+                  closeLeftTags()
+                }
+              },
+              {
+                icon: 'ant-design:vertical-left-outlined',
+                label: t('common.closeTheRightTab'),
+                disabled:
+                  !!visitedViews?.length &&
+                  (item.fullPath === visitedViews[visitedViews.length - 1].fullPath ||
+                    selectedTag?.fullPath !== item.fullPath),
+                command: () => {
+                  closeRightTags()
+                }
+              },
+              {
+                divided: true,
+                icon: 'ant-design:tag-outlined',
+                label: t('common.closeOther'),
+                disabled: selectedTag?.fullPath !== item.fullPath,
+                command: () => {
+                  closeOthersTags()
+                }
+              },
+              {
+                icon: 'ant-design:line-outlined',
+                label: t('common.closeAll'),
+                command: () => {
+                  closeAllTags()
+                }
+              }
+            ]"
+            v-for="item in visitedViews"
+            :key="item.fullPath"
+            :class="[
+              'v-tags-view__item',
+              {
+                'v-tags-view__item--affix': item?.meta?.affix,
+                'is-active': isActive(item)
+              }
+            ]"
+          >
+            <router-link :to="{ ...item }" custom #default="{ navigate }">
+              <div @click="navigate" class="h-full">
+                {{ t(item?.meta?.title as string) }}
+                <Icon
+                  class="v-tags-view__item--close"
+                  color="#333"
+                  icon="ant-design:close-outlined"
+                  :size="12"
+                  @click.prevent.stop="closeSelectedTag(item)"
+                />
+              </div>
+            </router-link>
+          </ContextMenu>
+        </div>
+      </ElScrollbar>
+    </div>
+    <span class="v-tags-view__tool w-[40px] h-[40px] text-center leading-[40px] cursor-pointer">
+      <Icon icon="ant-design:right-outlined" color="#333" />
+    </span>
+    <span
+      class="v-tags-view__tool w-[40px] h-[40px] text-center leading-[40px] cursor-pointer"
+      @click="refreshSelectedTag(selectedTag)"
+    >
+      <Icon icon="ant-design:reload-outlined" color="#333" />
+    </span>
+    <ContextMenu
+      trigger="click"
+      :schema="[
+        {
+          icon: 'ant-design:sync-outlined',
+          label: t('common.reload'),
+          command: () => {
+            refreshSelectedTag(selectedTag)
+          }
+        },
+        {
+          icon: 'ant-design:close-outlined',
+          label: t('common.closeTab')
+        },
+        {
+          divided: true,
+          icon: 'ant-design:vertical-right-outlined',
+          label: t('common.closeTheLeftTab'),
+          disabled: !!visitedViews?.length && selectedTag?.fullPath === visitedViews[0].fullPath,
+          command: () => {
+            closeLeftTags()
+          }
+        },
+        {
+          icon: 'ant-design:vertical-left-outlined',
+          label: t('common.closeTheRightTab'),
+          disabled:
+            !!visitedViews?.length &&
+            selectedTag?.fullPath === visitedViews[visitedViews.length - 1].fullPath,
+          command: () => {
+            closeRightTags()
+          }
+        },
+        {
+          divided: true,
+          icon: 'ant-design:tag-outlined',
+          label: t('common.closeOther'),
+          command: () => {
+            closeOthersTags()
+          }
+        },
+        {
+          icon: 'ant-design:line-outlined',
+          label: t('common.closeAll'),
+          command: () => {
+            closeAllTags()
+          }
+        }
+      ]"
+    >
+      <span
+        class="v-tags-view__tool w-[40px] h-[40px] text-center leading-[40px] cursor-pointer block"
+      >
+        <Icon icon="ant-design:down-outlined" color="#333" />
+      </span>
+    </ContextMenu>
+  </div>
 </template>
+
+<style lang="less" scoped>
+@prefix-cls: ~'@{namespace}-tags-view';
+
+.@{prefix-cls} {
+  &__tool {
+    position: relative;
+
+    &:hover {
+      :deep(span) {
+        color: var(--el-color-black) !important;
+      }
+    }
+
+    &:after {
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 100%;
+      height: 100%;
+      border: 1px solid var(--top-tool-border-color);
+      content: '';
+    }
+  }
+
+  &__item + &__item {
+    margin-left: 4px;
+  }
+
+  &__item {
+    position: relative;
+    top: 1px;
+    height: calc(~'100% - 4px');
+    padding: 0 15px;
+    font-size: 12px;
+    line-height: calc(~'var( - -tags-view-height) - 4px');
+    cursor: pointer;
+    border: 1px solid #d9d9d9;
+
+    &--close {
+      position: absolute;
+      top: 50%;
+      right: 5px;
+      display: none;
+      transform: translate(0, -50%);
+    }
+    &:not(.@{prefix-cls}__item--affix):hover {
+      .@{prefix-cls}__item--close {
+        display: block;
+      }
+    }
+  }
+
+  &__item:not(.@{prefix-cls}__item--affix) {
+    padding-right: 25px;
+  }
+
+  &__item:not(.is-active) {
+    &:hover {
+      color: var(--el-color-primary);
+    }
+  }
+
+  &__item.is-active {
+    color: var(--el-color-white);
+    background-color: var(--el-color-primary);
+    .@{prefix-cls}__item--close {
+      :deep(span) {
+        color: var(--el-color-white) !important;
+      }
+    }
+  }
+}
+</style>

+ 21 - 0
src/components/TagsView/src/helper.ts

@@ -0,0 +1,21 @@
+import type { RouteMeta, RouteLocationNormalizedLoaded } from 'vue-router'
+import { pathResolve } from '@/utils/routerHelper'
+
+export const filterAffixTags = (routes: AppRouteRecordRaw[], parentPath = '') => {
+  let tags: RouteLocationNormalizedLoaded[] = []
+  routes.forEach((route) => {
+    const meta = route.meta as RouteMeta
+    const tagPath = pathResolve(parentPath, route.path)
+    if (meta?.affix) {
+      tags.push({ ...route, path: tagPath, fullPath: tagPath } as RouteLocationNormalizedLoaded)
+    }
+    if (route.children) {
+      const tempTags: RouteLocationNormalizedLoaded[] = filterAffixTags(route.children, tagPath)
+      if (tempTags.length >= 1) {
+        tags = [...tags, ...tempTags]
+      }
+    }
+  })
+
+  return tags
+}

+ 5 - 19
src/layout/Layout.vue

@@ -1,6 +1,5 @@
 <script lang="tsx">
-import { computed, defineComponent, KeepAlive } from 'vue'
-import { useTagsViewStore } from '@/store/modules/tagsView'
+import { computed, defineComponent } from 'vue'
 import { useAppStore } from '@/store/modules/app'
 import { Menu } from '@/components/Menu'
 import { Collapse } from '@/components/Collapse'
@@ -10,12 +9,7 @@ import { UserInfo } from '@/components/UserInfo'
 import { Screenfull } from '@/components/Screenfull'
 import { Breadcrumb } from '@/components/Breadcrumb'
 import { TagsView } from '@/components/TagsView'
-
-const tagsViewStore = useTagsViewStore()
-
-const getCaches = computed((): string[] => {
-  return tagsViewStore.getCachedViews
-})
+import AppView from './components/AppView.vue'
 
 const appStore = useAppStore()
 
@@ -71,18 +65,10 @@ export default defineComponent({
               <UserInfo class="header__tigger"></UserInfo>
             </div>
           </div>
-          <div class="v-app-right__tags relative">
+          <div class="v-app-right__tags-view relative">
             <TagsView></TagsView>
           </div>
-          <router-view>
-            {{
-              default: ({ Component, route }) => (
-                <KeepAlive include={getCaches.value}>
-                  <Component is={Component} key={route.fullPath}></Component>
-                </KeepAlive>
-              )
-            }}
-          </router-view>
+          <AppView></AppView>
         </div>
       </section>
     )
@@ -111,7 +97,7 @@ export default defineComponent({
     transition: left var(--transition-time-02);
 
     &__tool,
-    &__tags {
+    &__tags-view {
       &::after {
         position: absolute;
         bottom: 0;

+ 20 - 0
src/layout/components/AppView.vue

@@ -0,0 +1,20 @@
+<script setup lang="ts">
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { computed } from 'vue'
+
+const tagsViewStore = useTagsViewStore()
+
+const getCaches = computed((): string[] => {
+  return tagsViewStore.getCachedViews
+})
+</script>
+
+<template>
+  <router-view>
+    <template #default="{ Component, route }">
+      <keep-alive :include="getCaches">
+        <component :is="Component" :key="route.fullPath" />
+      </keep-alive>
+    </template>
+  </router-view>
+</template>

+ 7 - 1
src/locales/en.ts

@@ -11,7 +11,13 @@ export default {
     reminder: 'Reminder',
     loginOutMessage: 'Exit the system?',
     ok: 'OK',
-    cancel: 'Cancel'
+    cancel: 'Cancel',
+    reload: 'Reload current',
+    closeTab: 'Close current',
+    closeTheLeftTab: 'Close left',
+    closeTheRightTab: 'Close right',
+    closeOther: 'Close other',
+    closeAll: 'Close all'
   },
   size: {
     default: 'Default',

+ 7 - 1
src/locales/zh-CN.ts

@@ -11,7 +11,13 @@ export default {
     reminder: '温馨提示',
     loginOutMessage: '是否退出本系统?',
     ok: '确定',
-    cancel: '取消'
+    cancel: '取消',
+    reload: '重新加载',
+    closeTab: '关闭标签页',
+    closeTheLeftTab: '关闭左侧标签页',
+    closeTheRightTab: '关闭右侧标签页',
+    closeOther: '关闭其他标签页',
+    closeAll: '关闭全部标签页'
   },
   size: {
     default: '默认',

+ 4 - 2
src/router/index.ts

@@ -20,7 +20,8 @@ export const constantRouterMap: AppRouteRecordRaw[] = [
       }
     ],
     meta: {
-      hidden: true
+      hidden: true,
+      noTagsView: true
     }
   },
   {
@@ -107,7 +108,8 @@ export const asyncRouterMap: AppRouteRecordRaw[] = [
         name: 'Icons',
         meta: {
           title: '图标',
-          icon: 'carbon:skill-level-advanced'
+          icon: 'carbon:skill-level-advanced',
+          affix: true
         }
       }
     ]

+ 64 - 106
src/store/modules/tagsView.ts

@@ -1,8 +1,9 @@
-// import router from '@/router'
+import router from '@/router'
 import type { RouteLocationNormalizedLoaded } from 'vue-router'
 import { getRawRoute } from '@/utils/routerHelper'
 import { defineStore } from 'pinia'
 import { store } from '../index'
+import { findIndex } from '@/utils'
 
 export interface TagsViewState {
   visitedViews: RouteLocationNormalizedLoaded[]
@@ -24,18 +25,24 @@ export const useTagsViewStore = defineStore({
     }
   },
   actions: {
-    ADD_VISITED_VIEW(view: RouteLocationNormalizedLoaded): void {
-      if (this.visitedViews.some((v: RouteLocationNormalizedLoaded) => v.path === view.path)) return
+    // 新增缓存和tag
+    addView(view: RouteLocationNormalizedLoaded): void {
+      this.addVisitedView(view)
+      this.addCachedView()
+    },
+    // 新增tag
+    addVisitedView(view: RouteLocationNormalizedLoaded) {
+      if (this.visitedViews.some((v) => v.path === view.path)) return
       if (view.meta?.noTagsView) return
       this.visitedViews.push(
         Object.assign({}, view, {
-          title: view.meta.title || 'no-name'
+          title: view.meta?.title || 'no-name'
         })
       )
     },
-    SET_CACHED_VIEW(): void {
+    // 新增缓存
+    addCachedView() {
       const cacheMap: Set<string> = new Set()
-
       for (const v of this.visitedViews) {
         const item = getRawRoute(v)
         const needCache = !item.meta?.noCache
@@ -45,9 +52,17 @@ export const useTagsViewStore = defineStore({
         const name = item.name as string
         cacheMap.add(name)
       }
+      if (Array.from(this.cachedViews).sort().toString() === Array.from(cacheMap).sort().toString())
+        return
       this.cachedViews = cacheMap
     },
-    DEL_VISITED_VIEW(view: RouteLocationNormalizedLoaded): void {
+    // 删除某个
+    delView(view: RouteLocationNormalizedLoaded) {
+      this.delVisitedView(view)
+      this.addCachedView()
+    },
+    // 删除tag
+    delVisitedView(view: RouteLocationNormalizedLoaded) {
       for (const [i, v] of this.visitedViews.entries()) {
         if (v.path === view.path) {
           this.visitedViews.splice(i, 1)
@@ -55,117 +70,60 @@ export const useTagsViewStore = defineStore({
         }
       }
     },
-    DEL_CACHED_VIEW(): void {
-      // const route = router.currentRoute.value
-      // for (const [key, value] of this.cachedViews) {
-      //   const index = value.findIndex((item: string) => item === (route.name as string))
-      //   if (index === -1) {
-      //     continue
-      //   }
-      //   if (value.length === 1) {
-      //     this.cachedViews.delete(key)
-      //     continue
-      //   }
-      //   value.splice(index, 1)
-      //   this.cachedViews.set(key, value)
-      // }
+    // 删除缓存
+    delCachedView() {
+      const route = router.currentRoute.value
+      const index = findIndex<string>(this.getCachedViews, (v) => v === route.name)
+      if (index > -1) {
+        this.cachedViews.delete(this.getCachedViews[index])
+      }
     },
-    DEL_OTHERS_VISITED_VIEWS(view: RouteLocationNormalizedLoaded): void {
-      this.visitedViews = this.visitedViews.filter((v) => {
-        return v.meta.affix || v.path === view.path
-      })
+    // 删除所有缓存和tag
+    delAllViews() {
+      this.delAllVisitedViews()
+      this.addCachedView()
     },
-    DEL_ALL_VISITED_VIEWS(): void {
-      // keep affix tags
+    // 删除所有tag
+    delAllVisitedViews() {
       const affixTags = this.visitedViews.filter((tag) => tag.meta.affix)
       this.visitedViews = affixTags
     },
-    UPDATE_VISITED_VIEW(view: RouteLocationNormalizedLoaded): void {
-      for (let v of this.visitedViews) {
-        if (v.path === view.path) {
-          v = Object.assign(v, view)
-          break
-        }
-      }
-    },
-    addView(view: RouteLocationNormalizedLoaded): void {
-      this.addVisitedView(view)
+    // 删除其他
+    delOthersViews(view: RouteLocationNormalizedLoaded) {
+      this.delOthersVisitedViews(view)
       this.addCachedView()
     },
-    addVisitedView(view: RouteLocationNormalizedLoaded): void {
-      this.ADD_VISITED_VIEW(view)
-    },
-    addCachedView(): void {
-      this.SET_CACHED_VIEW()
-    },
-    delView(view: RouteLocationNormalizedLoaded): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.delVisitedView(view)
-        this.SET_CACHED_VIEW()
-        resolve({
-          visitedViews: [...this.visitedViews],
-          cachedViews: [...this.cachedViews]
-        })
-      })
-    },
-    delVisitedView(view: RouteLocationNormalizedLoaded): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.DEL_VISITED_VIEW(view)
-        resolve([...this.visitedViews])
-      })
-    },
-    delCachedView(): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.DEL_CACHED_VIEW()
-        resolve([...this.cachedViews])
+    // 删除其他tag
+    delOthersVisitedViews(view: RouteLocationNormalizedLoaded) {
+      this.visitedViews = this.visitedViews.filter((v) => {
+        return v?.meta?.affix || v.path === view.path
       })
     },
-    delOthersViews(view: RouteLocationNormalizedLoaded): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.delOthersVisitedViews(view)
-        this.SET_CACHED_VIEW()
-        resolve({
-          visitedViews: [...this.visitedViews],
-          cachedViews: [...this.cachedViews]
+    // 删除左侧
+    delLeftViews(view: RouteLocationNormalizedLoaded) {
+      const index = findIndex<RouteLocationNormalizedLoaded>(
+        this.visitedViews,
+        (v) => v.path === view.path
+      )
+      if (index > -1) {
+        this.visitedViews = this.visitedViews.filter((v, i) => {
+          return v?.meta?.affix || v.path === view.path || i > index
         })
-      })
-    },
-    delOthersVisitedViews(view: RouteLocationNormalizedLoaded): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.DEL_OTHERS_VISITED_VIEWS(view)
-        resolve([...this.visitedViews])
-      })
-    },
-    delOthersCachedViews(): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.SET_CACHED_VIEW()
-        resolve([...this.cachedViews])
-      })
+        this.addCachedView()
+      }
     },
-    delAllViews(): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.delAllVisitedViews()
-        this.SET_CACHED_VIEW()
-        resolve({
-          visitedViews: [...this.visitedViews],
-          cachedViews: [...this.cachedViews]
+    // 删除右侧
+    delRightViews(view: RouteLocationNormalizedLoaded) {
+      const index = findIndex<RouteLocationNormalizedLoaded>(
+        this.visitedViews,
+        (v) => v.path === view.path
+      )
+      if (index > -1) {
+        this.visitedViews = this.visitedViews.filter((v, i) => {
+          return v?.meta?.affix || v.path === view.path || i < index
         })
-      })
-    },
-    delAllVisitedViews(): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.DEL_ALL_VISITED_VIEWS()
-        resolve([...this.visitedViews])
-      })
-    },
-    delAllCachedViews(): Promise<unknown> {
-      return new Promise((resolve) => {
-        this.SET_CACHED_VIEW()
-        resolve([...this.cachedViews])
-      })
-    },
-    updateVisitedView(view: RouteLocationNormalizedLoaded): void {
-      this.UPDATE_VISITED_VIEW(view)
+        this.addCachedView()
+      }
     }
   }
 })

+ 289 - 0
src/utils/domUtils.ts

@@ -0,0 +1,289 @@
+import { isServer } from './is'
+const ieVersion = isServer ? 0 : Number((document as any).documentMode)
+const SPECIAL_CHARS_REGEXP = /([\:\-\_]+(.))/g
+const MOZ_HACK_REGEXP = /^moz([A-Z])/
+
+export interface ViewportOffsetResult {
+  left: number
+  top: number
+  right: number
+  bottom: number
+  rightIncludeBody: number
+  bottomIncludeBody: number
+}
+
+/* istanbul ignore next */
+const trim = function (string: string) {
+  return (string || '').replace(/^[\s\uFEFF]+|[\s\uFEFF]+$/g, '')
+}
+
+/* istanbul ignore next */
+const camelCase = function (name: string) {
+  return name
+    .replace(SPECIAL_CHARS_REGEXP, function (_, __, letter, offset) {
+      return offset ? letter.toUpperCase() : letter
+    })
+    .replace(MOZ_HACK_REGEXP, 'Moz$1')
+}
+
+/* istanbul ignore next */
+export function hasClass(el: Element, cls: string) {
+  if (!el || !cls) return false
+  if (cls.indexOf(' ') !== -1) {
+    throw new Error('className should not contain space.')
+  }
+  if (el.classList) {
+    return el.classList.contains(cls)
+  } else {
+    return (' ' + el.className + ' ').indexOf(' ' + cls + ' ') > -1
+  }
+}
+
+/* istanbul ignore next */
+export function addClass(el: Element, cls: string) {
+  if (!el) return
+  let curClass = el.className
+  const classes = (cls || '').split(' ')
+
+  for (let i = 0, j = classes.length; i < j; i++) {
+    const clsName = classes[i]
+    if (!clsName) continue
+
+    if (el.classList) {
+      el.classList.add(clsName)
+    } else if (!hasClass(el, clsName)) {
+      curClass += ' ' + clsName
+    }
+  }
+  if (!el.classList) {
+    el.className = curClass
+  }
+}
+
+/* istanbul ignore next */
+export function removeClass(el: Element, cls: string) {
+  if (!el || !cls) return
+  const classes = cls.split(' ')
+  let curClass = ' ' + el.className + ' '
+
+  for (let i = 0, j = classes.length; i < j; i++) {
+    const clsName = classes[i]
+    if (!clsName) continue
+
+    if (el.classList) {
+      el.classList.remove(clsName)
+    } else if (hasClass(el, clsName)) {
+      curClass = curClass.replace(' ' + clsName + ' ', ' ')
+    }
+  }
+  if (!el.classList) {
+    el.className = trim(curClass)
+  }
+}
+
+export function getBoundingClientRect(element: Element): DOMRect | number {
+  if (!element || !element.getBoundingClientRect) {
+    return 0
+  }
+  return element.getBoundingClientRect()
+}
+
+/**
+ * 获取当前元素的left、top偏移
+ *   left:元素最左侧距离文档左侧的距离
+ *   top:元素最顶端距离文档顶端的距离
+ *   right:元素最右侧距离文档右侧的距离
+ *   bottom:元素最底端距离文档底端的距离
+ *   rightIncludeBody:元素最左侧距离文档右侧的距离
+ *   bottomIncludeBody:元素最底端距离文档最底部的距离
+ *
+ * @description:
+ */
+export function getViewportOffset(element: Element): ViewportOffsetResult {
+  const doc = document.documentElement
+
+  const docScrollLeft = doc.scrollLeft
+  const docScrollTop = doc.scrollTop
+  const docClientLeft = doc.clientLeft
+  const docClientTop = doc.clientTop
+
+  const pageXOffset = window.pageXOffset
+  const pageYOffset = window.pageYOffset
+
+  const box = getBoundingClientRect(element)
+
+  const { left: retLeft, top: rectTop, width: rectWidth, height: rectHeight } = box as DOMRect
+
+  const scrollLeft = (pageXOffset || docScrollLeft) - (docClientLeft || 0)
+  const scrollTop = (pageYOffset || docScrollTop) - (docClientTop || 0)
+  const offsetLeft = retLeft + pageXOffset
+  const offsetTop = rectTop + pageYOffset
+
+  const left = offsetLeft - scrollLeft
+  const top = offsetTop - scrollTop
+
+  const clientWidth = window.document.documentElement.clientWidth
+  const clientHeight = window.document.documentElement.clientHeight
+  return {
+    left: left,
+    top: top,
+    right: clientWidth - rectWidth - left,
+    bottom: clientHeight - rectHeight - top,
+    rightIncludeBody: clientWidth - left,
+    bottomIncludeBody: clientHeight - top
+  }
+}
+
+/* istanbul ignore next */
+export const on = function (
+  element: HTMLElement | Document | Window,
+  event: string,
+  handler: EventListenerOrEventListenerObject
+): void {
+  if (element && event && handler) {
+    element.addEventListener(event, handler, false)
+  }
+}
+
+/* istanbul ignore next */
+export const off = function (
+  element: HTMLElement | Document | Window,
+  event: string,
+  handler: any
+): void {
+  if (element && event && handler) {
+    element.removeEventListener(event, handler, false)
+  }
+}
+
+/* istanbul ignore next */
+export const once = function (el: HTMLElement, event: string, fn: EventListener): void {
+  const listener = function (this: any, ...args: unknown[]) {
+    if (fn) {
+      // @ts-ignore
+      fn.apply(this, args)
+    }
+    off(el, event, listener)
+  }
+  on(el, event, listener)
+}
+
+/* istanbul ignore next */
+export const getStyle =
+  ieVersion < 9
+    ? function (element: Element | any, styleName: string) {
+        if (isServer) return
+        if (!element || !styleName) return null
+        styleName = camelCase(styleName)
+        if (styleName === 'float') {
+          styleName = 'styleFloat'
+        }
+        try {
+          switch (styleName) {
+            case 'opacity':
+              try {
+                return element.filters.item('alpha').opacity / 100
+              } catch (e) {
+                return 1.0
+              }
+            default:
+              return element.style[styleName] || element.currentStyle
+                ? element.currentStyle[styleName]
+                : null
+          }
+        } catch (e) {
+          return element.style[styleName]
+        }
+      }
+    : function (element: Element | any, styleName: string) {
+        if (isServer) return
+        if (!element || !styleName) return null
+        styleName = camelCase(styleName)
+        if (styleName === 'float') {
+          styleName = 'cssFloat'
+        }
+        try {
+          const computed = (document as any).defaultView.getComputedStyle(element, '')
+          return element.style[styleName] || computed ? computed[styleName] : null
+        } catch (e) {
+          return element.style[styleName]
+        }
+      }
+
+/* istanbul ignore next */
+export function setStyle(element: Element | any, styleName: any, value: any) {
+  if (!element || !styleName) return
+
+  if (typeof styleName === 'object') {
+    for (const prop in styleName) {
+      if (Object.prototype.hasOwnProperty.call(styleName, prop)) {
+        setStyle(element, prop, styleName[prop])
+      }
+    }
+  } else {
+    styleName = camelCase(styleName)
+    if (styleName === 'opacity' && ieVersion < 9) {
+      element.style.filter = isNaN(value) ? '' : 'alpha(opacity=' + value * 100 + ')'
+    } else {
+      element.style[styleName] = value
+    }
+  }
+}
+
+/* istanbul ignore next */
+export const isScroll = (el: Element, vertical: any) => {
+  if (isServer) return
+
+  const determinedDirection = vertical !== null || vertical !== undefined
+  const overflow = determinedDirection
+    ? vertical
+      ? getStyle(el, 'overflow-y')
+      : getStyle(el, 'overflow-x')
+    : getStyle(el, 'overflow')
+
+  return overflow.match(/(scroll|auto)/)
+}
+
+/* istanbul ignore next */
+export const getScrollContainer = (el: Element, vertical?: any) => {
+  if (isServer) return
+
+  let parent: any = el
+  while (parent) {
+    if ([window, document, document.documentElement].includes(parent)) {
+      return window
+    }
+    if (isScroll(parent, vertical)) {
+      return parent
+    }
+    parent = parent.parentNode
+  }
+
+  return parent
+}
+
+/* istanbul ignore next */
+export const isInContainer = (el: Element, container: any) => {
+  if (isServer || !el || !container) return false
+
+  const elRect = el.getBoundingClientRect()
+  let containerRect
+
+  if ([window, document, document.documentElement, null, undefined].includes(container)) {
+    containerRect = {
+      top: 0,
+      right: window.innerWidth,
+      bottom: window.innerHeight,
+      left: 0
+    }
+  } else {
+    containerRect = container.getBoundingClientRect()
+  }
+
+  return (
+    elRect.top < containerRect.bottom &&
+    elRect.bottom > containerRect.top &&
+    elRect.right > containerRect.left &&
+    elRect.left < containerRect.right
+  )
+}

+ 21 - 0
src/utils/index.ts

@@ -38,3 +38,24 @@ export const underlineToHump = (str: string): string => {
 export const setCssVar = (prop: string, val: any, dom = document.documentElement) => {
   dom.style.setProperty(prop, val)
 }
+
+/**
+ * 查找数组对象的某个下标
+ * @param {Array} ary 查找的数组
+ * @param {Functon} fn 判断的方法
+ */
+// eslint-disable-next-line
+export const findIndex = <T = Recordable>(ary: Array<T>, fn: Fn): number => {
+  if (ary.findIndex) {
+    return ary.findIndex(fn)
+  }
+  let index = -1
+  ary.some((item: T, i: number, ary: Array<T>) => {
+    const ret: T = fn(item, i, ary)
+    if (ret) {
+      index = i
+      return ret
+    }
+  })
+  return index
+}

+ 7 - 2
src/views/Level/Menu111.vue

@@ -1,5 +1,10 @@
-<script setup lang="ts"></script>
+<script setup lang="ts" name="Menu111">
+import { onMounted } from 'vue'
+onMounted(() => {
+  console.log('????')
+})
+</script>
 
 <template>
-  <div>Menu111 <input type="text" /></div>
+  <div class="h-[100000px]">Menu111 <input type="text" /></div>
 </template>

+ 2 - 11
src/views/Login/Login.vue

@@ -2,7 +2,6 @@
 import { LoginForm } from './components'
 import { ThemeSwitch } from '@/components/ThemeSwitch'
 import { LocaleDropdown } from '@/components/LocaleDropdown'
-import { useDesign } from '@/hooks/web/useDesign'
 import { useI18n } from '@/hooks/web/useI18n'
 import { underlineToHump } from '@/utils'
 import { useAppStore } from '@/store/modules/app'
@@ -10,22 +9,14 @@ import { useAppStore } from '@/store/modules/app'
 const appStore = useAppStore()
 
 const { t } = useI18n()
-
-const { getPrefixCls } = useDesign()
-
-const prefixCls = getPrefixCls('login')
 </script>
 
 <template>
   <div
-    :class="prefixCls"
-    class="h-[100%] relative overflow-hidden <xl:bg-v-dark <sm:px-10px <xl:px-10px <md:px-10px"
+    class="v-login h-[100%] relative overflow-hidden <xl:bg-v-dark <sm:px-10px <xl:px-10px <md:px-10px"
   >
     <div class="relative h-full flex mx-auto">
-      <div
-        :class="`${prefixCls}__left`"
-        class="flex-1 bg-gray-500 bg-opacity-20 relative p-30px <xl:hidden"
-      >
+      <div class="v-login__left flex-1 bg-gray-500 bg-opacity-20 relative p-30px <xl:hidden">
         <div class="flex items-center relative text-white">
           <img src="@/assets/imgs/logo.png" alt="" class="w-48px h-48px mr-10px" />
           <span class="text-20px font-bold">{{ underlineToHump(appStore.getTitle) }}</span>

+ 1 - 2
src/views/Login/components/LoginForm.vue

@@ -129,11 +129,10 @@ const signIn = async () => {
       await permissionStore.generateRoutes().catch(() => {})
 
       permissionStore.getAddRouters.forEach((route) => {
-        console.log(route)
         addRoute(route as RouteRecordRaw) // 动态添加可访问路由表
       })
       permissionStore.setIsAddRouters(true)
-      push({ path: redirect.value || '/level' })
+      push({ path: redirect.value || permissionStore.addRouters[0].path })
     }
   }
 }

+ 7 - 0
types/componentType/contextMenu.d.ts

@@ -0,0 +1,7 @@
+declare type contextMenuSchema = {
+  disabled?: boolean
+  divided?: boolean
+  icon?: string
+  label: string
+  command?: (item: contextMenuSchema) => viod
+}

+ 3 - 1
vite.config.ts

@@ -10,6 +10,7 @@ import StyleImport, { ElementPlusResolve } from 'vite-plugin-style-import'
 import ViteSvgIcons from 'vite-plugin-svg-icons'
 import PurgeIcons from 'vite-plugin-purge-icons'
 import { viteMockServe } from 'vite-plugin-mock'
+import VueSetupExtend from 'vite-plugin-vue-setup-extend'
 
 // https://vitejs.dev/config/
 const root = process.cwd()
@@ -68,7 +69,8 @@ export default ({ command, mode }: ConfigEnv): UserConfig => {
 
           setupProdMockServer()
           `
-      })
+      }),
+      VueSetupExtend()
     ],
 
     css: {