| <template> | 
|   <view | 
|     ref="container" | 
|     class="nut-tabs" | 
|     :class="[ | 
|       direction, | 
|       { fullHeight, noContent: !showPaneContent, flexTitle, noSmile: !showSmile }, | 
|     ]" | 
|     :style="tabsStyle" | 
|   > | 
|     <div :class="['pro-tabs__titles_wrapper', { isTransparent }]"> | 
|       <scroll-view | 
|         :id="`nut-tabs__titles_${name}`" | 
|         :scroll-x="getScrollX" | 
|         :scroll-y="getScrollY" | 
|         :scroll-with-animation="true" | 
|         :scroll-left="scrollLeft" | 
|         :scroll-top="scrollTop" | 
|         :enable-flex="true" | 
|         class="nut-tabs__titles tabs-scrollview" | 
|         :class="{ | 
|           [type]: type, | 
|           scrollable: titleScroll, | 
|           'scroll-vertical': getScrollY, | 
|           [size]: size, | 
|         }" | 
|         :style="tabsNavStyle" | 
|         enhanced | 
|         :show-scrollbar="false" | 
|         @scroll="onScroll" | 
|       > | 
|         <view class="nut-tabs__list"> | 
|           <slot v-if="$slots.titles" name="titles"></slot> | 
|           <template v-else> | 
|             <view | 
|               v-for="(item, index) in titles" | 
|               :key="item.paneKey" | 
|               class="nut-tabs__titles-item taro" | 
|               :style="titleStyle" | 
|               :class="{ active: item.paneKey == modelValue, disabled: item.disabled }" | 
|               @click="tabMethods.tabChange(item, index)" | 
|             > | 
|               <view | 
|                 v-if="type == 'line'" | 
|                 class="nut-tabs__titles-item__line" | 
|                 :style="tabsActiveStyle" | 
|               ></view> | 
|               <view | 
|                 v-if="type == 'smile'" | 
|                 class="nut-tabs__titles-item__smile" | 
|                 :style="tabsActiveStyle" | 
|               > | 
|                 <JoySmile :color="color" /> | 
|               </view> | 
|               <nut-badge | 
|                 right="-12" | 
|                 top="4" | 
|                 :value="item.badgeValue" | 
|                 :max="99" | 
|                 :hidden="!item.badgeValue" | 
|                 color="#FC0B0B" | 
|               > | 
|                 <view class="nut-tabs__titles-item__text" :class="{ ellipsis: ellipsis }" | 
|                   >{{ item.title }} | 
|                 </view> | 
|               </nut-badge> | 
|               <view class="nut-tabs__sub-titles-item__text" :class="{ ellipsis: ellipsis }" | 
|                 >{{ item.subTitle }} | 
|               </view> | 
|             </view> | 
|             <view | 
|               v-if="canShowLabel" | 
|               class="nut-tabs__titles-item nut-tabs__titles-placeholder" | 
|             ></view> | 
|           </template> | 
|         </view> | 
|       </scroll-view> | 
|       <slot name="right"></slot> | 
|     </div> | 
|   | 
|     <view | 
|       :id="'tabsContentRef-' + refRandomId" | 
|       ref="tabsContentRef" | 
|       class="nut-tabs__content" | 
|       :style="contentStyle" | 
|       @touchstart="touchMethods.onTouchStart" | 
|       @touchmove="touchMethods.onTouchMove" | 
|       @touchend="touchMethods.onTouchEnd" | 
|       @touchcancel="touchMethods.onTouchEnd" | 
|       v-show="showPaneContent" | 
|     > | 
|       <slot name="default"></slot> | 
|     </view> | 
|   </view> | 
| </template> | 
|   | 
| <script setup lang="ts"> | 
| import { JoySmile } from '@nutui/icons-vue-taro'; | 
| import { tabsProps, Title, TypeOfFun, RectItem, raf, pxCheck, useTabContentTouch } from './tabs'; | 
| import Taro from '@tarojs/taro'; | 
| import { | 
|   CSSProperties, | 
|   useSlots, | 
|   computed, | 
|   ref, | 
|   onMounted, | 
|   provide, | 
|   VNode, | 
|   Ref, | 
|   nextTick, | 
|   watch, | 
|   onActivated, | 
| } from 'vue'; | 
| import { useTaroRect, useScrollDistance } from 'senin-mini/hooks'; | 
|   | 
| defineOptions({ | 
|   name: 'ProTabs', | 
| }); | 
|   | 
| const props = defineProps(tabsProps); | 
|   | 
| const emit = defineEmits<{ | 
|   (e: 'update:modelValue', value: string | number): void; | 
|   (e: 'click', item: Title): void; | 
|   (e: 'change', item: Title): void; | 
| }>(); | 
|   | 
| const slots = useSlots(); | 
|   | 
| const container = ref(null); | 
| provide('tabsOpiton', { | 
|   activeKey: computed(() => props.modelValue || '0'), | 
|   autoHeight: computed(() => props.autoHeight), | 
|   animatedTime: computed(() => props.animatedTime), | 
| }); | 
| const titles: Ref<Title[]> = ref([]); | 
| const renderTitles = (vnodes: VNode[]) => { | 
|   vnodes.forEach((vnode: VNode, index: number) => { | 
|     let type = vnode.type; | 
|     type = (type as any).name || type; | 
|     if (type === 'nut-tab-pane' || type === 'ProTabPane') { | 
|       let title = new Title(); | 
|       if (vnode.props?.title || vnode.props?.['pane-key'] || vnode.props?.['paneKey']) { | 
|         let paneKeyType = TypeOfFun(vnode.props?.['pane-key']); | 
|         let paneIndex = | 
|           paneKeyType === 'number' || paneKeyType === 'string' | 
|             ? String(vnode.props?.['pane-key']) | 
|             : null; | 
|         let camelPaneKeyType = TypeOfFun(vnode.props?.['paneKey']); | 
|         let camelPaneIndex = | 
|           camelPaneKeyType === 'number' || camelPaneKeyType === 'string' | 
|             ? String(vnode.props?.['paneKey']) | 
|             : null; | 
|         title.title = vnode.props?.title; | 
|         title.subTitle = vnode.props?.subTitle; | 
|         title.badgeValue = vnode.props?.badgeValue; | 
|         title.paneKey = paneIndex || camelPaneIndex || String(index); | 
|         title.disabled = vnode.props?.disabled; | 
|       } else { | 
|         // title.titleSlot = vnode.children?.title() as VNode[]; | 
|       } | 
|       titles.value.push(title); | 
|     } else { | 
|       if (vnode.children === ' ') { | 
|         return; | 
|       } | 
|       renderTitles(vnode.children as VNode[]); | 
|     } | 
|   }); | 
| }; | 
|   | 
| const currentIndex = ref((props.modelValue as number) || 0); | 
| const findTabsIndex = (value: string | number) => { | 
|   let index = titles.value.findIndex((item) => item.paneKey === value); | 
|   if (titles.value.length === 0) { | 
|     // console.warn('[NutUI] <Tabs> 当前未找到 TabPane 组件元素 , 请检查 .'); | 
|   } else if (index === -1) { | 
|     // console.warn('[NutUI] <Tabs> 请检查 v-model 值是否为 paneKey ,如 paneKey 未设置,请采用下标控制 .'); | 
|   } else { | 
|     currentIndex.value = index; | 
|   } | 
| }; | 
| const getScrollX = computed(() => { | 
|   return props.titleScroll && props.direction === 'horizontal'; | 
| }); | 
| const getScrollY = computed(() => { | 
|   return props.titleScroll && props.direction === 'vertical'; | 
| }); | 
| const titleRef = ref([]) as Ref<HTMLElement[]>; | 
| // const scrollLeft = ref(0); | 
| const scrollTop = ref(0); | 
| const scrollWithAnimation = ref(false); | 
| const getRect = (selector: string) => { | 
|   return new Promise((resolve) => { | 
|     Taro.createSelectorQuery() | 
|       .select(selector) | 
|       .boundingClientRect() | 
|       .exec((rect = []) => { | 
|         resolve(rect[0]); | 
|       }); | 
|   }); | 
| }; | 
| const getAllRect = (selector: string) => { | 
|   return new Promise((resolve) => { | 
|     Taro.createSelectorQuery() | 
|       .selectAll(selector) | 
|       .boundingClientRect() | 
|       .exec((rect = []) => resolve(rect[0])); | 
|   }); | 
| }; | 
| const navRectRef = ref(); | 
| const titleRectRef = ref<RectItem[]>([]); | 
| const canShowLabel = ref(false); | 
| const scrollIntoView = () => { | 
|   if (!props.name) return; | 
|   | 
|   raf(() => { | 
|     Promise.all([ | 
|       getRect(`#nut-tabs__titles_${props.name}`), | 
|       getAllRect(`#nut-tabs__titles_${props.name} .nut-tabs__titles-item`), | 
|     ]).then(([navRect, titleRects]: any) => { | 
|       navRectRef.value = navRect; | 
|       titleRectRef.value = titleRects; | 
|   | 
|       if (navRectRef.value) { | 
|         if (props.direction === 'vertical') { | 
|           const titlesTotalHeight = titleRects.reduce( | 
|             (prev: number, curr: RectItem) => prev + curr.height, | 
|             0 | 
|           ); | 
|           if (titlesTotalHeight > navRectRef.value.height) { | 
|             canShowLabel.value = true; | 
|           } else { | 
|             canShowLabel.value = false; | 
|           } | 
|         } else { | 
|           const titlesTotalWidth = titleRects.reduce( | 
|             (prev: number, curr: RectItem) => prev + curr.width, | 
|             0 | 
|           ); | 
|           if (titlesTotalWidth > navRectRef.value.width) { | 
|             canShowLabel.value = true; | 
|           } else { | 
|             canShowLabel.value = false; | 
|           } | 
|         } | 
|       } | 
|   | 
|       const titleRect: RectItem = titleRectRef.value[currentIndex.value]; | 
|   | 
|       let to = 0; | 
|       if (props.direction === 'vertical') { | 
|         const DEFAULT_PADDING = 11; | 
|         const top = titleRects | 
|           .slice(0, currentIndex.value) | 
|           .reduce((prev: number, curr: RectItem) => prev + curr.height + 0, DEFAULT_PADDING); | 
|         to = top - (navRectRef.value.height - titleRect.height) / 2; | 
|       } else { | 
|         const DEFAULT_PADDING = 31; | 
|         const left = titleRects | 
|           .slice(0, currentIndex.value) | 
|           .reduce((prev: number, curr: RectItem) => prev + curr.width + 20, DEFAULT_PADDING); | 
|         to = left - (navRectRef.value?.width ?? 0 - titleRect?.width ?? 0) / 2; | 
|       } | 
|   | 
|       nextTick(() => { | 
|         scrollWithAnimation.value = true; | 
|       }); | 
|   | 
|       scrollDirection(to, props.direction); | 
|     }); | 
|   }); | 
| }; | 
|   | 
| const { | 
|   onScroll, | 
|   scrollDistance: scrollLeft, | 
|   setScrollDistance: setScrollLeft, | 
| } = useScrollDistance({ direction: 'horizontal' }); | 
|   | 
| const scrollDirection = (to: number, direction: 'horizontal' | 'vertical') => { | 
|   let count = 0; | 
|   const from = direction === 'horizontal' ? scrollLeft.value : scrollTop.value; | 
|   const frames = 1; | 
|   | 
|   function animate() { | 
|     if (direction === 'horizontal') { | 
|       scrollLeft.value = Math.max(0.1, to); | 
|     } else { | 
|       scrollTop.value += (to - from) / frames; | 
|     } | 
|   | 
|     if (++count < frames) { | 
|       raf(animate); | 
|     } | 
|   } | 
|   | 
|   if (direction === 'horizontal') { | 
|     setScrollLeft(Math.max(0.1, to)); | 
|   } else { | 
|     animate(); | 
|   } | 
| }; | 
| const init = (vnodes: VNode[] = slots.default?.()) => { | 
|   titles.value = []; | 
|   vnodes = vnodes?.filter((item) => typeof item.children !== 'string'); | 
|   if (vnodes && vnodes.length) { | 
|     renderTitles(vnodes); | 
|   } | 
|   findTabsIndex(props.modelValue); | 
|   setTimeout(() => { | 
|     scrollIntoView(); | 
|   }, 500); | 
| }; | 
|   | 
| watch( | 
|   () => slots.default?.(), | 
|   (vnodes: VNode[]) => { | 
|     init(vnodes); | 
|   } | 
| ); | 
| watch( | 
|   () => props.modelValue, | 
|   (value: string | number) => { | 
|     findTabsIndex(value); | 
|     setTimeout(() => { | 
|       scrollIntoView(); | 
|     }, 500); | 
|   } | 
| ); | 
| onMounted(init); | 
| onActivated(init); | 
| const tabMethods = { | 
|   isBegin: () => { | 
|     return currentIndex.value === 0; | 
|   }, | 
|   isEnd: () => { | 
|     return currentIndex.value === titles.value.length - 1; | 
|   }, | 
|   next: () => { | 
|     currentIndex.value += 1; | 
|     tabMethods.updateValue(titles.value[currentIndex.value]); | 
|   }, | 
|   prev: () => { | 
|     currentIndex.value -= 1; | 
|     tabMethods.updateValue(titles.value[currentIndex.value]); | 
|   }, | 
|   updateValue: (item: Title) => { | 
|     emit('update:modelValue', item.paneKey); | 
|     emit('change', item); | 
|   }, | 
|   tabChange: (item: Title, index: number) => { | 
|     emit('click', item); | 
|     if (item.disabled || currentIndex.value === index) { | 
|       return; | 
|     } | 
|     currentIndex.value = index; | 
|     tabMethods.updateValue(item); | 
|   }, | 
|   setTabItemRef: (el: HTMLElement, index: number) => { | 
|     titleRef.value[index] = el; | 
|   }, | 
| }; | 
| const { tabsContentRef, touchState, touchMethods } = useTabContentTouch( | 
|   props, | 
|   tabMethods, | 
|   Taro, | 
|   useTaroRect | 
| ); | 
| const contentStyle = computed(() => { | 
|   let offsetPercent = currentIndex.value * 100; | 
|   if (touchState.moving) { | 
|     offsetPercent += touchState.offset; | 
|   } | 
|   let style: CSSProperties = { | 
|     transform: | 
|       props.direction === 'horizontal' | 
|         ? `translate3d(-${offsetPercent}%, 0, 0)` | 
|         : `translate3d( 0,-${offsetPercent}%, 0)`, | 
|     transitionDuration: touchState.moving ? undefined : `${props.animatedTime}ms`, | 
|   }; | 
|   if (props.animatedTime === 0) { | 
|     style = {}; | 
|   } | 
|   return style; | 
| }); | 
| const tabsNavStyle = computed(() => { | 
|   return { | 
|     background: props.background, | 
|   }; | 
| }); | 
| const tabsActiveStyle = computed(() => { | 
|   return { | 
|     color: props.type === 'smile' ? props.color : '', | 
|     background: props.type === 'line' ? props.color : '', | 
|   }; | 
| }); | 
| const titleStyle = computed(() => { | 
|   if (!props.titleGutter) return {}; | 
|   const px = pxCheck(props.titleGutter); | 
|   if (props.direction === 'vertical') { | 
|     return { marginTop: px, marginBottom: px }; | 
|   } | 
|   return { marginLeft: px, marginRight: px }; | 
| }); | 
| const refRandomId = Math.random().toString(36).slice(-8); | 
|   | 
| const systemInfo = Taro.getSystemInfoSync(); | 
|   | 
| const tabsStyle = computed(() => { | 
|   if (systemInfo.platform.toLowerCase() === 'ios') { | 
|     return { overflow: 'visible' }; | 
|   } else { | 
|     return {}; | 
|   } | 
| }); | 
| </script> | 
|   | 
| <style lang="scss"> | 
| @import '@/styles/common.scss'; | 
|   | 
| .pro-tabs__titles_wrapper { | 
|   display: flex; | 
|   align-items: center; | 
|   background-color: #fff; | 
|   position: relative; | 
|   z-index: 10; | 
|   box-shadow: none; | 
|   padding: 0 boleGetCssVar('size', 'body-padding-h'); | 
|   margin-bottom: 8px; | 
|   | 
|   &.isTransparent { | 
|     background-color: transparent; | 
|     margin-bottom: 0; | 
|   } | 
| } | 
|   | 
| .nut-tabs.noContent { | 
|   box-shadow: none; | 
|   position: relative; | 
|   z-index: 9; | 
| } | 
|   | 
| .flexTitle { | 
|   box-shadow: none; | 
|   position: relative; | 
|   z-index: 9; | 
|   | 
|   .pro-tabs__titles_wrapper { | 
|     padding: 0; | 
|   | 
|     .nut-tabs__titles.tabs-scrollview .nut-tabs__list .nut-tabs__titles-item { | 
|       flex: 1; | 
|       margin: 0; | 
|       min-width: 0; | 
|       flex-shrink: 0; | 
|     } | 
|   } | 
| } | 
|   | 
| .nut-tabs__titles.tabs-scrollview { | 
|   background-color: transparent; | 
|   flex: 1; | 
|   min-width: 0; | 
|   box-sizing: border-box; | 
|   height: 104rpx; | 
|   @include hiddenScrollBar; | 
|   | 
|   .nut-tabs__list { | 
|     height: 104rpx; | 
|     justify-content: flex-start; | 
|   | 
|     .nut-tabs__titles-item { | 
|       flex: none; | 
|       width: auto; | 
|       margin-right: 78rpx; | 
|       flex-direction: column; | 
|       font-size: 26rpx; | 
|       color: boleGetCssVar('text-color', 'secondary'); | 
|       align-items: center; | 
|       transition: all 0.3s ease; | 
|       min-width: 0; | 
|   | 
|       .nut-tabs__sub-titles-item__text { | 
|         font-size: 20rpx; | 
|         color: boleGetCssVar('text-color', 'regular'); | 
|       } | 
|   | 
|       .nut-tabs__titles-item__text { | 
|         font-size: 28rpx; | 
|         color: boleGetCssVar('text-color', 'regular'); | 
|         line-height: 40rpx; | 
|       } | 
|   | 
|       &.active { | 
|         color: #222; | 
|         font-weight: 700; | 
|         font-size: 30rpx; | 
|   | 
|         .nut-tabs__sub-titles-item__text { | 
|           color: boleGetCssVar('color', 'primary'); | 
|         } | 
|   | 
|         .nut-tabs__titles-item__line { | 
|           width: 100%; | 
|         } | 
|   | 
|         .nut-tabs__titles-item__text { | 
|           color: boleGetCssVar('text-color', 'primary'); | 
|         } | 
|       } | 
|   | 
|       &:last-child { | 
|         margin-right: 0; | 
|       } | 
|   | 
|       &:first-child { | 
|         margin-left: 0 !important; | 
|       } | 
|   | 
|       .nut-tabs__titles-item__line { | 
|         //   width: 100%; | 
|         background: boleGetCssVar('color', 'primary'); | 
|         bottom: 0; | 
|         height: 4rpx; | 
|         max-width: 40rpx; | 
|       } | 
|     } | 
|   } | 
| } | 
|   | 
| .nut-tabs.fullHeight { | 
|   height: 100%; | 
|   display: flex; | 
|   flex-direction: column; | 
|   | 
|   .nut-tabs__content { | 
|     flex: 1; | 
|     min-height: 0; | 
|   | 
|     .nut-tab-pane { | 
|       padding: 0; | 
|       background-color: transparent; | 
|       overflow: hidden; | 
|       display: flex; | 
|       flex-direction: column; | 
|     } | 
|   } | 
| } | 
|   | 
| .nut-tabs.noSmile { | 
|   .nut-tabs__titles.tabs-scrollview { | 
|     height: 86rpx; | 
|   | 
|     .nut-tabs__list { | 
|       height: 86rpx; | 
|     } | 
|   | 
|     .nut-tabs__titles-item__smile { | 
|       display: none !important; | 
|     } | 
|   | 
|     .nut-tabs__titles-item { | 
|       .nut-tabs__titles-item__text { | 
|         padding-top: 8rpx; | 
|       } | 
|   | 
|       &.active { | 
|         .nut-tabs__titles-item__text { | 
|           padding-top: 0; | 
|           font-size: 32rpx; | 
|           transition: all 0.3s ease; | 
|         } | 
|       } | 
|     } | 
|   } | 
| } | 
| </style> |