Tabs.vue 13 KB

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