<template> 
 | 
  <scroll-view 
 | 
    :scroll-y="true" 
 | 
    :refresher-background="'transparent'" 
 | 
    :lowerThreshold="100" 
 | 
    @scrolltolower="lower" 
 | 
    :refresherEnabled="refresherEnabled" 
 | 
    :refresherTriggered="state.triggered" 
 | 
    @refresherrefresh="onRefresherRefresh" 
 | 
    :show-scrollbar="false" 
 | 
    :style="scrollViewStyle" 
 | 
    :enable-back-to-top="true" 
 | 
    :class="[scrollViewClassName]" 
 | 
    @scroll="onScroll" 
 | 
    :scroll-top="scrollDistance" 
 | 
    enhanced 
 | 
    :scroll-with-animation="true" 
 | 
    v-bind="{ ...$attrs }" 
 | 
  > 
 | 
    <slot name="header"></slot> 
 | 
    <LoadingLayout :loading="isLoading" :error="isError" :loadError="() => refetch?.()"> 
 | 
      <NoData v-if="hasNoData" /> 
 | 
      <div 
 | 
        :class="['infinite-list-inner', { noShowMoreText: !showMoreText, hasPaddingTop }]" 
 | 
        v-else 
 | 
      > 
 | 
        <slot name="extra" /> 
 | 
        <slot v-if="$slots.default" /> 
 | 
        <template v-else> 
 | 
          <template v-for="(group, index) in listData.pages" :key="index"> 
 | 
            <template v-for="(item, i) in group.data" :key="i"> 
 | 
              <slot 
 | 
                name="renderItem" 
 | 
                :item="item" 
 | 
                :groupIndex="index" 
 | 
                :itemIndex="i" 
 | 
                :index="findDataIndex(item)" 
 | 
              /> 
 | 
            </template> 
 | 
          </template> 
 | 
        </template> 
 | 
      </div> 
 | 
      <div 
 | 
        v-if="!hasNoData && showMoreText && listData?.pages?.length > 0 && !hasMore" 
 | 
        class="loading-more-tips" 
 | 
      > 
 | 
        {{ noMoreText }} 
 | 
      </div> 
 | 
      <div v-if="isFetching && hasMore && enabledLoadingMore" class="infiniting-tips"> 
 | 
        <Loading class="infiniting-tips-icon"></Loading>数据加载中... 
 | 
      </div> 
 | 
    </LoadingLayout> 
 | 
  </scroll-view> 
 | 
  <div class="back-top-wrapper" @click.stop="backToTop" v-show="oldScrollDistance > 100"> 
 | 
    <img class="back-top-img" :src="IconBackTop" /> 
 | 
    <div class="back-top-text">返回顶部</div> 
 | 
  </div> 
 | 
</template> 
 | 
  
 | 
<script lang="ts" setup generic="T"> 
 | 
import { VNode, CSSProperties } from 'vue'; 
 | 
import NoData from '../NoData/NoData.vue'; 
 | 
import LoadingLayout from '../Layout/LoadingLayout.vue'; 
 | 
import { FetchNextPageOptions } from '@tanstack/vue-query'; 
 | 
import { Loading } from '@nutui/icons-vue-taro'; 
 | 
import { useScrollDistance } from 'senin-mini/hooks'; 
 | 
import IconBackTop from '@/assets/components/icon-back-top.png'; 
 | 
  
 | 
defineOptions({ 
 | 
  name: 'InfiniteLoading', 
 | 
}); 
 | 
  
 | 
type Page = { 
 | 
  data?: T[]; 
 | 
  pageModel?: { 
 | 
    rows?: number; 
 | 
    page?: number; 
 | 
    totalCount?: number; 
 | 
    totalPage?: number; 
 | 
  }; 
 | 
  [key: string]: any; 
 | 
}; 
 | 
  
 | 
type TData = { 
 | 
  pages: Page[]; 
 | 
}; 
 | 
  
 | 
type Props = { 
 | 
  // list?: TData; 
 | 
  listData?: TData; 
 | 
  flattenListData?: T[]; 
 | 
  renderItem?: (item: T, index: number) => VNode; 
 | 
  refresherEnabled?: boolean; 
 | 
  hasMore?: boolean; 
 | 
  isLoading?: boolean; 
 | 
  isError?: boolean; 
 | 
  isFetching?: boolean; 
 | 
  isFetchingNextPage?: boolean; 
 | 
  refetch?: (options?: any) => Promise<any>; 
 | 
  scrollViewStyle?: string | CSSProperties; 
 | 
  scrollViewClassName?: string; 
 | 
  fetchNextPage?: (options?: FetchNextPageOptions) => Promise<any>; 
 | 
  showMoreText?: boolean; 
 | 
  hasPaddingTop?: boolean; 
 | 
  noMoreText?: string; 
 | 
  enabledLoadingMore?: boolean; 
 | 
  customNoData?: boolean; 
 | 
}; 
 | 
  
 | 
const props = withDefaults(defineProps<Props>(), { 
 | 
  renderItem: () => () => null, 
 | 
  refresherEnabled: true, 
 | 
  showMoreText: true, 
 | 
  hasPaddingTop: false, 
 | 
  noMoreText: '没有更多内容了~', 
 | 
  enabledLoadingMore: true, 
 | 
  customNoData: undefined, 
 | 
}); 
 | 
  
 | 
const emit = defineEmits<{ 
 | 
  (e: 'refresh', done: () => any): void; 
 | 
  (e: 'loadMore'): void; 
 | 
}>(); 
 | 
  
 | 
const hasNoData = computed(() => { 
 | 
  if (props.customNoData !== undefined) return props.customNoData; 
 | 
  if (props.listData?.pages?.length) { 
 | 
    return props.listData?.pages[0].data.length === 0; 
 | 
  } 
 | 
  return true; 
 | 
}); 
 | 
  
 | 
const state = reactive({ 
 | 
  triggered: false, 
 | 
}); 
 | 
  
 | 
function lower() { 
 | 
  if (props.hasMore && props.enabledLoadingMore) { 
 | 
    props.fetchNextPage?.(); 
 | 
    emit('loadMore'); 
 | 
  } 
 | 
} 
 | 
  
 | 
// eslint-disable-next-line no-unused-vars 
 | 
async function onRefresherRefresh() { 
 | 
  try { 
 | 
    // 正处于刷新状态 
 | 
    if (state.triggered) return; 
 | 
    state.triggered = true; 
 | 
  
 | 
    await props.refetch?.(); 
 | 
  
 | 
    state.triggered = false; 
 | 
  } catch (error) {} 
 | 
} 
 | 
  
 | 
const { onScroll, scrollDistance, oldScrollDistance, setScrollDistance } = useScrollDistance(); 
 | 
  
 | 
function backToTop() { 
 | 
  setScrollDistance(0); 
 | 
} 
 | 
  
 | 
const scrollToBottom = (dis = 300) => { 
 | 
  setScrollDistance(scrollDistance.value + dis); 
 | 
}; 
 | 
  
 | 
function findDataIndex(item: T) { 
 | 
  return props.flattenListData?.findIndex((data) => data === item); 
 | 
} 
 | 
  
 | 
defineExpose({ backToTop, scrollToBottom }); 
 | 
</script> 
 | 
  
 | 
<style lang="scss"> 
 | 
@import '@/styles/common.scss'; 
 | 
  
 | 
.loading-more-tips { 
 | 
  color: boleGetCssVar('text-color', 'primary'); 
 | 
  padding: 18px 10px; 
 | 
  width: auto; 
 | 
  font-size: 24px; 
 | 
  text-align: center; 
 | 
} 
 | 
  
 | 
.infinite-list-inner { 
 | 
  // padding: 30px 30px 0; 
 | 
  
 | 
  &.hasPaddingTop { 
 | 
    padding-top: 20px; 
 | 
  } 
 | 
  
 | 
  &.noShowMoreText { 
 | 
    padding-bottom: 30px; 
 | 
  } 
 | 
} 
 | 
  
 | 
.infiniting-tips { 
 | 
  color: boleGetCssVar('text-color', 'primary'); 
 | 
  padding: 18px 10px; 
 | 
  width: auto; 
 | 
  font-size: 24px; 
 | 
  display: flex; 
 | 
  align-items: center; 
 | 
  justify-content: center; 
 | 
  
 | 
  .infiniting-tips-icon { 
 | 
    margin-right: 10px; 
 | 
  } 
 | 
} 
 | 
  
 | 
.back-top-wrapper { 
 | 
  width: 92px; 
 | 
  height: 92px; 
 | 
  background: #ffffff; 
 | 
  box-shadow: 0px 0px 28px 0px rgba(0, 0, 0, 0.18); 
 | 
  position: fixed; 
 | 
  border-radius: 50%; 
 | 
  right: boleGetCssVar('size', 'body-padding-h'); 
 | 
  bottom: 390px; 
 | 
  
 | 
  .back-top-img { 
 | 
    width: 44px; 
 | 
    height: 44px; 
 | 
    margin: 12px auto 2px; 
 | 
  } 
 | 
  
 | 
  .back-top-text { 
 | 
    font-weight: 400; 
 | 
    font-size: 16px; 
 | 
    color: boleGetCssVar('text-color', 'regular'); 
 | 
    line-height: 22px; 
 | 
    text-align: center; 
 | 
  } 
 | 
} 
 | 
</style> 
 |