index.vue
7.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
<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>