123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478 |
- <script setup lang="tsx">
- import { isEmpty } from 'lodash-es'
- import { NButton, NDropdown, NEllipsis, NScrollbar } from 'naive-ui'
- import {
- computed,
- defineComponent,
- nextTick,
- onBeforeUnmount,
- onMounted,
- reactive,
- ref,
- Transition,
- TransitionGroup,
- useTemplateRef,
- watch,
- } from 'vue'
- import { VueDraggable } from 'vue-draggable-plus'
- import { ButtonAnimation } from '@/components'
- import { useInjection } from '@/composables'
- import { layoutInjectionKey } from '@/injection'
- import router from '@/router'
- import { usePreferencesStore, useTabsStore } from '@/stores'
- import type { Tab, Key } from '@/stores'
- import type { DropdownOption } from 'naive-ui'
- import type { PropType } from 'vue'
- type ContextMenuActions = {
- close: () => void
- closeAll: () => void
- closeLeft: () => void
- closeOther: () => void
- closeRight: () => void
- pin: () => void
- keepalive: () => void
- lock: () => void
- }
- const { shouldRefreshRoute } = useInjection(layoutInjectionKey)
- const scrollbarRef = useTemplateRef<InstanceType<typeof NScrollbar>>('scrollbarRef')
- const tabsStore = useTabsStore()
- const preferencesStore = usePreferencesStore()
- const {
- setTabActivePath,
- getTab,
- removeTab,
- updateTab,
- setTabs,
- getRemovableIdsBefore,
- getRemovableIdsAfter,
- getRemovableIdsOther,
- getRemovableIds,
- } = tabsStore
- const tabPinnedList = computed({
- get: () => tabsStore.tabs.filter((tab) => tab.pinned),
- set: (newPinnedTabs: Tab[]) => {
- const currentUnpinnedTabs = tabsStore.tabs.filter((tab) => !tab.pinned)
- setTabs([...newPinnedTabs, ...currentUnpinnedTabs])
- },
- })
- const tabUnPinnedList = computed({
- get: () => tabsStore.tabs.filter((tab) => !tab.pinned),
- set: (newUnpinnedTabs: Tab[]) => {
- const currentPinnedTabs = tabsStore.tabs.filter((tab) => tab.pinned)
- setTabs([...currentPinnedTabs, ...newUnpinnedTabs])
- },
- })
- const pendingActivePath = ref('')
- const tabBackgroundTransitionClasses = reactive({
- leaveToClass: '',
- enterFromClass: '',
- })
- const isTabDragging = ref(false)
- const showTabTooltip = ref(true)
- const showTabDropdown = ref(false)
- const tabContextMenu = ref<Tab | null>(null)
- const tabDropdownOptions = computed<DropdownOption[]>(() => {
- const targetTab = tabContextMenu.value
- if (isEmpty(targetTab)) {
- return []
- }
- const { id, componentName } = targetTab
- const { pinned, locked, keepAlive } = getTab(id) ?? {}
- return [
- {
- key: 'close',
- icon: () => <span class='iconify ph--x' />,
- label: '关闭',
- disabled: locked,
- },
- {
- key: 'closeOther',
- icon: () => <span class='iconify ph--arrows-out-line-horizontal' />,
- label: '关闭其他',
- disabled: isEmpty(getRemovableIdsOther(id)),
- },
- {
- key: 'closeLeft',
- icon: () => <span class='iconify ph--arrow-line-left' />,
- label: '关闭左侧',
- disabled: isEmpty(getRemovableIdsBefore(id)),
- },
- {
- key: 'closeRight',
- icon: () => <span class='iconify ph--arrow-line-right' />,
- label: '关闭右侧',
- disabled: isEmpty(getRemovableIdsAfter(id)),
- },
- {
- key: 'closeAll',
- icon: () => <span class='iconify ph--arrows-horizontal' />,
- label: '关闭所有',
- disabled: isEmpty(getRemovableIds()),
- },
- {
- key: 'pin',
- icon: () => (
- <span
- class={pinned ? 'iconify ph--push-pin-simple-slash' : 'iconify ph--push-pin-simple'}
- />
- ),
- label: pinned ? '取消固定' : '固定标签页',
- },
- {
- key: 'keepalive',
- icon: () => (
- <span
- class={
- keepAlive ? 'iconify-[hugeicons--database-02]' : 'iconify-[hugeicons--database-locked]'
- }
- />
- ),
- label: keepAlive ? '取消缓存' : '缓存标签页',
- disabled: isEmpty(componentName),
- },
- {
- key: 'lock',
- icon: () => (
- <span class={locked ? 'iconify ph--lock-simple-open' : 'iconify ph--lock-simple'} />
- ),
- label: locked ? '取消锁定' : '锁定标签页',
- disabled: pinned,
- },
- ]
- })
- const dropdownPosition = reactive({
- x: 0,
- y: 0,
- })
- const handleTabClick = (path: string) => {
- setTabActivePath(path)
- }
- const handleTabCloseClick = (id: Key) => {
- removeTab(id)
- }
- const handleTabContextMenuClick = (e: MouseEvent, tab: Tab) => {
- e.preventDefault()
- tabContextMenu.value = tab
- showTabDropdown.value = false
- showTabTooltip.value = false
- nextTick(() => {
- showTabDropdown.value = true
- dropdownPosition.x = e.clientX
- dropdownPosition.y = e.clientY
- })
- }
- const onTabDropdownClickOutside = () => {
- showTabDropdown.value = false
- showTabTooltip.value = true
- tabContextMenu.value = null
- }
- const onTabDraggableStart = () => {
- showTabDropdown.value = false
- showTabTooltip.value = false
- }
- const onTabDraggableEnd = () => {
- showTabTooltip.value = true
- }
- const onTabDraggableChoose = () => {
- isTabDragging.value = true
- }
- const onTabDraggableUnchoose = () => {
- isTabDragging.value = false
- }
- const onTabDropdownSelected = (key: keyof ContextMenuActions) => {
- showTabDropdown.value = false
- showTabTooltip.value = true
- getTabContextMenuActions()?.[key]()
- }
- const onScrollbarWheeled = (e: WheelEvent) => {
- if (!scrollbarRef.value) return
- scrollbarRef.value.scrollBy({
- left: (e.deltaY || e.deltaX) * 3,
- behavior: 'smooth',
- })
- }
- function getTabContextMenuActions(): ContextMenuActions | null {
- const targetTab = tabContextMenu.value
- if (!targetTab) {
- return null
- }
- const { id } = targetTab
- const { locked, keepAlive, pinned } = getTab(id) ?? {}
- return {
- close: () => {
- removeTab(id)
- },
- closeOther: () => {
- removeTab(getRemovableIdsOther(id))
- },
- closeLeft: () => {
- removeTab(getRemovableIdsBefore(id))
- },
- closeRight: () => {
- removeTab(getRemovableIdsAfter(id))
- },
- closeAll: () => {
- removeTab(getRemovableIds())
- },
- pin: () => {
- updateTab(id, { pinned: !pinned })
- },
- keepalive: () => {
- updateTab(id, { keepAlive: !keepAlive })
- },
- lock: () => {
- updateTab(id, { locked: !locked })
- },
- }
- }
- function scrollToActiveTab(behavior: ScrollBehavior = 'auto') {
- nextTick(() => {
- document.querySelector('.tab-active')?.scrollIntoView({
- behavior,
- })
- })
- }
- function handleTabRefreshClick() {
- shouldRefreshRoute.value = true
- }
- const routerAfterEach = router.afterEach(() => {
- nextTick(() => {
- pendingActivePath.value = tabsStore.tabActivePath
- })
- })
- const InternalTabs = defineComponent({
- props: {
- modelValue: {
- type: Array as PropType<Tab[]>,
- required: true,
- },
- },
- emits: ['update:modelValue'],
- setup(props, { emit }) {
- return () => (
- <VueDraggable
- class='flex'
- modelValue={props.modelValue}
- animation={150}
- easing='cubic-bezier(0, 0, 1, 1)'
- direction='horizontal'
- scrollSensitivity={100}
- ghostClass='bg-primary/30'
- onStart={onTabDraggableStart}
- onEnd={onTabDraggableEnd}
- onChoose={onTabDraggableChoose}
- onUnchoose={onTabDraggableUnchoose}
- {...{
- 'onUpdate:modelValue': (value: Tab[]) => emit('update:modelValue', value),
- }}
- >
- <TransitionGroup
- duration={300}
- type='transition'
- enterFromClass='max-w-0'
- leaveToClass='max-w-0'
- onAfterEnter={() => scrollToActiveTab('smooth')}
- >
- {props.modelValue.map((tab) => (
- <div
- key={tab.id}
- class={[
- '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',
- {
- 'tab-active': tab.path === pendingActivePath.value,
- group: !tab.locked && !preferencesStore.preferences.showTabClose,
- },
- ]}
- onClick={() => handleTabClick(tab.path)}
- onContextmenu={(e) => handleTabContextMenuClick(e, tab)}
- >
- <Transition
- type='transition'
- leaveActiveClass='transition-[opacity,scale,translate] will-change-[opacity,transform,scale]'
- enterActiveClass='transition-[opacity,scale,translate] will-change-[opacity,transform,scale]'
- leaveToClass={tabBackgroundTransitionClasses.leaveToClass}
- enterFromClass={tabBackgroundTransitionClasses.enterFromClass}
- onAfterEnter={() => {
- scrollToActiveTab('smooth')
- }}
- >
- {tab.path === pendingActivePath.value && (
- <div class='absolute inset-0 z-0 size-full border-t-[1.5px] border-primary bg-primary/6' />
- )}
- </Transition>
- <div class={['flex items-center py-2.5 pl-4', tab.pinned ? 'pr-4' : 'pr-2.5']}>
- <div
- class={[
- 'flex flex-1 items-center overflow-hidden transition-[translate]',
- {
- 'translate-x-2.5':
- !tab.pinned && (tab.locked || !preferencesStore.preferences.showTabClose),
- 'group-hover:translate-x-0':
- !tab.pinned && !tab.locked && !preferencesStore.preferences.showTabClose,
- },
- ]}
- >
- <div class='mr-2 grid shrink-0 place-items-center overflow-hidden'>
- <span
- class={[
- tab.icon,
- {
- 'text-primary': tab.componentName && getTab(tab.id)?.keepAlive,
- },
- ]}
- />
- </div>
- <NEllipsis tooltip={showTabTooltip.value}>{tab.title}</NEllipsis>
- </div>
- {!tab.pinned && (
- <div
- class={[
- 'ml-1 flex overflow-hidden transition-[opacity,scale]',
- {
- 'scale-0 opacity-0':
- tab.locked || !preferencesStore.preferences.showTabClose,
- 'group-hover:scale-100 group-hover:opacity-100':
- !tab.locked && !preferencesStore.preferences.showTabClose,
- },
- ]}
- >
- <NButton
- quaternary
- circle
- size='tiny'
- onClick={(e) => {
- e.stopPropagation()
- handleTabCloseClick(tab.id)
- }}
- disabled={tab.locked}
- >
- {{
- icon: () => <span class='iconify ph--x' />,
- }}
- </NButton>
- </div>
- )}
- </div>
- </div>
- ))}
- </TransitionGroup>
- </VueDraggable>
- )
- },
- })
- watch(
- (): [Tab[], string] => [tabsStore.tabs, tabsStore.tabActivePath],
- ([newTabs, newTabActivePath], [oldTabs, oldTabActivePath]) => {
- if (!newTabActivePath) {
- tabBackgroundTransitionClasses.leaveToClass = 'scale-0 opacity-0'
- return
- }
- if (!oldTabActivePath) {
- tabBackgroundTransitionClasses.enterFromClass = 'scale-0 opacity-0'
- return
- }
- const oldActiveIndex = oldTabs.findIndex((item) => item.path === oldTabActivePath)
- const newActiveIndex = newTabs.findIndex((item) => item.path === newTabActivePath)
- if (oldActiveIndex > newActiveIndex && newActiveIndex !== -1) {
- tabBackgroundTransitionClasses.leaveToClass = '-translate-x-full'
- tabBackgroundTransitionClasses.enterFromClass = 'translate-x-full'
- } else {
- tabBackgroundTransitionClasses.leaveToClass = 'translate-x-full'
- tabBackgroundTransitionClasses.enterFromClass = '-translate-x-full'
- }
- },
- )
- onMounted(() => {
- scrollToActiveTab()
- pendingActivePath.value = tabsStore.tabActivePath
- tabBackgroundTransitionClasses.enterFromClass = 'scale-0 opacity-0'
- })
- onBeforeUnmount(() => {
- routerAfterEach()
- })
- </script>
- <template>
- <div
- class="flex min-h-0 overflow-hidden border-b border-naive-border bg-naive-card transition-[background-color,border-color] select-none"
- >
- <InternalTabs v-model="tabPinnedList" />
- <NScrollbar
- ref="scrollbarRef"
- x-scrollable
- @wheel.passive="onScrollbarWheeled"
- >
- <InternalTabs v-model="tabUnPinnedList" />
- </NScrollbar>
- <div class="flex items-center px-3">
- <ButtonAnimation
- title="刷新"
- @click="handleTabRefreshClick"
- animation="rotate"
- >
- <span class="iconify size-5 ph--arrows-clockwise"></span>
- </ButtonAnimation>
- </div>
- <NDropdown
- placement="bottom-start"
- trigger="manual"
- :x="dropdownPosition.x"
- :y="dropdownPosition.y"
- :options="tabDropdownOptions"
- :show="showTabDropdown"
- @clickoutside="onTabDropdownClickOutside"
- @select="onTabDropdownSelected"
- >
- </NDropdown>
- </div>
- </template>
|