index.vue 7.89 KB
<template>
  <view class="card-wrapper">
    <scroll-view
      class="scroll"
      scroll-y
      :lower-threshold="lowerThreshold"
      @scrolltolower="onLoadMore"
      @scroll="onScroll"
    >
      <slot name="header"></slot>

      <view v-if="hasError && !loading" class="error">
        <text>加载失败</text>
        <button class="retry" @click="reload">重试</button>
      </view>

      <Empty v-if="items.length === 0 && !loading && !hasError" text="暂无数据" />

      <view
        v-for="(item, idx) in items"
        :key="getRenderKey(item, idx)"
        :class="['card-item', { 'select-item': selectable && showCheck }]"
        @click="toggleSelect(item, idx)"
        @tap="toggleSelect(item, idx)"
      >
        <view v-if="selectable && showCheck" class="check">
          <view class="dot" :class="{ checked: isSelected(item, idx) }">
            <uni-icons v-if="isSelected(item, idx)" type="checkmarkempty" color="#fff" size="14" />
          </view>
        </view>
        <!-- 父组件传入的卡片内容 -->
        <slot :item="item" :selected="isSelected(item, idx)"></slot>
      </view>

      <!-- 当列表为空时不显示“没有更多数据了”,但加载时仍显示 loading -->
      <uni-load-more v-if="items.length > 0 || loading" :status="loadMoreStatus" />
    </scroll-view>
  </view>
  
</template>

<script>
export default {
  name: 'CardList',
  components: { Empty: () => import('../empty/index.vue') },
  props: {
    // 请求方法,签名:({ pageIndex, pageSize, query, extra }) => Promise<{ records|list, totalCount }>
    fetchFn: { type: Function, default: null },
    // 筛选条件对象(会被监听,变化后自动刷新)
    query: { type: Object, default: () => ({}) },
    // 其他筛选值或附加参数(会被监听,变化后自动刷新)
    extra: { type: Object, default: () => ({}) },
    pageSize: { type: Number, default: 10 },
    immediate: { type: Boolean, default: true },
    enableRefresh: { type: Boolean, default: true },
    enableLoadMore: { type: Boolean, default: true },
    // 支持多选
    selectable: { type: Boolean, default: false },
    // 是否显示左侧选择圆点
    showCheck: { type: Boolean, default: true },
    rowKey: { type: String, default: 'id' },
    // v-model:selectedKeys
    selectedKeys: { type: Array, default: () => [] }
  },
  data() {
    return {
      items: [],
      pageIndex: 1,
      totalCount: 0,
      loading: false,
      finished: false,
      hasError: false,
      refresherTriggered: false,
      lowerThreshold: 120,
      scrollCooldownUntil: 0,
      loadMoreCooldownUntil: 0
    }
  },
  computed: {
    loadMoreStatus() {
      if (this.loading) return 'loading'
      if (this.finished) return 'noMore'
      return 'more'
    }
  },
  watch: {
    query: {
      deep: true,
      handler() {
        this.reload()
      }
    },
    extra: {
      deep: true,
      handler() {
        this.reload()
      }
    }
  },
  methods: {
    getKey(item, idx) {
      const k = this.rowKey && item && item[this.rowKey] != null ? item[this.rowKey] : idx
      return k
    },
    getRenderKey(item, idx) {
      const base = this.getKey(item, idx)
      return String(base) + '-' + String(idx)
    },
    isSelected(item, idx) {
      const key = this.getKey(item, idx)
      return this.selectedKeys.includes(key)
    },
    toggleSelect(item, idx) {
      if (!this.selectable) return
      const key = this.getKey(item, idx)
      const next = this.selectedKeys.slice(0)
      const i = next.indexOf(key)
      if (i >= 0) next.splice(i, 1)
      else next.push(key)
      this.$emit('update:selectedKeys', next)
    },
    onScroll(e) {
      if (!this.enableLoadMore || this.loading || this.finished) return
      // 刷新后的短暂冷却期内不触发,避免误触发加载更多
      if (this.scrollCooldownUntil && Date.now() < this.scrollCooldownUntil) return
      const d = (e && e.detail) ? e.detail : {}
      const scrollTop = Number(d.scrollTop || 0)
      const scrollHeight = Number(d.scrollHeight || (e && e.target && e.target.scrollHeight) || 0)
      const clientHeight = Number(d.clientHeight || (e && e.target && e.target.clientHeight) || 0)
      const threshold = Number(this.lowerThreshold || 120)
      const canScroll = scrollHeight > clientHeight
      const nearBottom = scrollTop + clientHeight + threshold >= scrollHeight
      if (canScroll && nearBottom && scrollTop > 0) {
        this.onLoadMore()
      }
    },
    async fetch() {
      if (!this.fetchFn) return
      this.loading = true
      this.hasError = false
      try {
        // 捕获请求时的页码,避免并发返回顺序导致错误的拼接/覆盖
        const reqPage = this.pageIndex
        const res = await this.fetchFn({
          pageIndex: reqPage,
          pageSize: this.pageSize,
          query: this.query,
          extra: this.extra
        })
        const list = (res && (res.records || res.list || res.rows || res.items || res.datas))
          ? (res.records || res.list || res.rows || res.items || res.datas)
          : [];
        const totalCount = (res && res.totalCount != null) ? res.totalCount : 0;
        // 根据请求发起时的页码判断赋值/拼接,避免错误追加旧数据
        if (reqPage === 1) this.items = list
        else this.items = this.items.concat(list)
        this.totalCount = totalCount
        this.finished = (res && typeof res.hasNext === 'boolean')
          ? !res.hasNext
          : (this.items.length >= totalCount || list.length < this.pageSize)
        this.$emit('loaded', { items: this.items, totalCount: this.totalCount, pageIndex: this.pageIndex })
      } catch (e) {
        console.error('[CardList] fetch error', e)
        this.hasError = true
        this.$emit('error', e)
      } finally {
        this.loading = false
        this.refresherTriggered = false
      }
    },
    reload() {
      // 切换条件(如 tab/分厂)与刷新时,重置数据,不与之前请求数据拼接
      this.pageIndex = 1
      this.items = []
      this.totalCount = 0
      this.finished = false
      // 短暂禁止触发加载更多,避免滚动到底部立即追加
      this.loadMoreCooldownUntil = Date.now() + 800
      this.fetch()
    },
    onRefresh() {
      if (!this.enableRefresh) return
      this.refresherTriggered = true
      // 设置滚动触发冷却期,避免刷新结束立刻触发加载更多
      this.scrollCooldownUntil = Date.now() + 800
      // 同步禁止 scrolltolower 的加载更多
      this.loadMoreCooldownUntil = Date.now() + 800
      this.reload()
      this.$emit('refresh')
    },
    onLoadMore() {
      if (!this.enableLoadMore || this.loading || this.finished) return
      // 刷新/重载后的短暂冷却期,避免自动触底立即追加
      if (this.loadMoreCooldownUntil && Date.now() < this.loadMoreCooldownUntil) return
      this.pageIndex += 1
      this.fetch()
    },
    clear() {
      this.items = []
      this.totalCount = 0
      this.pageIndex = 1
      this.finished = false
    }
  },
  mounted() {
    if (this.immediate) this.fetch()
  }
}
</script>

<style lang="scss" scoped>
@import '../../static/scss/global.scss';
.card-wrapper {
  display: flex;
  flex-direction: column;
  height: 100%;
}
.scroll {
  flex: 1;
  height: 100%;
}
.card-item {
  background: #fff;
  padding: 32rpx;
  position: relative;
  margin-bottom: 20rpx;
  &.select-item {
    left: 96rpx;
  }
}
.check {
  position: absolute;
  left: -60rpx;
  top: 50%;
  transform: translateY(-50%);
}
.dot {
  width: 32rpx;
  height: 32rpx;
  border-radius: 50%;
  border: 2rpx solid #dcdcdc;
  display: flex;
  align-items: center;
  justify-content: center;
}
.checked {
  background: $theme-primary;
  border-color: $theme-primary;
}
.error {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 20rpx;
  padding: 60rpx 0;
  color: #f56c6c;
}
.retry {
  border: 1rpx solid #f56c6c;
  color: #f56c6c;
  background: #fff;
}
</style>