添加列表刷新组件

This commit is contained in:
icssoa
2025-08-11 09:54:23 +08:00
parent 30147ed73a
commit 4f2d437ef8
13 changed files with 482 additions and 108 deletions

View File

@@ -48,4 +48,9 @@ export default {
margin-top: 0; margin-top: 0;
} }
} }
.uni-toast {
border-radius: 32rpx;
background-color: rgba(0, 0, 0, 0.8) !important;
}
</style> </style>

View File

@@ -1,5 +1,6 @@
export * from "./refs"; export * from "./refs";
export * from "./page"; export * from "./page";
export * from "./pager";
export * from "./long-press"; export * from "./long-press";
export * from "./cache"; export * from "./cache";
export * from "./parent"; export * from "./parent";

94
cool/hooks/pager.ts Normal file
View File

@@ -0,0 +1,94 @@
import { ref } from "vue";
import { assign, parse } from "../utils";
import type { Response } from "../service";
type Pagination = {
page: number;
size: number;
total: number;
};
type PagerResponse = {
list: UTSJSONObject[];
pagination: Pagination;
};
type PagerCallback = (params: UTSJSONObject) => Promise<UTSJSONObject>;
export class Pager {
public page = 1;
public size = 20;
public total = 0;
public list = ref<UTSJSONObject[]>([]);
public loading = ref(false);
public refreshing = ref(false);
public finished = ref(false);
public params = {} as UTSJSONObject;
public cb: PagerCallback | null = null;
constructor(cb: PagerCallback) {
this.cb = cb;
}
done() {
this.loading.value = false;
}
clear() {
this.list.value = [];
this.finished.value = false;
this.refreshing.value = false;
this.loading.value = false;
}
public refresh = async (params: UTSJSONObject): Promise<UTSJSONObject> => {
return new Promise((resolve, reject) => {
assign(this.params, params);
const data = {
page: this.page,
size: this.size,
...this.params
};
this.loading.value = true;
this.cb!(data)
.then((res) => {
const { list, pagination } = parse<PagerResponse>(res)!;
this.page = pagination.page;
this.size = pagination.size;
this.total = pagination.total;
this.finished.value = this.list.value.length >= this.total;
if (data.page == 1) {
this.list.value = list;
} else {
this.list.value.push(...list);
}
resolve(res);
})
.catch((err) => {
reject(err);
})
.finally(() => {
this.loading.value = false;
});
});
};
public loadMore = () => {
if (this.loading.value || this.finished.value) {
return;
}
this.page += 1;
this.refresh({});
};
}
export function usePager(cb: PagerCallback) {
return new Pager(cb);
}

View File

@@ -57,7 +57,7 @@ export class User {
} }
}) })
.catch(() => { .catch(() => {
this.logout(); // this.logout();
}); });
} }
} }

View File

@@ -1,13 +1,15 @@
<template> <template>
<view class="w-full p-3 bg-white rounded-xl mb-3 dark:bg-surface-800"> <view class="p-3 pb-0">
<cl-image :src="item.image" mode="widthFix" width="100%" height="250rpx"></cl-image> <view class="w-full p-3 bg-white rounded-xl dark:bg-surface-800">
<cl-text :pt="{ className: 'mt-2' }">{{ item.title }}</cl-text> <cl-image :src="item?.image" mode="aspectFill" width="100%" height="280rpx"></cl-image>
<cl-text :pt="{ className: 'mt-2' }">{{ item?.title }}</cl-text>
</view>
</view> </view>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, type PropType } from "vue"; import { computed } from "vue";
import { useParent } from "@/cool"; import { parse } from "@/cool";
defineOptions({ defineOptions({
name: "goods-item" name: "goods-item"
@@ -15,15 +17,16 @@ defineOptions({
type GoodsItem = { type GoodsItem = {
id: number; id: number;
likeCount: number;
title: string; title: string;
image: string; image: string;
}; };
const props = defineProps({ const props = defineProps({
item: { value: {
type: Object as PropType<GoodsItem>, type: Object,
default: () => ({}) default: () => ({})
} }
}); });
const item = computed(() => parse<GoodsItem>(props.value));
</script> </script>

View File

@@ -1,77 +1,117 @@
<template> <template>
<cl-page> <cl-page>
<view class="p-3"> <cl-list-view
<cl-list-view :data="data" :virtual="false"> ref="listViewRef"
:data="data"
:virtual="false"
:pt="{
refresher: {
className: 'pt-3'
}
}"
:refresher-enabled="true"
@pull="onPull"
@bottom="loadMore"
>
<template #item="{ value }"> <template #item="{ value }">
<goods-item :item="value"></goods-item> <goods-item :value="value"></goods-item>
</template>
<template #bottom>
<view class="py-3">
<cl-loadmore :loading="loading" v-if="list.length > 0"></cl-loadmore>
</view>
</template> </template>
</cl-list-view> </cl-list-view>
</view>
</cl-page> </cl-page>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useListView } from "@/uni_modules/cool-ui"; import { useListView, useUi, type ClListViewItem } from "@/uni_modules/cool-ui";
import { ref } from "vue"; import { computed, ref } from "vue";
import { random } from "@/cool"; import { usePager } from "@/cool";
import GoodsItem from "../components/goods-item.uvue"; import GoodsItem from "../components/goods-item.uvue";
const ui = useUi();
const listViewRef = ref<ClListViewComponentPublicInstance | null>(null);
let id = 0; let id = 0;
const data = useListView([ const { refresh, list, loading, loadMore } = usePager((params) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
list: [
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "春日樱花盛开时节,粉色花瓣如诗如画般飘洒", title: "春日樱花盛开时节,粉色花瓣如诗如画般飘洒",
image: "https://unix.cool-js.com/images/demo/1.jpg" image: "https://unix.cool-js.com/images/demo/1.jpg"
}, },
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "夕阳西下的海滩边,金色阳光温柔地洒在波光粼粼的海面上,构成令人心旷神怡的日落美景", title: "夕阳西下的海滩边,金色阳光温柔地洒在波光粼粼的海面上,构成令人心旷神怡的日落美景",
image: "https://unix.cool-js.com/images/demo/2.jpg" image: "https://unix.cool-js.com/images/demo/2.jpg"
}, },
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "寒冬腊月时分,洁白雪花纷纷扬扬地覆盖着整个世界,感受冬日的宁静与美好", title: "寒冬腊月时分,洁白雪花纷纷扬扬地覆盖着整个世界,感受冬日的宁静与美好",
image: "https://unix.cool-js.com/images/demo/3.jpg" image: "https://unix.cool-js.com/images/demo/3.jpg"
}, },
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "都市夜景霓虹闪烁,五彩斑斓光芒照亮城市营造梦幻般景象", title: "都市夜景霓虹闪烁,五彩斑斓光芒照亮城市营造梦幻般景象",
image: "https://unix.cool-js.com/images/demo/5.jpg" image: "https://unix.cool-js.com/images/demo/5.jpg"
}, },
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "云雾缭绕的山间风光如诗如画让人心旷神怡,微风轻抚树梢带来阵阵清香,鸟儿在林间自由歌唱", title: "云雾缭绕的山间风光如诗如画让人心旷神怡,微风轻抚树梢带来阵阵清香,鸟儿在林间自由歌唱",
image: "https://unix.cool-js.com/images/demo/6.jpg" image: "https://unix.cool-js.com/images/demo/6.jpg"
}, },
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "古老建筑与现代摩天大楼交相辉映,传统与现代完美融合创造独特城市景观", title: "古老建筑与现代摩天大楼交相辉映,传统与现代完美融合创造独特城市景观",
image: "https://unix.cool-js.com/images/demo/7.jpg" image: "https://unix.cool-js.com/images/demo/7.jpg"
}, },
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "广袤田野绿意盎然风光无限,金黄麦浪在微风中轻柔摇曳,农家炊烟袅袅升起", title: "广袤田野绿意盎然风光无限,金黄麦浪在微风中轻柔摇曳,农家炊烟袅袅升起",
image: "https://unix.cool-js.com/images/demo/8.jpg" image: "https://unix.cool-js.com/images/demo/8.jpg"
}, },
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "璀璨星空下银河横跨天际,繁星闪烁神秘光芒营造浪漫夜空美景", title: "璀璨星空下银河横跨天际,繁星闪烁神秘光芒营造浪漫夜空美景",
image: "https://unix.cool-js.com/images/demo/9.jpg" image: "https://unix.cool-js.com/images/demo/9.jpg"
}, },
{ {
id: id++, id: id++,
likeCount: random(100, 1000),
title: "雄伟瀑布从高耸悬崖飞流直下激起千层浪花,彩虹在水雾中若隐若现如梦如幻", title: "雄伟瀑布从高耸悬崖飞流直下激起千层浪花,彩虹在水雾中若隐若现如梦如幻",
image: "https://unix.cool-js.com/images/demo/10.jpg" image: "https://unix.cool-js.com/images/demo/10.jpg"
} }
]); ],
pagination: {
page: params["page"],
size: params["size"],
total: 100
}
});
ui.hideLoading();
}, 1000);
});
});
const data = computed<ClListViewItem[]>(() => {
return useListView(list.value);
});
async function onPull() {
await refresh({ page: 1 });
listViewRef.value!.stopRefresh();
}
onReady(() => {
ui.showLoading("加载中");
refresh({});
});
</script> </script>

View File

@@ -263,7 +263,7 @@ const data = computed<Item[]>(() => {
label: t("列表刷新"), label: t("列表刷新"),
icon: "refresh-line", icon: "refresh-line",
path: "/pages/demo/data/list-view-refresh", path: "/pages/demo/data/list-view-refresh",
disabled: true disabled: false
}, },
{ {
label: t("瀑布流"), label: t("瀑布流"),

18
types/uni-app.d.ts vendored
View File

@@ -179,6 +179,24 @@ declare interface UniScrollEvent extends UniEvent {
}; };
} }
declare interface UniScrollToUpperEvent extends UniEvent {
detail: {
direction: string;
};
}
declare interface UniScrollToLowerEvent extends UniEvent {
detail: {
direction: string;
};
}
declare interface UniRefresherEvent extends UniEvent {
detail: {
dy: number;
};
}
declare interface UniSwiperChangeEvent extends UniEvent { declare interface UniSwiperChangeEvent extends UniEvent {
detail: { detail: {
current: number; current: number;

View File

@@ -1,5 +1,5 @@
<template> <template>
<view class="cl-list-view"> <view class="cl-list-view" :class="[pt.className]">
<cl-index-bar <cl-index-bar
v-if="hasIndex" v-if="hasIndex"
v-model="activeIndex" v-model="activeIndex"
@@ -13,21 +13,61 @@
<scroll-view <scroll-view
class="cl-list-view__scroller" class="cl-list-view__scroller"
:class="[pt.scroller?.className]"
:scroll-top="targetScrollTop" :scroll-top="targetScrollTop"
:show-scrollbar="false" :scroll-into-view="scrollIntoView"
:scroll-with-animation="scrollWithAnimation"
:show-scrollbar="showScrollbar"
:refresher-triggered="refreshTriggered"
:refresher-enabled="refresherEnabled"
:refresher-threshold="refresherThreshold"
:refresher-background="refresherBackground"
refresher-default-style="none"
direction="vertical" direction="vertical"
@scrolltoupper="onScrollToUpper"
@scrolltolower="onScrollToLower"
@scroll="onScroll" @scroll="onScroll"
@scrollend="onScrollEnd"
@refresherpulling="onRefresherPulling"
@refresherrefresh="onRefresherRefresh"
@refresherrestore="onRefresherRestore"
@refresherabort="onRefresherAbort"
> >
<view <view
class="cl-list-view__virtual-list" slot="refresher"
:style="{ height: virtual ? listHeight + 'px' : 'auto' }" class="cl-list-view__refresher"
:class="[
{
'is-pulling': refresherStatus === 'pulling',
'is-refreshing': refresherStatus === 'refreshing'
},
pt.refresher?.className
]"
:style="{
height: refresherThreshold + 'px'
}"
> >
<view class="cl-list-view__spacer-top" :style="{ height: spacerTopHeight + 'px' }"> <cl-loading
v-if="refresherStatus === 'refreshing'"
:size="28"
:pt="{
className: 'mr-2'
}"
></cl-loading>
<cl-text> {{ refresherText }} </cl-text>
</view>
<view
class="cl-list-view__virtual-list"
:class="[pt.list?.className]"
:style="listStyle"
>
<view class="cl-list-view__spacer-top" :style="spacerTopStyle">
<slot name="top"></slot> <slot name="top"></slot>
</view> </view>
<view <view
v-for="item in visibleItems" v-for="(item, index) in visibleItems"
:key="item.key" :key="item.key"
class="cl-list-view__virtual-item" class="cl-list-view__virtual-item"
> >
@@ -62,7 +102,13 @@
}" }"
@tap="onItemTap(item)" @tap="onItemTap(item)"
> >
<slot name="item" :item="item" :data="item.data" :value="item.data.value"> <slot
name="item"
:item="item"
:data="item.data"
:value="item.data.value"
:index="index"
>
<view class="cl-list-view__item-inner"> <view class="cl-list-view__item-inner">
<cl-text> {{ item.data.label }} </cl-text> <cl-text> {{ item.data.label }} </cl-text>
</view> </view>
@@ -70,10 +116,7 @@
</view> </view>
</view> </view>
<view <view class="cl-list-view__spacer-bottom" :style="spacerBottomStyle">
class="cl-list-view__spacer-bottom"
:style="{ height: spacerBottomHeight + 'px' }"
>
<slot name="bottom"></slot> <slot name="bottom"></slot>
</view> </view>
</view> </view>
@@ -134,7 +177,7 @@ defineSlots<{
// 分组头部插槽 // 分组头部插槽
header(props: { index: string }): any; header(props: { index: string }): any;
// 列表项插槽 // 列表项插槽
item(props: { data: ClListViewItem; item: VirtualItem; value: any | null }): any; item(props: { data: ClListViewItem; item: VirtualItem; value: any | null; index: number }): any;
// 底部插槽 // 底部插槽
bottom(): any; bottom(): any;
// 索引插槽 // 索引插槽
@@ -181,18 +224,80 @@ const props = defineProps({
virtual: { virtual: {
type: Boolean, type: Boolean,
default: true default: true
},
// 滚动到指定位置
scrollIntoView: {
type: String,
default: ""
},
// 是否启用滚动动画
scrollWithAnimation: {
type: Boolean,
default: false
},
// 是否显示滚动条
showScrollbar: {
type: Boolean,
default: false
},
// 是否启用下拉刷新
refresherEnabled: {
type: Boolean,
default: false
},
// 下拉刷新触发距离,相当于下拉内容高度
refresherThreshold: {
type: Number,
default: 45
},
// 下拉刷新区域背景色
refresherBackground: {
type: String,
default: "transparent"
},
// 下拉刷新默认文案
refresherDefaultText: {
type: String,
default: "下拉刷新"
},
// 释放刷新文案
refresherPullingText: {
type: String,
default: "释放立即刷新"
},
// 正在刷新文案
refresherRefreshingText: {
type: String,
default: "加载中"
} }
}); });
const emit = defineEmits(["item-tap"]); const emit = defineEmits([
"item-tap",
"refresher-pulling",
"refresher-refresh",
"refresher-restore",
"refresher-abort",
"scrolltoupper",
"scrolltolower",
"scroll",
"scrollend",
"pull",
"top",
"bottom"
]);
// 获取当前组件实例,用于后续DOM操作 // 获取当前组件实例,用于后续DOM操作
const { proxy } = getCurrentInstance()!; const { proxy } = getCurrentInstance()!;
// 透传样式配置类型
type PassThrough = { type PassThrough = {
className?: string; className?: string;
item?: PassThroughProps; item?: PassThroughProps;
list?: PassThroughProps;
indexBar?: PassThroughProps; indexBar?: PassThroughProps;
scroller?: PassThroughProps;
refresher?: PassThroughProps;
}; };
// 解析透传样式配置 // 解析透传样式配置
@@ -371,10 +476,6 @@ const spacerTopHeight = computed<number>(() => {
if (isEmpty(visibleItems.value)) { if (isEmpty(visibleItems.value)) {
return 0; return 0;
} }
// 如果未启用虚拟列表,返回0
if (!props.virtual) {
return 0;
}
// 返回第一个可见项目的顶部位置 // 返回第一个可见项目的顶部位置
return visibleItems.value[0].top; return visibleItems.value[0].top;
}); });
@@ -385,16 +486,33 @@ const spacerBottomHeight = computed<number>(() => {
if (isEmpty(visibleItems.value)) { if (isEmpty(visibleItems.value)) {
return 0; return 0;
} }
// 如果未启用虚拟列表,返回0
if (!props.virtual) {
return 0;
}
// 获取最后一个可见项目 // 获取最后一个可见项目
const lastItem = visibleItems.value[visibleItems.value.length - 1]; const lastItem = visibleItems.value[visibleItems.value.length - 1];
// 计算下方占位高度 // 计算下方占位高度
return listHeight.value - (lastItem.top + lastItem.height); return listHeight.value - (lastItem.top + lastItem.height);
}); });
// 列表样式
const listStyle = computed(() => {
return {
height: props.virtual ? `${listHeight.value}px` : "auto"
};
});
// 上方占位容器样式
const spacerTopStyle = computed(() => {
return {
height: props.virtual ? `${spacerTopHeight.value}px` : "auto"
};
});
// 下方占位容器样式
const spacerBottomStyle = computed(() => {
return {
height: props.virtual ? `${spacerBottomHeight.value}px` : "auto"
};
});
// 存储每个分组头部距离顶部的位置数组 // 存储每个分组头部距离顶部的位置数组
const tops = ref<number[]>([]); const tops = ref<number[]>([]);
@@ -418,6 +536,42 @@ function getTops() {
tops.value = arr; tops.value = arr;
} }
// 下拉刷新触发标志
const refreshTriggered = ref(false);
// 下拉刷新相关状态
const refresherStatus = ref<"default" | "pulling" | "refreshing">("default");
// 下拉刷新文案
const refresherText = computed(() => {
switch (refresherStatus.value) {
case "pulling":
return props.refresherPullingText;
case "refreshing":
return props.refresherRefreshingText;
default:
return props.refresherDefaultText;
}
});
// 停止下拉刷新
function stopRefresh() {
refreshTriggered.value = false;
refresherStatus.value = "default";
}
// 滚动到顶部事件处理函数
function onScrollToUpper(e: UniScrollToUpperEvent) {
emit("scrolltoupper", e);
emit("top");
}
// 滚动到底部事件处理函数
function onScrollToLower(e: UniScrollToLowerEvent) {
emit("scrolltolower", e);
emit("bottom");
}
// 滚动锁定标志,用于防止滚动时触发不必要的计算 // 滚动锁定标志,用于防止滚动时触发不必要的计算
let scrollLock = false; let scrollLock = false;
@@ -435,6 +589,13 @@ function onScroll(e: UniScrollEvent) {
activeIndex.value = index; activeIndex.value = index;
} }
}); });
emit("scroll", e);
}
// 滚动结束事件处理函数
function onScrollEnd(e: UniScrollEvent) {
emit("scrollend", e);
} }
// 行点击事件处理函数 // 行点击事件处理函数
@@ -456,6 +617,31 @@ function onIndexChange(index: number) {
}, 300); }, 300);
} }
// 下拉刷新事件处理函数
function onRefresherPulling(e: UniRefresherEvent) {
if (e.detail.dy > props.refresherThreshold * 1.5) {
refresherStatus.value = "pulling";
}
emit("refresher-pulling", e);
}
function onRefresherRefresh(e: UniRefresherEvent) {
refresherStatus.value = "refreshing";
refreshTriggered.value = true;
emit("refresher-refresh", e);
emit("pull", e);
}
function onRefresherRestore(e: UniRefresherEvent) {
refresherStatus.value = "default";
emit("refresher-restore", e);
}
function onRefresherAbort(e: UniRefresherEvent) {
refresherStatus.value = "default";
emit("refresher-abort", e);
}
// 获取滚动容器的高度 // 获取滚动容器的高度
function getScrollerHeight() { function getScrollerHeight() {
setTimeout(() => { setTimeout(() => {
@@ -493,7 +679,8 @@ onMounted(() => {
}); });
defineExpose({ defineExpose({
data data,
stopRefresh
}); });
</script> </script>
@@ -516,10 +703,7 @@ defineExpose({
&__index { &__index {
@apply flex flex-row items-center bg-white; @apply flex flex-row items-center bg-white;
@apply absolute top-0 left-0 w-full; @apply absolute top-0 left-0 w-full px-[20rpx] z-20;
top: 0px;
padding: 0 20rpx;
z-index: 11;
&.is-dark { &.is-dark {
@apply bg-surface-600 border-none; @apply bg-surface-600 border-none;
@@ -531,10 +715,7 @@ defineExpose({
} }
&__header { &__header {
@apply flex flex-row items-center; @apply flex flex-row items-center relative px-[20rpx] z-10;
padding: 0 20rpx;
position: relative;
z-index: 10;
} }
&__item { &__item {
@@ -542,5 +723,9 @@ defineExpose({
@apply flex flex-row items-center px-[20rpx] h-full; @apply flex flex-row items-center px-[20rpx] h-full;
} }
} }
&__refresher {
@apply flex flex-row items-center justify-center w-full h-full;
}
} }
</style> </style>

View File

@@ -16,4 +16,12 @@ export type ClListViewProps = {
bottomHeight?: number; bottomHeight?: number;
bufferSize?: number; bufferSize?: number;
virtual?: boolean; virtual?: boolean;
// 下拉刷新相关属性
refresherEnabled?: boolean;
refresherThreshold?: number;
refresherTriggered?: boolean;
refresherBackground?: string;
refresherDefaultText?: string;
refresherPullingText?: string;
refresherRefreshingText?: string;
}; };

View File

@@ -2,7 +2,7 @@ import { computed, ref, type ComputedRef } from "vue";
import type { ClFormRule, ClFormValidateError } from "../types"; import type { ClFormRule, ClFormValidateError } from "../types";
import { useParent } from "@/cool"; import { useParent } from "@/cool";
class UseForm { class Form {
public formRef = ref<ClFormComponentPublicInstance | null>(null); public formRef = ref<ClFormComponentPublicInstance | null>(null);
public disabled: ComputedRef<boolean>; public disabled: ComputedRef<boolean>;
@@ -82,5 +82,5 @@ class UseForm {
} }
export function useForm() { export function useForm() {
return new UseForm(); return new Form();
} }

View File

@@ -79,6 +79,25 @@ class Ui {
instance.showToast(options); instance.showToast(options);
} }
} }
/**
* 显示加载中弹窗
* @param title 提示内容
* @param mask 是否显示蒙层
*/
showLoading(title: string, mask: boolean | null = null): void {
uni.showLoading({
title,
mask: mask ?? true
});
}
/**
* 隐藏加载中弹窗
*/
hideLoading(): void {
uni.hideLoading();
}
} }
/** /**

View File

@@ -128,6 +128,7 @@ declare type ClListItemComponentPublicInstance = {
declare type ClListViewComponentPublicInstance = { declare type ClListViewComponentPublicInstance = {
data: ClListViewItem[]; data: ClListViewItem[];
stopRefresh: () => void;
}; };
declare type ClCascaderComponentPublicInstance = { declare type ClCascaderComponentPublicInstance = {