index.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478
  1. <script setup lang="tsx">
  2. import { isEmpty } from 'lodash-es'
  3. import { 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. defineOptions({
  37. name: 'Tabs',
  38. })
  39. const { shouldRefreshRoute } = useInjection(layoutInjectionKey)
  40. const scrollbarRef = useTemplateRef<InstanceType<typeof NScrollbar>>('scrollbarRef')
  41. const tabsStore = useTabsStore()
  42. const preferencesStore = usePreferencesStore()
  43. const {
  44. setTabActivePath,
  45. getTab,
  46. removeTab,
  47. updateTab,
  48. setTabs,
  49. getRemovableIdsBefore,
  50. getRemovableIdsAfter,
  51. getRemovableIdsOther,
  52. getRemovableIds,
  53. } = tabsStore
  54. const tabPinnedList = computed({
  55. get: () => tabsStore.tabs.filter((tab) => tab.pinned),
  56. set: (newPinnedTabs: Tab[]) => {
  57. const currentUnpinnedTabs = tabsStore.tabs.filter((tab) => !tab.pinned)
  58. setTabs([...newPinnedTabs, ...currentUnpinnedTabs])
  59. },
  60. })
  61. const tabUnPinnedList = computed({
  62. get: () => tabsStore.tabs.filter((tab) => !tab.pinned),
  63. set: (newUnpinnedTabs: Tab[]) => {
  64. const currentPinnedTabs = tabsStore.tabs.filter((tab) => tab.pinned)
  65. setTabs([...currentPinnedTabs, ...newUnpinnedTabs])
  66. },
  67. })
  68. const pendingActivePath = ref('')
  69. const tabBackgroundTransitionClasses = reactive({
  70. leaveToClass: '',
  71. enterFromClass: '',
  72. })
  73. const isTabDragging = ref(false)
  74. const showTabTooltip = ref(true)
  75. const showTabDropdown = ref(false)
  76. const tabContextMenu = ref<Tab | null>(null)
  77. const tabDropdownOptions = computed<DropdownOption[]>(() => {
  78. const targetTab = tabContextMenu.value
  79. if (isEmpty(targetTab)) {
  80. return []
  81. }
  82. const { id, componentName } = targetTab
  83. const { pinned, locked, keepAlive } = getTab(id) ?? {}
  84. return [
  85. {
  86. key: 'close',
  87. icon: () => <span class='iconify ph--x' />,
  88. label: '关闭',
  89. disabled: locked,
  90. },
  91. {
  92. key: 'closeOther',
  93. icon: () => <span class='iconify ph--arrows-out-line-horizontal' />,
  94. label: '关闭其他',
  95. disabled: isEmpty(getRemovableIdsOther(id)),
  96. },
  97. {
  98. key: 'closeLeft',
  99. icon: () => <span class='iconify ph--arrow-line-left' />,
  100. label: '关闭左侧',
  101. disabled: isEmpty(getRemovableIdsBefore(id)),
  102. },
  103. {
  104. key: 'closeRight',
  105. icon: () => <span class='iconify ph--arrow-line-right' />,
  106. label: '关闭右侧',
  107. disabled: isEmpty(getRemovableIdsAfter(id)),
  108. },
  109. {
  110. key: 'closeAll',
  111. icon: () => <span class='iconify ph--arrows-horizontal' />,
  112. label: '关闭所有',
  113. disabled: isEmpty(getRemovableIds()),
  114. },
  115. {
  116. key: 'pin',
  117. icon: () => (
  118. <span
  119. class={pinned ? 'iconify ph--push-pin-simple-slash' : 'iconify ph--push-pin-simple'}
  120. />
  121. ),
  122. label: pinned ? '取消固定' : '固定标签页',
  123. },
  124. {
  125. key: 'keepalive',
  126. icon: () => (
  127. <span
  128. class={
  129. keepAlive ? 'iconify-[hugeicons--database-02]' : 'iconify-[hugeicons--database-locked]'
  130. }
  131. />
  132. ),
  133. label: keepAlive ? '取消缓存' : '缓存标签页',
  134. disabled: isEmpty(componentName),
  135. },
  136. {
  137. key: 'lock',
  138. icon: () => (
  139. <span class={locked ? 'iconify ph--lock-simple-open' : 'iconify ph--lock-simple'} />
  140. ),
  141. label: locked ? '取消锁定' : '锁定标签页',
  142. disabled: pinned,
  143. },
  144. ]
  145. })
  146. const dropdownPosition = reactive({
  147. x: 0,
  148. y: 0,
  149. })
  150. const handleTabClick = (path: string) => {
  151. setTabActivePath(path)
  152. }
  153. const handleTabCloseClick = (id: Key) => {
  154. removeTab(id)
  155. }
  156. const handleTabContextMenuClick = (e: MouseEvent, tab: Tab) => {
  157. e.preventDefault()
  158. tabContextMenu.value = tab
  159. showTabDropdown.value = false
  160. showTabTooltip.value = false
  161. nextTick(() => {
  162. showTabDropdown.value = true
  163. dropdownPosition.x = e.clientX
  164. dropdownPosition.y = e.clientY
  165. })
  166. }
  167. const onTabDropdownClickOutside = () => {
  168. showTabDropdown.value = false
  169. showTabTooltip.value = true
  170. tabContextMenu.value = null
  171. }
  172. const onTabDraggableStart = () => {
  173. showTabDropdown.value = false
  174. showTabTooltip.value = false
  175. }
  176. const onTabDraggableEnd = () => {
  177. showTabTooltip.value = true
  178. }
  179. const onTabDraggableChoose = () => {
  180. isTabDragging.value = true
  181. }
  182. const onTabDraggableUnchoose = () => {
  183. isTabDragging.value = false
  184. }
  185. const onTabDropdownSelected = (key: keyof ContextMenuActions) => {
  186. showTabDropdown.value = false
  187. showTabTooltip.value = true
  188. getTabContextMenuActions()?.[key]()
  189. }
  190. const onScrollbarWheeled = (e: WheelEvent) => {
  191. if (!scrollbarRef.value) return
  192. scrollbarRef.value.scrollBy({
  193. left: (e.deltaY || e.deltaX) * 3,
  194. behavior: 'smooth',
  195. })
  196. }
  197. function getTabContextMenuActions(): ContextMenuActions | null {
  198. const targetTab = tabContextMenu.value
  199. if (!targetTab) {
  200. return null
  201. }
  202. const { id } = targetTab
  203. const { locked, keepAlive, pinned } = getTab(id) ?? {}
  204. return {
  205. close: () => {
  206. removeTab(id)
  207. },
  208. closeOther: () => {
  209. removeTab(getRemovableIdsOther(id))
  210. },
  211. closeLeft: () => {
  212. removeTab(getRemovableIdsBefore(id))
  213. },
  214. closeRight: () => {
  215. removeTab(getRemovableIdsAfter(id))
  216. },
  217. closeAll: () => {
  218. removeTab(getRemovableIds())
  219. },
  220. pin: () => {
  221. updateTab(id, { pinned: !pinned })
  222. },
  223. keepalive: () => {
  224. updateTab(id, { keepAlive: !keepAlive })
  225. },
  226. lock: () => {
  227. updateTab(id, { locked: !locked })
  228. },
  229. }
  230. }
  231. function scrollToActiveTab(behavior: ScrollBehavior = 'auto') {
  232. nextTick(() => {
  233. document.querySelector('.tab-active')?.scrollIntoView({
  234. behavior,
  235. })
  236. })
  237. }
  238. function handleTabRefreshClick() {
  239. shouldRefreshRoute.value = true
  240. }
  241. const routerAfterEach = router.afterEach(() => {
  242. nextTick(() => {
  243. pendingActivePath.value = tabsStore.tabActivePath
  244. })
  245. })
  246. const InternalTabs = defineComponent({
  247. props: {
  248. modelValue: {
  249. type: Array as PropType<Tab[]>,
  250. required: true,
  251. },
  252. },
  253. emits: ['update:modelValue'],
  254. setup(props, { emit }) {
  255. return () => (
  256. <VueDraggable
  257. class='flex h-10.5'
  258. modelValue={props.modelValue}
  259. animation={150}
  260. easing='cubic-bezier(0, 0, 1, 1)'
  261. direction='horizontal'
  262. scrollSensitivity={100}
  263. ghostClass='bg-primary/30'
  264. onStart={onTabDraggableStart}
  265. onEnd={onTabDraggableEnd}
  266. onChoose={onTabDraggableChoose}
  267. onUnchoose={onTabDraggableUnchoose}
  268. {...{
  269. 'onUpdate:modelValue': (value: Tab[]) => emit('update:modelValue', value),
  270. }}
  271. >
  272. <TransitionGroup
  273. duration={300}
  274. type='transition'
  275. enterFromClass='max-w-0'
  276. leaveToClass='max-w-0'
  277. onAfterEnter={() => scrollToActiveTab('smooth')}
  278. >
  279. {props.modelValue.map((tab) => (
  280. <div
  281. key={tab.id}
  282. class={[
  283. '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',
  284. {
  285. 'tab-active': tab.path === pendingActivePath.value,
  286. group: !tab.locked && !preferencesStore.preferences.showTabClose,
  287. },
  288. ]}
  289. onClick={() => handleTabClick(tab.path)}
  290. onContextmenu={(e) => handleTabContextMenuClick(e, tab)}
  291. >
  292. <div
  293. class={[
  294. 'relative z-10 flex h-full items-center pl-4',
  295. tab.pinned ? 'pr-4' : 'pr-2.5',
  296. ]}
  297. >
  298. <div
  299. class={[
  300. 'flex flex-1 items-center overflow-hidden transition-[translate]',
  301. {
  302. 'translate-x-2.5':
  303. !tab.pinned && (tab.locked || !preferencesStore.preferences.showTabClose),
  304. 'group-hover:translate-x-0':
  305. !tab.pinned && !tab.locked && !preferencesStore.preferences.showTabClose,
  306. },
  307. ]}
  308. >
  309. <div class='mr-2 grid shrink-0 place-items-center overflow-hidden'>
  310. <span
  311. class={[
  312. 'size-4.5',
  313. tab.icon,
  314. {
  315. 'text-primary': tab.componentName && getTab(tab.id)?.keepAlive,
  316. },
  317. ]}
  318. />
  319. </div>
  320. <NEllipsis tooltip={showTabTooltip.value}>{tab.title}</NEllipsis>
  321. </div>
  322. {!tab.pinned && (
  323. <div
  324. class={[
  325. 'ml-1 flex overflow-hidden rounded-full p-1 transition-[background-color,opacity,scale] hover:bg-naive-button-hover',
  326. {
  327. 'scale-0 opacity-0':
  328. tab.locked || !preferencesStore.preferences.showTabClose,
  329. 'group-hover:scale-100 group-hover:opacity-100':
  330. !tab.locked && !preferencesStore.preferences.showTabClose,
  331. },
  332. ]}
  333. onClick={(e) => {
  334. e.stopPropagation()
  335. handleTabCloseClick(tab.id)
  336. }}
  337. >
  338. <span class='iconify-[line-md--close] size-3.5' />
  339. </div>
  340. )}
  341. </div>
  342. <Transition
  343. type='transition'
  344. leaveActiveClass='transition-[opacity,scale,translate] will-change-[opacity,transform,scale]'
  345. enterActiveClass='transition-[opacity,scale,translate] will-change-[opacity,transform,scale]'
  346. leaveToClass={tabBackgroundTransitionClasses.leaveToClass}
  347. enterFromClass={tabBackgroundTransitionClasses.enterFromClass}
  348. onAfterEnter={() => {
  349. scrollToActiveTab('smooth')
  350. }}
  351. >
  352. {tab.path === pendingActivePath.value && (
  353. <div class='absolute inset-0 size-full border-t-[1.5px] border-primary bg-primary/6' />
  354. )}
  355. </Transition>
  356. </div>
  357. ))}
  358. </TransitionGroup>
  359. </VueDraggable>
  360. )
  361. },
  362. })
  363. watch(
  364. [() => 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>