Browse Source

优化页签功能&支持全屏按钮操作

RuoYi 2 tháng trước cách đây
mục cha
commit
328924d787

+ 69 - 16
src/layout/components/TagsView/ScrollPane.vue

@@ -17,6 +17,8 @@ const { proxy } = getCurrentInstance()
 
 const scrollWrapper = computed(() => proxy.$refs.scrollContainer.$refs.wrapRef)
 
+const emits = defineEmits(['scroll', 'updateArrows'])
+
 onMounted(() => {
   scrollWrapper.value.addEventListener('scroll', emitScroll, true)
 })
@@ -25,15 +27,46 @@ onBeforeUnmount(() => {
   scrollWrapper.value.removeEventListener('scroll', emitScroll)
 })
 
+const emitScroll = () => {
+  emits('scroll')
+  emits('updateArrows')
+}
+
+function smoothScrollTo(target) {
+  const $scrollWrapper = scrollWrapper.value
+  const start = $scrollWrapper.scrollLeft
+  const distance = target - start
+  const duration = 300
+  let startTime = null
+
+  // easeInOutQuad »º¶¯º¯Êý
+  function ease(t, b, c, d) {
+    t /= d / 2
+    if (t < 1) return c / 2 * t * t + b
+    t--
+    return -c / 2 * (t * (t - 2) - 1) + b
+  }
+
+  function step(timestamp) {
+    if (!startTime) startTime = timestamp
+    const elapsed = timestamp - startTime
+    $scrollWrapper.scrollLeft = ease(elapsed, start, distance, duration)
+    if (elapsed < duration) {
+      requestAnimationFrame(step)
+    } else {
+      $scrollWrapper.scrollLeft = target
+      emits('updateArrows')
+    }
+  }
+
+  requestAnimationFrame(step)
+}
+
 function handleScroll(e) {
   const eventDelta = e.wheelDelta || -e.deltaY * 40
   const $scrollWrapper = scrollWrapper.value
   $scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
-}
-
-const emits = defineEmits()
-const emitScroll = () => {
-  emits('scroll')
+  emits('updateArrows')
 }
 
 const tagsViewStore = useTagsViewStore()
@@ -47,16 +80,15 @@ function moveToTarget(currentTag) {
   let firstTag = null
   let lastTag = null
 
-  // find first tag and last tag
   if (visitedViews.value.length > 0) {
     firstTag = visitedViews.value[0]
     lastTag = visitedViews.value[visitedViews.value.length - 1]
   }
 
   if (firstTag === currentTag) {
-    $scrollWrapper.scrollLeft = 0
+    smoothScrollTo(0)
   } else if (lastTag === currentTag) {
-    $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
+    smoothScrollTo($scrollWrapper.scrollWidth - $containerWidth)
   } else {
     const tagListDom = document.getElementsByClassName('tags-view-item')
     const currentIndex = visitedViews.value.findIndex(item => item === currentTag)
@@ -72,22 +104,38 @@ function moveToTarget(currentTag) {
         }
       }
     }
-
-    // the tag's offsetLeft after of nextTag
     const afterNextTagOffsetLeft = nextTag.offsetLeft + nextTag.offsetWidth + tagAndTagSpacing.value
-
-    // the tag's offsetLeft before of prevTag
     const beforePrevTagOffsetLeft = prevTag.offsetLeft - tagAndTagSpacing.value
     if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
-      $scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
+      smoothScrollTo(afterNextTagOffsetLeft - $containerWidth)
     } else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
-      $scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
+      smoothScrollTo(beforePrevTagOffsetLeft)
     }
   }
 }
 
+function scrollToStart() {
+  smoothScrollTo(0)
+}
+
+function scrollToEnd() {
+  const $scrollWrapper = scrollWrapper.value
+  smoothScrollTo($scrollWrapper.scrollWidth - $scrollWrapper.clientWidth)
+}
+
+function getScrollState() {
+  const $scrollWrapper = scrollWrapper.value
+  return {
+    canLeft: $scrollWrapper.scrollLeft > 0,
+    canRight: $scrollWrapper.scrollLeft < $scrollWrapper.scrollWidth - $scrollWrapper.clientWidth - 1
+  }
+}
+
 defineExpose({
   moveToTarget,
+  scrollToStart,
+  scrollToEnd,
+  getScrollState
 })
 </script>
 
@@ -97,11 +145,16 @@ defineExpose({
   position: relative;
   overflow: hidden;
   width: 100%;
+  height: 100%;
   :deep(.el-scrollbar__bar) {
-    bottom: 0px;
+    display: none;
   }
   :deep(.el-scrollbar__wrap) {
-    height: 39px;
+    height: 34px;
+    display: flex;
+    align-items: center;
+    overflow-x: auto;
+    overflow-y: hidden;
   }
 }
 </style>

+ 197 - 36
src/layout/components/TagsView/index.vue

@@ -1,6 +1,12 @@
 <template>
   <div id="tags-view-container" class="tags-view-container">
-    <scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll">
+    <!-- 左切换箭头 -->
+    <span class="tags-nav-btn tags-nav-btn--left" :class="{ disabled: !canScrollLeft }" @click="scrollLeft">
+      <el-icon><arrow-left /></el-icon>
+    </span>
+
+    <!-- 标签滚动区 -->
+    <scroll-pane ref="scrollPaneRef" class="tags-view-wrapper" @scroll="handleScroll" @update-arrows="updateArrowState">
       <router-link
         v-for="tag in visitedViews"
         :key="tag.path"
@@ -19,6 +25,50 @@
         </span>
       </router-link>
     </scroll-pane>
+
+    <!-- 右切换箭头 -->
+    <span class="tags-nav-btn tags-nav-btn--right" :class="{ disabled: !canScrollRight }" @click="scrollRight">
+      <el-icon><arrow-right /></el-icon>
+    </span>
+
+    <!-- 下拉操作菜单 -->
+    <el-dropdown class="tags-action-dropdown" trigger="click" placement="bottom-end" @command="handleDropdownCommand">
+      <span class="tags-action-btn">
+        <el-icon><arrow-down /></el-icon>
+      </span>
+      <template #dropdown>
+        <el-dropdown-menu class="tags-dropdown-menu">
+          <el-dropdown-item command="refresh">
+            <refresh-right style="width: 1em; height: 1em;" /> 刷新页面
+          </el-dropdown-item>
+          <el-dropdown-item v-if="!isAffix(selectedDropdownTag)" command="close">
+            <close style="width: 1em; height: 1em;" /> 关闭当前
+          </el-dropdown-item>
+          <el-dropdown-item command="closeOthers">
+            <circle-close style="width: 1em; height: 1em;" /> 关闭其他
+          </el-dropdown-item>
+          <el-dropdown-item command="closeLeft" :disabled="isFirstView()">
+            <back style="width: 1em; height: 1em;" /> 关闭左侧
+          </el-dropdown-item>
+          <el-dropdown-item command="closeRight" :disabled="isLastView()">
+            <right style="width: 1em; height: 1em;" /> 关闭右侧
+          </el-dropdown-item>
+          <el-dropdown-item command="closeAll" divided>
+            <circle-close style="width: 1em; height: 1em;" /> 全部关闭
+          </el-dropdown-item>
+        </el-dropdown-menu>
+      </template>
+    </el-dropdown>
+
+    <!-- 全屏按钮 -->
+    <span class="tags-action-btn tags-fullscreen-btn" :title="isFullscreen ? '退出全屏' : '全屏'" @click="toggleFullscreen">
+      <el-icon>
+        <full-screen v-if="!isFullscreen" />
+        <aim v-else />
+      </el-icon>
+    </span>
+
+    <!-- 右键上下文菜单 -->
     <ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
       <li @click="refreshSelectedTag(selectedTag)">
         <refresh-right style="width: 1em; height: 1em;" /> 刷新页面
@@ -55,6 +105,9 @@ const left = ref(0)
 const selectedTag = ref({})
 const affixTags = ref([])
 const scrollPaneRef = ref(null)
+const canScrollLeft = ref(false)
+const canScrollRight = ref(false)
+const isFullscreen = ref(false)
 
 const { proxy } = getCurrentInstance()
 const route = useRoute()
@@ -65,6 +118,9 @@ const routes = computed(() => usePermissionStore().routes)
 const theme = computed(() => useSettingsStore().theme)
 const tagsIcon = computed(() => useSettingsStore().tagsIcon)
 
+// 下拉菜单针对当前激活的 tag
+const selectedDropdownTag = computed(() => visitedViews.value.find(v => isActive(v)) || {})
+
 watch(route, () => {
   addTags()
   moveToCurrentTag()
@@ -78,9 +134,20 @@ watch(visible, (value) => {
   }
 })
 
+watch(visitedViews, () => {
+  nextTick(() => updateArrowState())
+})
+
 onMounted(() => {
   initTags()
   addTags()
+  window.addEventListener('resize', updateArrowState)
+  document.addEventListener('fullscreenchange', onFullscreenChange)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', updateArrowState)
+  document.removeEventListener('fullscreenchange', onFullscreenChange)
 })
 
 function isActive(r) {
@@ -90,18 +157,19 @@ function isActive(r) {
 function activeStyle(tag) {
   if (!isActive(tag)) return {}
   return {
-    "background-color": theme.value,
-    "border-color": theme.value
+    'background-color': theme.value,
+    'border-color': theme.value
   }
 }
 
 function isAffix(tag) {
-  return tag.meta && tag.meta.affix
+  return tag && tag.meta && tag.meta.affix
 }
 
 function isFirstView() {
   try {
-    return selectedTag.value.fullPath === '/index' || selectedTag.value.fullPath === visitedViews.value[1].fullPath
+    const tag = selectedTag.value && selectedTag.value.fullPath ? selectedTag.value : selectedDropdownTag.value
+    return tag.fullPath === '/index' || tag.fullPath === visitedViews.value[1].fullPath
   } catch (err) {
     return false
   }
@@ -109,7 +177,8 @@ function isFirstView() {
 
 function isLastView() {
   try {
-    return selectedTag.value.fullPath === visitedViews.value[visitedViews.value.length - 1].fullPath
+    const tag = selectedTag.value && selectedTag.value.fullPath ? selectedTag.value : selectedDropdownTag.value
+    return tag.fullPath === visitedViews.value[visitedViews.value.length - 1].fullPath
   } catch (err) {
     return false
   }
@@ -141,9 +210,8 @@ function initTags() {
   const res = filterAffixTags(routes.value)
   affixTags.value = res
   for (const tag of res) {
-    // Must have tag name
     if (tag.name) {
-       useTagsViewStore().addVisitedView(tag)
+      useTagsViewStore().addVisitedView(tag)
     }
   }
 }
@@ -160,7 +228,6 @@ function moveToCurrentTag() {
     for (const r of visitedViews.value) {
       if (r.path === route.path) {
         scrollPaneRef.value.moveToTarget(r)
-        // when query is different then update
         if (r.fullPath !== route.fullPath) {
           useTagsViewStore().updateVisitedView(route)
         }
@@ -169,6 +236,51 @@ function moveToCurrentTag() {
   })
 }
 
+function scrollLeft() {
+  if (!canScrollLeft.value) return
+  scrollPaneRef.value.scrollToStart()
+}
+
+function scrollRight() {
+  if (!canScrollRight.value) return
+  scrollPaneRef.value.scrollToEnd()
+}
+
+function updateArrowState() {
+  nextTick(() => {
+    if (scrollPaneRef.value) {
+      const state = scrollPaneRef.value.getScrollState()
+      canScrollLeft.value = state.canLeft
+      canScrollRight.value = state.canRight
+    }
+  })
+}
+
+function toggleFullscreen() {
+  if (!document.fullscreenElement) {
+    document.documentElement.requestFullscreen()
+  } else {
+    document.exitFullscreen()
+  }
+}
+
+function onFullscreenChange() {
+  isFullscreen.value = !!document.fullscreenElement
+}
+
+function handleDropdownCommand(command) {
+  const tag = selectedDropdownTag.value
+  selectedTag.value = tag
+  switch (command) {
+    case 'refresh':     refreshSelectedTag(tag); break
+    case 'close':       closeSelectedTag(tag); break
+    case 'closeOthers': closeOthersTags(); break
+    case 'closeLeft':   closeLeftTags(); break
+    case 'closeRight':  closeRightTags(); break
+    case 'closeAll':    closeAllTags(tag); break
+  }
+}
+
 function refreshSelectedTag(view) {
   proxy.$tab.refreshPage(view)
   if (route.meta.link) {
@@ -221,10 +333,7 @@ function toLastView(visitedViews, view) {
   if (latestView) {
     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
+    if (view && view.name === 'Dashboard') {
       router.replace({ path: '/redirect' + view.fullPath })
     } else {
       router.push('/')
@@ -233,18 +342,7 @@ function toLastView(visitedViews, view) {
 }
 
 function openMenu(tag, e) {
-  const menuMinWidth = 105
-  const offsetLeft = proxy.$el.getBoundingClientRect().left // container margin left
-  const offsetWidth = proxy.$el.offsetWidth // container width
-  const maxLeft = offsetWidth - menuMinWidth // left boundary
-  const l = e.clientX - offsetLeft + 15 // 15: margin right
-
-  if (l > maxLeft) {
-    left.value = maxLeft
-  } else {
-    left.value = l
-  }
-
+  left.value = e.clientX
   top.value = e.clientY
   visible.value = true
   selectedTag.value = tag
@@ -256,6 +354,7 @@ function closeMenu() {
 
 function handleScroll() {
   closeMenu()
+  updateArrowState()
 }
 </script>
 
@@ -265,9 +364,49 @@ function handleScroll() {
   width: 100%;
   background: var(--tags-bg, #fff);
   border-bottom: 1px solid var(--tags-item-border, #d8dce5);
-  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
+  display: flex;
+  align-items: center;
+  overflow: hidden;
+
+  $btn-width: 28px;
+  $btn-color: #71717a;
+  $btn-hover-bg: #f0f2f5;
+  $btn-hover-color: #303133;
+  $btn-disabled-color: #c0c4cc;
+  $divider: 1px solid var(--tags-item-border, #d8dce5);
+
+  .tags-nav-btn {
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: $btn-width;
+    height: 34px;
+    cursor: pointer;
+    color: $btn-color;
+    font-size: 13px;
+    user-select: none;
+    transition: background 0.15s, color 0.15s;
+
+    &:hover:not(.disabled) {
+      background: $btn-hover-bg;
+      color: $btn-hover-color;
+    }
+
+    &.disabled {
+      color: $btn-disabled-color;
+      cursor: not-allowed;
+    }
+
+    &--left  { border-right: $divider; }
+    &--right { border-left: $divider; }
+  }
 
   .tags-view-wrapper {
+    flex: 1;
+    min-width: 0;
+    height: 100%;
+
     .tags-view-item {
       display: inline-block;
       position: relative;
@@ -280,15 +419,9 @@ function handleScroll() {
       padding: 0 8px;
       font-size: 12px;
       margin-left: 5px;
-      margin-top: 4px;
-
-      &:first-of-type {
-        margin-left: 15px;
-      }
 
-      &:last-of-type {
-        margin-right: 15px;
-      }
+      &:first-of-type { margin-left: 6px; }
+      &:last-of-type  { margin-right: 15px; }
 
       &.active {
         background-color: #42b983;
@@ -313,11 +446,40 @@ function handleScroll() {
     content: none !important;
   }
 
+  .tags-action-dropdown {
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+  }
+
+  .tags-action-btn {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: $btn-width;
+    height: 34px;
+    cursor: pointer;
+    color: $btn-color;
+    font-size: 13px;
+    border-left: $divider;
+    user-select: none;
+    transition: background 0.15s, color 0.15s;
+
+    &:hover {
+      background: $btn-hover-bg;
+      color: $btn-hover-color;
+    }
+  }
+
+  .tags-fullscreen-btn {
+    border-left: $divider;
+  }
+
   .contextmenu {
     margin: 0;
     background: var(--el-bg-color-overlay, #fff);
     z-index: 3000;
-    position: absolute;
+    position: fixed;
     list-style-type: none;
     padding: 5px 0;
     border-radius: 4px;
@@ -341,7 +503,6 @@ function handleScroll() {
 </style>
 
 <style lang="scss">
-//reset element css of el-icon-close
 .tags-view-wrapper {
   .tags-view-item {
     .el-icon-close {