Tabs.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. <script setup lang="ts">
  2. import { isEmpty } from 'lodash-es'
  3. import { NButton, NDropdown, NEllipsis, NScrollbar } from 'naive-ui'
  4. import { storeToRefs } from 'pinia'
  5. import {
  6. computed,
  7. h,
  8. inject,
  9. nextTick,
  10. onMounted,
  11. reactive,
  12. ref,
  13. useTemplateRef,
  14. watch,
  15. watchEffect,
  16. } from 'vue'
  17. import { VueDraggable } from 'vue-draggable-plus'
  18. import { ButtonAnimation } from '@/components'
  19. import { tabsInjectionKey } from '@/injection'
  20. import router from '@/router'
  21. import { useConfigureStore } from '@/stores/configure'
  22. import { useTabsStore } from '@/stores/tabs'
  23. import type { Tab } from '@/stores/tabs'
  24. import type { DropdownOption } from 'naive-ui'
  25. type ContextMenuActions = {
  26. close: () => void
  27. closeAll: () => void
  28. closeLeft: () => void
  29. closeOther: () => void
  30. closeRight: () => void
  31. keepAlive: () => void
  32. lock: () => void
  33. }
  34. const tabsInject = inject(tabsInjectionKey, null)
  35. const scrollbarRef = useTemplateRef<InstanceType<typeof NScrollbar>>('scrollbarRef')
  36. const tabsStore = useTabsStore()
  37. const configureStore = useConfigureStore()
  38. const { tabs, tabActiveKey, keepAliveTabs } = storeToRefs(tabsStore)
  39. const {
  40. isLocked,
  41. isPinned,
  42. hasKeepAliveTab,
  43. getUnlockedKeysBefore,
  44. getUnlockedKeysAfter,
  45. getUnlockedKeysExcept,
  46. getUnlockedKeys,
  47. setKeepAliveTab,
  48. setLocked,
  49. update,
  50. remove,
  51. removeBefore,
  52. removeAfter,
  53. removeExcept,
  54. removeAll,
  55. } = tabsStore
  56. const tabPinnedList = ref<Tab[]>([])
  57. const tabList = ref<Tab[]>([])
  58. const isMounted = ref(false)
  59. const tabBackgroundTransitionClasses = reactive({
  60. leaveToClass: '',
  61. enterFromClass: 'scale-0 opacity-0',
  62. })
  63. const isTabDragging = ref(false)
  64. const showTabTooltip = ref(true)
  65. const showTabDropdown = ref(false)
  66. const tabContextMenu = ref<Tab | null>(null)
  67. const tabDropdownOptions = computed<DropdownOption[]>(() => {
  68. const currentTab = tabContextMenu.value
  69. if (!currentTab) {
  70. return []
  71. }
  72. const { key, componentName } = currentTab
  73. return [
  74. {
  75. disabled: isLocked(key) || isPinned(key),
  76. icon: () =>
  77. h('span', {
  78. class: 'iconify ph--x',
  79. }),
  80. key: 'close',
  81. label: '关闭',
  82. },
  83. {
  84. disabled: isEmpty(getUnlockedKeysExcept(key)),
  85. icon: () =>
  86. h('span', {
  87. class: 'iconify ph--arrows-out-line-horizontal',
  88. }),
  89. key: 'closeOther',
  90. label: '关闭其他',
  91. },
  92. {
  93. disabled: isEmpty(getUnlockedKeysBefore(key)),
  94. icon: () =>
  95. h('span', {
  96. class: 'iconify ph--arrow-line-left',
  97. }),
  98. key: 'closeLeft',
  99. label: '关闭左侧',
  100. },
  101. {
  102. disabled: isEmpty(getUnlockedKeysAfter(key)),
  103. icon: () =>
  104. h('span', {
  105. class: 'iconify ph--arrow-line-right',
  106. }),
  107. key: 'closeRight',
  108. label: '关闭右侧',
  109. },
  110. {
  111. disabled: isEmpty(getUnlockedKeys()),
  112. icon: () =>
  113. h('span', {
  114. class: 'iconify ph--arrows-horizontal',
  115. }),
  116. key: 'closeAll',
  117. label: '关闭所有',
  118. },
  119. {
  120. disabled: isEmpty(componentName),
  121. icon: () =>
  122. hasKeepAliveTab(componentName)
  123. ? h('span', {
  124. class: 'iconify-[hugeicons--database-02]',
  125. })
  126. : h('span', {
  127. class: 'iconify-[hugeicons--database-locked]',
  128. }),
  129. key: 'keepAlive',
  130. label: hasKeepAliveTab(componentName) ? '取消缓存' : '缓存标签页',
  131. },
  132. {
  133. disabled: isPinned(key),
  134. icon: () =>
  135. isLocked(key)
  136. ? h('span', {
  137. class: 'iconify ph--lock-simple-open',
  138. })
  139. : h('span', {
  140. class: 'iconify ph--lock-simple',
  141. }),
  142. key: 'lock',
  143. label: isLocked(key) ? '解锁标签页' : '锁定标签页',
  144. },
  145. ]
  146. })
  147. const dropdownPosition = reactive({
  148. x: 0,
  149. y: 0,
  150. })
  151. const handleTabClick = (key: string) => {
  152. router.push(key)
  153. }
  154. const handleTabCloseClick = (key: string) => {
  155. remove(key)
  156. }
  157. const handleTabContextMenuClick = (e: MouseEvent, tab: Tab) => {
  158. e.preventDefault()
  159. tabContextMenu.value = tab
  160. showTabDropdown.value = false
  161. showTabTooltip.value = false
  162. nextTick(() => {
  163. showTabDropdown.value = true
  164. dropdownPosition.x = e.clientX
  165. dropdownPosition.y = e.clientY
  166. })
  167. }
  168. const onTabDropdownClickOutside = () => {
  169. showTabDropdown.value = false
  170. showTabTooltip.value = true
  171. tabContextMenu.value = null
  172. }
  173. const onTabDraggableStart = () => {
  174. showTabDropdown.value = false
  175. showTabTooltip.value = false
  176. }
  177. const onTabDraggableEnd = () => {
  178. update([...tabPinnedList.value, ...tabList.value])
  179. showTabTooltip.value = true
  180. }
  181. const onTabDraggableChoose = () => {
  182. isTabDragging.value = true
  183. }
  184. const onTabDraggableUnchoose = () => {
  185. isTabDragging.value = false
  186. }
  187. const onTabDropdownSelected = (key: keyof ContextMenuActions) => {
  188. showTabDropdown.value = false
  189. showTabTooltip.value = true
  190. getTabContextMenuActions()?.[key]()
  191. }
  192. const onScrollbarWheeled = (e: WheelEvent) => {
  193. if (!scrollbarRef.value) return
  194. scrollbarRef.value.scrollBy({
  195. left: (e.deltaY || e.deltaX) * 3,
  196. behavior: 'smooth',
  197. })
  198. }
  199. function getTabContextMenuActions(): ContextMenuActions | null {
  200. const tabValue = tabContextMenu.value
  201. if (!tabValue) {
  202. return null
  203. }
  204. const { key, componentName } = tabValue
  205. return {
  206. close: () => {
  207. remove(key)
  208. },
  209. closeAll: () => {
  210. removeAll()
  211. },
  212. closeLeft: () => {
  213. removeBefore(key)
  214. },
  215. closeOther: () => {
  216. removeExcept(key)
  217. },
  218. closeRight: () => {
  219. removeAfter(key)
  220. },
  221. keepAlive: () => {
  222. if (componentName) {
  223. setKeepAliveTab(componentName)
  224. }
  225. },
  226. lock: () => {
  227. setLocked(key)
  228. },
  229. }
  230. }
  231. function scrollToActiveTab() {
  232. nextTick(() => {
  233. document.querySelector('.tab-active')?.scrollIntoView({
  234. behavior: 'smooth',
  235. })
  236. })
  237. }
  238. watch(
  239. (): [Tab[], string] => [tabs.value, tabActiveKey.value],
  240. ([newTabs, newActive], [oldTabs, oldActive]) => {
  241. if (!newActive) {
  242. tabBackgroundTransitionClasses.leaveToClass = 'scale-0 opacity-0'
  243. return
  244. }
  245. if (!oldActive) {
  246. tabBackgroundTransitionClasses.enterFromClass = 'scale-0 opacity-0'
  247. return
  248. }
  249. const oldActiveIndex = oldTabs.findIndex((item) => item.key === oldActive)
  250. const newActiveIndex = newTabs.findIndex((item) => item.key === newActive)
  251. if (oldActiveIndex > newActiveIndex && newActiveIndex !== -1) {
  252. tabBackgroundTransitionClasses.leaveToClass = '-translate-x-full'
  253. tabBackgroundTransitionClasses.enterFromClass = 'translate-x-full'
  254. } else {
  255. tabBackgroundTransitionClasses.leaveToClass = 'translate-x-full'
  256. tabBackgroundTransitionClasses.enterFromClass = '-translate-x-full'
  257. }
  258. },
  259. )
  260. watchEffect(() => {
  261. tabList.value = tabs.value.filter((tab) => !tab.pinned)
  262. tabPinnedList.value = tabs.value.filter((tab) => tab.pinned)
  263. })
  264. onMounted(() => {
  265. nextTick(() => {
  266. isMounted.value = true
  267. })
  268. })
  269. </script>
  270. <template>
  271. <div
  272. class="flex min-h-0 overflow-hidden border-b border-naive-border bg-naive-card transition-[background-color,border-color] duration-300 ease-naive-bezier"
  273. >
  274. <div class="flex shrink-0">
  275. <div
  276. v-for="tab in tabPinnedList"
  277. :key="tab.key"
  278. class="relative max-w-40 overflow-hidden border-r border-r-naive-border transition-[border-color,max-width] duration-300 ease-naive-bezier select-none"
  279. :class="[
  280. isTabDragging ? 'cursor-move' : 'cursor-pointer',
  281. {
  282. 'tab-active bg-primary/6': tab.key === tabActiveKey,
  283. },
  284. ]"
  285. @click="handleTabClick(tab.key)"
  286. @contextmenu="(e) => handleTabContextMenuClick(e, tab)"
  287. >
  288. <Transition
  289. enter-active-class="transition-[opacity,scale,translate] duration-300 ease-naive-bezier will-change-[opacity,transform,scale]"
  290. leave-active-class="transition-[opacity,scale,translate] duration-300 ease-naive-bezier will-change-[opacity,transform,scale]"
  291. :leave-to-class="tabBackgroundTransitionClasses.leaveToClass"
  292. :enter-from-class="tabBackgroundTransitionClasses.enterFromClass"
  293. @after-enter="scrollToActiveTab"
  294. >
  295. <div
  296. v-if="tab.key === tabActiveKey && isMounted"
  297. class="absolute inset-0 size-full border-t-[1.5px] border-primary bg-primary/6"
  298. />
  299. </Transition>
  300. <div
  301. class="flex items-center py-2.5 pl-4"
  302. :class="tab.pinned ? 'pr-4' : 'pr-2.5'"
  303. >
  304. <div class="mr-2 grid shrink-0 place-items-center">
  305. <span :class="tab.icon" />
  306. </div>
  307. <NEllipsis :tooltip="showTabTooltip">{{ tab.label }}</NEllipsis>
  308. </div>
  309. </div>
  310. </div>
  311. <NScrollbar
  312. ref="scrollbarRef"
  313. x-scrollable
  314. @wheel.passive="onScrollbarWheeled"
  315. >
  316. <VueDraggable
  317. class="flex"
  318. v-model="tabList"
  319. :animation="150"
  320. direction="horizontal"
  321. force-fallback
  322. :delay="300"
  323. ghost-class="bg-primary/30"
  324. @start="onTabDraggableStart"
  325. @end="onTabDraggableEnd"
  326. @choose="onTabDraggableChoose"
  327. @unchoose="onTabDraggableUnchoose"
  328. >
  329. <TransitionGroup
  330. enter-from-class="max-w-0"
  331. leave-to-class="max-w-0"
  332. @after-enter="scrollToActiveTab"
  333. >
  334. <div
  335. v-for="tab in tabList"
  336. :key="tab.key"
  337. class="relative overflow-hidden border-r border-r-naive-border transition-[border-color,max-width] duration-300 ease-naive-bezier select-none [&:not(.max-w-0)]:max-w-40"
  338. :class="[
  339. isTabDragging ? 'cursor-move' : 'cursor-pointer',
  340. {
  341. 'tab-active': tab.key === tabActiveKey,
  342. group: !tab.locked && !configureStore.configure.showTabClose,
  343. },
  344. ]"
  345. @click="handleTabClick(tab.key)"
  346. @contextmenu="(e) => handleTabContextMenuClick(e, tab)"
  347. >
  348. <Transition
  349. enter-active-class="transition-[opacity,scale,translate] duration-300 ease-naive-bezier will-change-[opacity,transform,scale]"
  350. leave-active-class="transition-[opacity,scale,translate] duration-300 ease-naive-bezier will-change-[opacity,transform,scale]"
  351. :leave-to-class="tabBackgroundTransitionClasses.leaveToClass"
  352. :enter-from-class="tabBackgroundTransitionClasses.enterFromClass"
  353. @after-enter="scrollToActiveTab"
  354. >
  355. <div
  356. v-if="tab.key === tabActiveKey && isMounted"
  357. class="absolute inset-0 size-full border-t-[1.5px] border-primary bg-primary/6"
  358. />
  359. </Transition>
  360. <div
  361. class="flex items-center gap-x-1 py-2.5 pl-4"
  362. :class="tab.pinned ? 'pr-4' : 'pr-2.5'"
  363. >
  364. <div
  365. class="flex items-center justify-center gap-x-1.5 transition-[translate] duration-300 ease-naive-bezier"
  366. :class="{
  367. 'translate-x-2.5': tab.locked || !configureStore.configure.showTabClose,
  368. 'group-hover:translate-x-0':
  369. !tab.locked && !configureStore.configure.showTabClose,
  370. }"
  371. >
  372. <span
  373. :class="[
  374. tab.icon,
  375. {
  376. 'text-primary':
  377. tab.componentName && keepAliveTabs.includes(tab.componentName),
  378. },
  379. ]"
  380. />
  381. <NEllipsis :tooltip="showTabTooltip">{{ tab.label }}</NEllipsis>
  382. </div>
  383. <div
  384. class="flex transition-[opacity,scale] duration-300 ease-naive-bezier"
  385. :class="{
  386. 'scale-0 opacity-0': tab.locked || !configureStore.configure.showTabClose,
  387. 'group-hover:scale-100 group-hover:opacity-100':
  388. !tab.locked && !configureStore.configure.showTabClose,
  389. }"
  390. >
  391. <NButton
  392. v-if="!tab.pinned"
  393. quaternary
  394. circle
  395. size="tiny"
  396. @click.stop="handleTabCloseClick(tab.key)"
  397. :disabled="tab.locked"
  398. >
  399. <template #icon>
  400. <span class="iconify ph--x" />
  401. </template>
  402. </NButton>
  403. </div>
  404. </div>
  405. </div>
  406. </TransitionGroup>
  407. </VueDraggable>
  408. </NScrollbar>
  409. <div class="flex items-center gap-x-2 px-3.5">
  410. <ButtonAnimation
  411. title="刷新"
  412. @click="tabsInject?.doRefresh(true)"
  413. animation="rotate"
  414. >
  415. <span class="iconify size-5 ph--arrows-clockwise"></span>
  416. </ButtonAnimation>
  417. </div>
  418. <NDropdown
  419. placement="bottom-start"
  420. trigger="manual"
  421. :x="dropdownPosition.x"
  422. :y="dropdownPosition.y"
  423. :options="tabDropdownOptions"
  424. :show="showTabDropdown"
  425. @clickoutside="onTabDropdownClickOutside"
  426. @select="onTabDropdownSelected"
  427. >
  428. </NDropdown>
  429. </div>
  430. </template>