feat: 新增 pull-refresh 下拉刷新组件

This commit is contained in:
不如摸鱼去 2025-07-30 12:34:41 +08:00
parent cade06fe4a
commit ddab83ac51
13 changed files with 1024 additions and 1 deletions

View File

@ -0,0 +1,189 @@
# PullRefresh 下拉刷新
用于提供下拉刷新的交互操作。
## 基础用法
下拉刷新时会触发 `refresh` 事件,在事件的回调函数中可以进行同步或异步操作,操作完成后将 `v-model` 设置为 `false`,表示加载完成。
```html
<wd-pull-refresh v-model="loading" @refresh="onRefresh">
<view class="content">
<wd-cell v-for="item in list" :key="item" :title="`列表项 ${item}`" />
</view>
</wd-pull-refresh>
```
```typescript
import { ref } from 'vue'
const loading = ref(false)
const list = ref([1, 2, 3, 4, 5])
function onRefresh() {
setTimeout(() => {
list.value = [1, 2, 3, 4, 5, 6]
loading.value = false
}, 2000)
}
```
## 自定义文案
通过 `pulling-text``loosing-text``loading-text``success-text` 属性可以自定义不同状态下的文案。
```html
<wd-pull-refresh
v-model="loading"
pulling-text="用力拉..."
loosing-text="快松手..."
loading-text="拼命加载中..."
success-text="加载成功!"
@refresh="onRefresh"
>
<view class="content">
<!-- 内容 -->
</view>
</wd-pull-refresh>
```
## 成功提示
通过 `success-text` 可以设置刷新成功后的提示文案,通过 `success-duration` 可以设置提示展示时长。
```html
<wd-pull-refresh
v-model="loading"
success-text="刷新成功"
:success-duration="1500"
@refresh="onRefresh"
>
<view class="content">
<!-- 内容 -->
</view>
</wd-pull-refresh>
```
## 自定义插槽
通过插槽可以自定义下拉刷新过程中的提示内容。
```html
<wd-pull-refresh v-model="loading" @refresh="onRefresh">
<template #pulling="{ distance }">
<view class="custom-slot">
<wd-icon name="arrow-down" size="20" />
<text>下拉距离: {{ Math.round(distance) }}px</text>
</view>
</template>
<template #loosing="{ distance }">
<view class="custom-slot">
<wd-icon name="arrow-up" size="20" />
<text>释放距离: {{ Math.round(distance) }}px</text>
</view>
</template>
<template #loading>
<view class="custom-slot">
<wd-loading size="20" />
<text>正在刷新数据...</text>
</view>
</template>
<template #success>
<view class="custom-slot">
<wd-icon name="check" size="20" color="#34d19d" />
<text>刷新完成</text>
</view>
</template>
<view class="content">
<!-- 内容 -->
</view>
</wd-pull-refresh>
```
## 禁用状态
通过 `disabled` 属性可以禁用下拉刷新。
```html
<wd-pull-refresh v-model="loading" disabled @refresh="onRefresh">
<view class="content">
<!-- 内容 -->
</view>
</wd-pull-refresh>
```
## API
### Props
| 参数 | 说明 | 类型 | 可选值 | 默认值 | 最低版本 |
|------|------|------|-------|--------|---------|
| v-model | 是否处于加载中状态 | `boolean` | - | `false` | - |
| disabled | 是否禁用下拉刷新 | `boolean` | - | `false` | - |
| pulling-text | 下拉过程提示文案 | `string` | - | `'下拉即可刷新...'` | - |
| loosing-text | 释放过程提示文案 | `string` | - | `'释放即可刷新...'` | - |
| loading-text | 加载过程提示文案 | `string` | - | `'加载中...'` | - |
| success-text | 刷新成功提示文案 | `string` | - | `''` | - |
| success-duration | 刷新成功提示展示时长(ms) | `number \| string` | - | `500` | - |
| animation-duration | 动画时长(ms) | `number \| string` | - | `300` | - |
| head-height | 顶部内容高度 | `number \| string` | - | `50` | - |
| pull-distance | 触发下拉刷新的距离 | `number \| string` | - | 与 `head-height` 相同 | - |
### Events
| 事件名 | 说明 | 参数 | 最低版本 |
|--------|------|------|---------|
| refresh | 下拉刷新时触发 | - | - |
| change | 拖拽时或状态改变时触发 | `{ status: PullRefreshStatus, distance: number }` | - |
### Slots
| 名称 | 说明 | 参数 | 最低版本 |
|------|------|------|---------|
| default | 内容区 | - | - |
| normal | 非下拉状态时顶部内容 | - | - |
| pulling | 下拉过程中顶部内容 | `{ distance: number }` | - |
| loosing | 释放过程中顶部内容 | `{ distance: number }` | - |
| loading | 加载过程中顶部内容 | `{ distance: number }` | - |
| success | 刷新成功时顶部内容 | - | - |
### Methods
通过 ref 可以获取到 PullRefresh 实例并调用实例方法。
| 方法名 | 说明 | 参数 | 返回值 | 最低版本 |
|--------|------|------|--------|---------|
| finish | 结束刷新状态 | - | - | - |
### 类型定义
组件导出以下类型定义:
```typescript
import type { PullRefreshProps, PullRefreshStatus } from '@/uni_modules/wot-design-uni'
type PullRefreshStatus = 'normal' | 'pulling' | 'loosing' | 'loading' | 'success'
```
## 常见问题
### 在某些情况下拖拽不生效?
请检查是否在页面滚动容器上设置了 `overflow: hidden` 或其他影响滚动的样式。
### 如何实现上拉加载更多?
可以结合 `wd-loadmore` 组件实现上拉加载更多功能。
```html
<wd-pull-refresh v-model="refreshing" @refresh="onRefresh">
<view class="content">
<wd-cell v-for="item in list" :key="item" :title="item" />
</view>
<wd-loadmore v-model="loading" @loadmore="onLoadmore" />
</wd-pull-refresh>
```

View File

@ -0,0 +1,189 @@
# PullRefresh
Provides pull-to-refresh interaction.
## Basic Usage
The `refresh` event will be triggered when pull-to-refresh, you can perform synchronous or asynchronous operations in the event callback function. After the operation is completed, set `v-model` to `false` to indicate that the loading is complete.
```html
<wd-pull-refresh v-model="loading" @refresh="onRefresh">
<view class="content">
<wd-cell v-for="item in list" :key="item" :title="`List item ${item}`" />
</view>
</wd-pull-refresh>
```
```typescript
import { ref } from 'vue'
const loading = ref(false)
const list = ref([1, 2, 3, 4, 5])
function onRefresh() {
setTimeout(() => {
list.value = [1, 2, 3, 4, 5, 6]
loading.value = false
}, 2000)
}
```
## Custom Text
You can customize the text in different states through `pulling-text`, `loosing-text`, `loading-text`, and `success-text` properties.
```html
<wd-pull-refresh
v-model="loading"
pulling-text="Pull hard..."
loosing-text="Release quickly..."
loading-text="Loading desperately..."
success-text="Load successfully!"
@refresh="onRefresh"
>
<view class="content">
<!-- Content -->
</view>
</wd-pull-refresh>
```
## Success Tip
You can set the prompt text after successful refresh through `success-text`, and set the prompt display duration through `success-duration`.
```html
<wd-pull-refresh
v-model="loading"
success-text="Refresh successful"
:success-duration="1500"
@refresh="onRefresh"
>
<view class="content">
<!-- Content -->
</view>
</wd-pull-refresh>
```
## Custom Slots
You can customize the prompt content during the pull-to-refresh process through slots.
```html
<wd-pull-refresh v-model="loading" @refresh="onRefresh">
<template #pulling="{ distance }">
<view class="custom-slot">
<wd-icon name="arrow-down" size="20" />
<text>Pull distance: {{ Math.round(distance) }}px</text>
</view>
</template>
<template #loosing="{ distance }">
<view class="custom-slot">
<wd-icon name="arrow-up" size="20" />
<text>Release distance: {{ Math.round(distance) }}px</text>
</view>
</template>
<template #loading>
<view class="custom-slot">
<wd-loading size="20" />
<text>Refreshing data...</text>
</view>
</template>
<template #success>
<view class="custom-slot">
<wd-icon name="check" size="20" color="#34d19d" />
<text>Refresh completed</text>
</view>
</template>
<view class="content">
<!-- Content -->
</view>
</wd-pull-refresh>
```
## Disabled State
You can disable pull-to-refresh through the `disabled` property.
```html
<wd-pull-refresh v-model="loading" disabled @refresh="onRefresh">
<view class="content">
<!-- Content -->
</view>
</wd-pull-refresh>
```
## API
### Props
| Parameter | Description | Type | Optional Values | Default Value | Minimum Version |
|-----------|-------------|------|----------------|---------------|----------------|
| v-model | Whether it is in loading state | `boolean` | - | `false` | - |
| disabled | Whether to disable pull-to-refresh | `boolean` | - | `false` | - |
| pulling-text | Text during pulling process | `string` | - | `'Pull to refresh...'` | - |
| loosing-text | Text during releasing process | `string` | - | `'Release to refresh...'` | - |
| loading-text | Text during loading process | `string` | - | `'Loading...'` | - |
| success-text | Text for successful refresh | `string` | - | `''` | - |
| success-duration | Display duration of success tip (ms) | `number \| string` | - | `500` | - |
| animation-duration | Animation duration (ms) | `number \| string` | - | `300` | - |
| head-height | Height of top content | `number \| string` | - | `50` | - |
| pull-distance | Distance to trigger pull-to-refresh | `number \| string` | - | Same as `head-height` | - |
### Events
| Event Name | Description | Parameters | Minimum Version |
|------------|-------------|------------|----------------|
| refresh | Triggered when pull-to-refresh | - | - |
| change | Triggered when dragging or status changes | `{ status: PullRefreshStatus, distance: number }` | - |
### Slots
| Name | Description | Parameters | Minimum Version |
|------|-------------|------------|----------------|
| default | Content area | - | - |
| normal | Top content when not pulling | - | - |
| pulling | Top content during pulling | `{ distance: number }` | - |
| loosing | Top content during releasing | `{ distance: number }` | - |
| loading | Top content during loading | `{ distance: number }` | - |
| success | Top content when refresh succeeds | - | - |
### Methods
You can get the PullRefresh instance through ref and call instance methods.
| Method Name | Description | Parameters | Return Value | Minimum Version |
|-------------|-------------|------------|--------------|----------------|
| finish | End refresh state | - | - | - |
### Type Definitions
The component exports the following type definitions:
```typescript
import type { PullRefreshProps, PullRefreshStatus } from '@/uni_modules/wot-design-uni'
type PullRefreshStatus = 'normal' | 'pulling' | 'loosing' | 'loading' | 'success'
```
## FAQ
### Dragging doesn't work in some cases?
Please check if `overflow: hidden` or other styles that affect scrolling are set on the page scroll container.
### How to implement pull-up to load more?
You can combine with the `wd-loadmore` component to implement pull-up to load more functionality.
```html
<wd-pull-refresh v-model="refreshing" @refresh="onRefresh">
<view class="content">
<wd-cell v-for="item in list" :key="item" :title="item" />
</view>
<wd-loadmore v-model="loading" @loadmore="onLoadmore" />
</wd-pull-refresh>
```

View File

@ -864,6 +864,7 @@
"progress-title": "Progress", "progress-title": "Progress",
"pu-tong-an-niu": "Normal button", "pu-tong-an-niu": "Normal button",
"pu-tong-shu-zhi": "Ordinary", "pu-tong-shu-zhi": "Ordinary",
"pullrefresh-xia-la-shua-xin": "",
"q1-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo": "Q1: The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales.", "q1-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo": "Q1: The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales. The seven day no reason return and exchange policy allows customers to accept returns and exchanges for all products within 7 days (based on the delivery receipt) without affecting secondary sales.",
"q1-you-hui-quan-shi-yong-xiang-qing-xiang-qing-ye-mian-wo-de-wo-de-you-hui-you-hui-quan-gui-ze-shuo-ming": "Q1: What are the details of coupon usage? Details page 【 My 】 - 【 My Discounts 】 - 【 Coupon Rules Explanation 】.", "q1-you-hui-quan-shi-yong-xiang-qing-xiang-qing-ye-mian-wo-de-wo-de-you-hui-you-hui-quan-gui-ze-shuo-ming": "Q1: What are the details of coupon usage? Details page 【 My 】 - 【 My Discounts 】 - 【 Coupon Rules Explanation 】.",
"qi-ta-xin-xi": "Other information", "qi-ta-xin-xi": "Other information",

View File

@ -864,6 +864,7 @@
"progress-title": "Progress 进度条", "progress-title": "Progress 进度条",
"pu-tong-an-niu": "普通按钮", "pu-tong-an-niu": "普通按钮",
"pu-tong-shu-zhi": "普通数值", "pu-tong-shu-zhi": "普通数值",
"pullrefresh-xia-la-shua-xin": "Pullrefresh 下拉刷新",
"q1-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo": "Q1:七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。", "q1-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo-qi-tian-wu-li-you-tui-huan-huo-zhi-du-suo-you-shang-pin-zai-bu-ying-xiang-er-ci-xiao-shou-de-qing-kuang-xia-7-tian-nei-yi-kuai-di-dan-qian-shou-wei-zhun-jun-jie-shou-ke-hu-tui-huan-huo": "Q1:七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。七天无理由退换货制度所有商品在不影响二次销售的情况下7天内以快递单签收为准均接受客户退换货。",
"q1-you-hui-quan-shi-yong-xiang-qing-xiang-qing-ye-mian-wo-de-wo-de-you-hui-you-hui-quan-gui-ze-shuo-ming": "Q1:优惠券使用详情?详情页面【我的】-【我的优惠】-【优惠券规则说明】。", "q1-you-hui-quan-shi-yong-xiang-qing-xiang-qing-ye-mian-wo-de-wo-de-you-hui-you-hui-quan-gui-ze-shuo-ming": "Q1:优惠券使用详情?详情页面【我的】-【我的优惠】-【优惠券规则说明】。",
"qi-ta-xin-xi": "其他信息", "qi-ta-xin-xi": "其他信息",

View File

@ -1326,6 +1326,21 @@
"navigationBarTitleText": "%rootportal-title%" "navigationBarTitleText": "%rootportal-title%"
// #endif // #endif
} }
},
{
"path": "pullRefresh/Index",
"name": "pullRefresh",
"style": {
"mp-alipay": {
"allowsBounceVertical": "NO"
},
// #ifdef MP
"navigationBarTitleText": "PullRefresh 下拉刷新",
// #endif
// #ifndef MP
"navigationBarTitleText": "%pullrefresh-title%"
// #endif
}
} }
] ]
} }

View File

@ -319,6 +319,10 @@ const list = computed(() => [
{ {
id: 'numberKeyboard', id: 'numberKeyboard',
name: t('numberkeyboard-shu-zi-jian-pan') name: t('numberkeyboard-shu-zi-jian-pan')
},
{
id: 'pullRefresh',
name: t('pullrefresh-xia-la-shua-xin')
} }
] ]
}, },

View File

@ -0,0 +1,100 @@
<template>
<view class="page">
<wd-tabs v-model="activeTab" @change="handleTabChange" animated sticky swipeable>
<wd-tab title="基础用法">
<wd-pull-refresh v-model="loading1" @refresh="onRefresh1">
<wd-cell v-for="item in list1" :key="item" :title="`列表项 ${item}`" />
</wd-pull-refresh>
</wd-tab>
<wd-tab title="局部滚动">
<wd-pull-refresh v-model="loading7" scroll-mode="scroll-view" :height="300" @refresh="onRefresh7">
<wd-cell title="scroll-view 滚动模式" label="固定高度 300px支持局部滚动" />
<wd-cell v-for="item in list7" :key="item" :title="`scroll-view 滚动 ${item}`" />
<wd-cell v-for="item in 15" :key="`extra-${item}`" :title="`额外内容 ${item}`" label="用于测试滚动效果" />
</wd-pull-refresh>
</wd-tab>
<wd-tab title="成功提示">
<wd-pull-refresh v-model="loading3" success-text="刷新成功" :success-duration="1500" @refresh="onRefresh3">
<wd-cell v-for="item in list3" :key="item" :title="`成功提示 ${item}`" />
</wd-pull-refresh>
</wd-tab>
<wd-tab title="自定义提示">
<wd-pull-refresh
v-model="loading2"
pulling-text="用力拉..."
loosing-text="快松手..."
loading-text="拼命加载中..."
success-text="加载成功!"
@refresh="onRefresh2"
>
<wd-cell v-for="item in list2" :key="item" :title="`自定义文案 ${item}`" />
</wd-pull-refresh>
</wd-tab>
</wd-tabs>
</view>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
// Tab
const activeTab = ref(0)
const refreshCount = ref(1)
// Loading
const loading1 = ref(false)
const loading2 = ref(false)
const loading3 = ref(false)
const loading7 = ref(false)
const loading8 = ref(false)
//
const list1 = ref(Array.from({ length: 20 }, (_, i) => i + 1))
const list2 = ref(Array.from({ length: 20 }, (_, i) => i + 1))
const list3 = ref(Array.from({ length: 20 }, (_, i) => i + 1))
const list7 = ref(Array.from({ length: 20 }, (_, i) => i + 1))
const list8 = ref(Array.from({ length: 20 }, (_, i) => i + 1))
// Tab
function handleTabChange(index: number) {
console.log('切换到tab:', index)
}
//
function onRefresh1() {
setTimeout(() => {
list1.value = Array.from({ length: 20 }, (_, i) => i + 1)
loading1.value = false
refreshCount.value++
}, 2000)
}
function onRefresh2() {
setTimeout(() => {
list2.value = Array.from({ length: 20 }, (_, i) => i + 1)
loading2.value = false
refreshCount.value++
}, 2000)
}
function onRefresh3() {
setTimeout(() => {
list3.value = Array.from({ length: 20 }, (_, i) => i + 1)
loading3.value = false
refreshCount.value++
}, 2000)
}
function onRefresh7() {
setTimeout(() => {
list7.value = [1, 2, 3, 4, 5, 6]
loading7.value = false
refreshCount.value++
}, 2000)
}
</script>
<style lang="scss" scoped></style>

View File

@ -658,7 +658,14 @@ $-toast-loading-margin-bottom: var(--wot-toast-loading-margin-bottom, 16px) !def
$-toast-box-shadow: var(--wot-toast-box-shadow, 0px 6px 16px 0px rgba(0, 0, 0, 0.08)) !default; // 外部阴影 $-toast-box-shadow: var(--wot-toast-box-shadow, 0px 6px 16px 0px rgba(0, 0, 0, 0.08)) !default; // 外部阴影
/* loading */ /* loading */
$-loading-size: var(--wot-loading-size, 32px) !default; // loading 大小 $-loading-size: var(--wot-loading-size, 30px) !default; // loading图标大小
/* pull-refresh */
$-pull-refresh-head-height: var(--wot-pull-refresh-head-height, 50px) !default; // 头部高度
$-pull-refresh-head-font-size: var(--wot-pull-refresh-head-font-size, 14px) !default; // 头部字体大小
$-pull-refresh-head-color: var(--wot-pull-refresh-head-color, rgba(0, 0, 0, 0.6)) !default; // 头部字体颜色
$-pull-refresh-loading-size: var(--wot-pull-refresh-loading-size, 20px) !default; // 加载图标大小
$-pull-refresh-animation-duration: var(--wot-pull-refresh-animation-duration, 300ms) !default; // 动画持续时间 大小
/* tooltip */ /* tooltip */
$-tooltip-bg: var(--wot-tooltip-bg, rgba(38, 39, 40, 0.8)) !default; // 背景色 $-tooltip-bg: var(--wot-tooltip-bg, rgba(38, 39, 40, 0.8)) !default; // 背景色
@ -970,5 +977,16 @@ $-signature-border: var(--wot-signature-border, 1px solid $-color-gray-5) !defau
$-signature-footer-margin-top: var(--wot-signature-footer-margin-top, 8px) !default; // 底部按钮上边距 $-signature-footer-margin-top: var(--wot-signature-footer-margin-top, 8px) !default; // 底部按钮上边距
$-signature-button-margin-left: var(--wot-signature-button-margin-left, 8px) !default; // 底部按钮左边距 $-signature-button-margin-left: var(--wot-signature-button-margin-left, 8px) !default; // 底部按钮左边距
/* pull-refresh */
$-pull-refresh-head-height: var(--wot-pull-refresh-head-height, 50px) !default; // 头部高度
$-pull-refresh-head-bg: var(--wot-pull-refresh-head-bg, $-color-white) !default; // 头部背景色
$-pull-refresh-content-bg: var(--wot-pull-refresh-content-bg, $-color-white) !default; // 内容背景色
$-pull-refresh-text-fs: var(--wot-pull-refresh-text-fs, $-fs-content) !default; // 文字字体大小
$-pull-refresh-text-color: var(--wot-pull-refresh-text-color, $-color-secondary) !default; // 文字颜色
$-pull-refresh-text-line-height: var(--wot-pull-refresh-text-line-height, 1.5) !default; // 文字行高
$-pull-refresh-loading-margin: var(--wot-pull-refresh-loading-margin, 8px) !default; // 加载状态文字左边距
$-pull-refresh-loading-size: var(--wot-pull-refresh-loading-size, 16px) !default; // 加载图标大小
$-pull-refresh-animation-duration: var(--wot-pull-refresh-animation-duration, 300ms) !default; // 动画持续时间

View File

@ -0,0 +1,42 @@
@import "../common/abstracts/_mixin.scss";
@import "../common/abstracts/variable.scss";
@include b(pull-refresh) {
position: relative;
overflow: hidden;
user-select: none;
@include e(head) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: $-pull-refresh-head-height;
display: flex;
align-items: center;
justify-content: center;
background-color: $-color-white;
}
@include e(content) {
position: relative;
background-color: $-pull-refresh-content-bg;
}
@include e(text) {
font-size: $-pull-refresh-text-fs;
color: $-pull-refresh-text-color;
line-height: $-pull-refresh-text-line-height;
}
@include e(loading) {
display: flex;
align-items: center;
justify-content: center;
flex-direction: row;
.wd-pull-refresh__text {
margin-left: $-pull-refresh-loading-margin;
}
}
}

View File

@ -0,0 +1,68 @@
/*
* @Author: Solo Coding
* @Date: 2024-12-19
* @LastEditTime: 2024-12-19
* @LastEditors: Solo Coding
* @Description: PullRefresh
* @FilePath: /wot-design-uni/src/uni_modules/wot-design-uni/components/wd-pull-refresh/types.ts
*/
import type { ExtractPropTypes } from 'vue'
import { baseProps, makeBooleanProp, makeStringProp, makeNumericProp } from '../common/props'
export type PullRefreshStatus = 'normal' | 'pulling' | 'loosing' | 'loading' | 'success'
export type ScrollMode = 'scroll-view' | 'page'
export const pullRefreshProps = {
...baseProps,
/**
*
*/
modelValue: makeBooleanProp(false),
/**
*
*/
disabled: makeBooleanProp(false),
/**
*
*/
pullingText: makeStringProp('下拉即可刷新...'),
/**
*
*/
loosingText: makeStringProp('释放即可刷新...'),
/**
*
*/
loadingText: makeStringProp('加载中...'),
/**
*
*/
successText: makeStringProp(''),
/**
* (ms)
*/
successDuration: makeNumericProp(300),
/**
* (ms)
*/
animationDuration: makeNumericProp(300),
/**
*
*/
headHeight: makeNumericProp(50),
/**
*
*/
pullDistance: makeNumericProp(''),
/**
* scroll-view() | page()
*/
scrollMode: makeStringProp<ScrollMode>('page'),
/**
* scroll-view
*/
height: makeNumericProp(400)
}
export type PullRefreshProps = ExtractPropTypes<typeof pullRefreshProps>

View File

@ -0,0 +1,273 @@
<template>
<view :class="`wd-pull-refresh ${props.customClass}`" :style="props.customStyle">
<!-- 统一的下拉头部区域 -->
<view class="wd-pull-refresh__head" :style="headStyle">
<slot v-if="status === 'normal'" name="normal"></slot>
<slot v-else-if="status === 'pulling'" name="pulling" :distance="distance">
<view class="wd-pull-refresh__text">{{ pullingText }}</view>
</slot>
<slot v-else-if="status === 'loosing'" name="loosing" :distance="distance">
<view class="wd-pull-refresh__text">{{ loosingText }}</view>
</slot>
<slot v-else-if="status === 'loading'" name="loading" :distance="distance">
<view class="wd-pull-refresh__loading">
<wd-loading :size="loadingSize" />
<view v-if="loadingText" class="wd-pull-refresh__text">{{ loadingText }}</view>
</view>
</slot>
<slot v-else-if="status === 'success'" name="success">
<view class="wd-pull-refresh__text">{{ successText }}</view>
</slot>
</view>
<!-- 根据滚动模式渲染不同的内容区域 -->
<!-- scroll-view 局部滚动模式 -->
<scroll-view
v-if="scrollMode === 'scroll-view'"
class="wd-pull-refresh__content"
:style="contentStyle"
:scroll-y="true"
:scroll-top="scrollTop"
@scroll="handleScrollViewScroll"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<slot></slot>
</scroll-view>
<!-- 页面全局滚动模式 -->
<view
v-else
class="wd-pull-refresh__content"
:style="contentStyle"
@touchstart="handleTouchStart"
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
>
<slot></slot>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch, type CSSProperties } from 'vue'
import { defineOptions } from 'vue'
import { onPageScroll } from '@dcloudio/uni-app'
import { useTouch } from '../composables/useTouch'
import { addUnit } from '../common/util'
import { pullRefreshProps, type PullRefreshStatus } from './types'
import WdLoading from '../wd-loading/wd-loading.vue'
defineOptions({
name: 'wd-pull-refresh',
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared'
}
})
const props = defineProps(pullRefreshProps)
const emit = defineEmits<{
refresh: []
change: [payload: { status: PullRefreshStatus; distance: number }]
'update:modelValue': [value: boolean]
}>()
const status = ref<PullRefreshStatus>('normal')
const distance = ref<number>(0)
const duration = ref<number>(0)
const isRefreshing = ref<boolean>(false)
const scrollTop = ref<number>(0)
const pageScrollTop = ref<number>(0)
const touch = useTouch()
// scroll-view scroll-view
function handleScrollViewScroll(event: any) {
if (props.scrollMode === 'scroll-view') {
scrollTop.value = event.detail.scrollTop || 0
}
}
// 使 uni-app onPageScroll page
onPageScroll((event) => {
if (props.scrollMode === 'page') {
const scrollTop = event.scrollTop || 0
pageScrollTop.value = scrollTop
}
})
//
const headHeight = computed(() => addUnit(props.headHeight))
const pullDistance = computed(() => {
return props.pullDistance ? Number(props.pullDistance) : Number(props.headHeight)
})
const loadingSize = computed(() => {
return Math.min(Number(props.headHeight) * 0.4, 20)
})
//
const headStyle = computed(() => {
const style: CSSProperties = {
height: headHeight.value,
transform: `translateY(${distance.value - Number(props.headHeight)}px)`,
transition: duration.value ? `transform ${duration.value}ms ease-out` : 'none'
}
return style
})
//
const contentStyle = computed(() => {
const style: CSSProperties = {
transform: `translateY(${distance.value}px)`,
transition: duration.value ? `transform ${duration.value}ms ease-out` : 'none'
}
if (props.scrollMode === 'scroll-view') {
style.height = addUnit(props.height)
}
return style
})
// v-model
watch(
() => props.modelValue,
(newVal) => {
if (!newVal && isRefreshing.value) {
//
duration.value = Number(props.animationDuration)
if (props.successText) {
//
setStatus('success')
setTimeout(() => {
resetPosition()
isRefreshing.value = false
}, Number(props.successDuration))
} else {
//
resetPosition()
isRefreshing.value = false
}
}
}
)
//
function handleTouchStart(event: TouchEvent) {
if (props.disabled || isRefreshing.value) return
//
const currentScrollTop = getCurrentScrollTop()
if (currentScrollTop > 0) return
touch.touchStart(event)
duration.value = 0
}
function handleTouchMove(event: TouchEvent) {
if (props.disabled || isRefreshing.value) return
touch.touchMove(event)
//
if (touch.deltaY.value <= 0 || touch.direction.value !== 'vertical') {
return
}
//
const currentScrollTop = getCurrentScrollTop()
if (currentScrollTop > 0) return
//
event.preventDefault()
//
const damping = 0.5
distance.value = Math.max(0, touch.deltaY.value * damping)
//
if (distance.value >= pullDistance.value) {
setStatus('loosing')
} else if (distance.value > 0) {
setStatus('pulling')
}
}
function handleTouchEnd() {
if (props.disabled || isRefreshing.value) return
duration.value = Number(props.animationDuration)
if (status.value === 'loosing') {
//
distance.value = Number(props.headHeight)
setStatus('loading')
isRefreshing.value = true
emit('update:modelValue', true)
emit('refresh')
} else {
//
resetPosition()
}
}
//
function setStatus(newStatus: PullRefreshStatus) {
if (status.value !== newStatus) {
status.value = newStatus
emit('change', { status: newStatus, distance: distance.value })
}
}
//
function resetPosition() {
//
duration.value = Number(props.animationDuration)
distance.value = 0
setStatus('normal')
// duration
setTimeout(() => {
duration.value = 0
}, Number(props.animationDuration))
}
//
function getCurrentScrollTop(): number {
try {
if (props.scrollMode === 'scroll-view') {
// scroll-view 使 scroll-view scrollTop
return scrollTop.value
} else {
// page 使
return pageScrollTop.value
}
} catch {
return 0
}
}
//
function finish() {
if (isRefreshing.value) {
emit('update:modelValue', false)
}
}
// 使
defineExpose({
finish,
scrollMode: computed(() => props.scrollMode)
})
</script>
<style lang="scss" scoped>
@import './index.scss';
</style>

View File

@ -40,6 +40,7 @@ declare module 'vue' {
WdPopover: typeof import('./components/wd-popover/wd-popover.vue')['default'] WdPopover: typeof import('./components/wd-popover/wd-popover.vue')['default']
WdPopup: typeof import('./components/wd-popup/wd-popup.vue')['default'] WdPopup: typeof import('./components/wd-popup/wd-popup.vue')['default']
WdProgress: typeof import('./components/wd-progress/wd-progress.vue')['default'] WdProgress: typeof import('./components/wd-progress/wd-progress.vue')['default']
WdPullRefresh: typeof import('./components/wd-pull-refresh/wd-pull-refresh.vue')['default']
WdRadio: typeof import('./components/wd-radio/wd-radio.vue')['default'] WdRadio: typeof import('./components/wd-radio/wd-radio.vue')['default']
WdRadioGroup: typeof import('./components/wd-radio-group/wd-radio-group.vue')['default'] WdRadioGroup: typeof import('./components/wd-radio-group/wd-radio-group.vue')['default']
WdRate: typeof import('./components/wd-rate/wd-rate.vue')['default'] WdRate: typeof import('./components/wd-rate/wd-rate.vue')['default']
@ -95,6 +96,7 @@ declare module 'vue' {
WdFloatingPanel: typeof import('./components/wd-floating-panel/wd-floating-panel.vue')['default'] WdFloatingPanel: typeof import('./components/wd-floating-panel/wd-floating-panel.vue')['default']
WdSignature: typeof import('./components/wd-signature/wd-signature.vue')['default'] WdSignature: typeof import('./components/wd-signature/wd-signature.vue')['default']
WdRootPortal: typeof import('./components/wd-root-portal/wd-root-portal.vue')['default'] WdRootPortal: typeof import('./components/wd-root-portal/wd-root-portal.vue')['default']
WdPullRefresh: typeof import('./components/wd-pull-refresh/wd-pull-refresh.vue')['default']
} }
} }

View File

@ -0,0 +1,121 @@
import { mount } from '@vue/test-utils'
import WdPullRefresh from '@/uni_modules/wot-design-uni/components/wd-pull-refresh/wd-pull-refresh.vue'
import { describe, expect, test, vi } from 'vitest'
import { nextTick } from 'vue'
describe('WdPullRefresh', () => {
test('should render correctly', () => {
const wrapper = mount(WdPullRefresh, {
props: {
modelValue: false
},
slots: {
default: '<div>Content</div>'
}
})
expect(wrapper.find('.wd-pull-refresh').exists()).toBe(true)
expect(wrapper.find('.wd-pull-refresh__content').exists()).toBe(true)
expect(wrapper.find('.wd-pull-refresh__head').exists()).toBe(true)
})
test('should support scroll mode prop', async () => {
const wrapper = mount(WdPullRefresh, {
props: {
modelValue: false,
scrollMode: 'page'
},
slots: {
default: '<div>Content</div>'
}
})
await nextTick()
expect(wrapper.vm.scrollMode).toBe('page')
})
test('should handle page scroll correctly', async () => {
const wrapper = mount(WdPullRefresh, {
props: {
modelValue: false,
scrollMode: 'page'
},
slots: {
default: '<div>Content</div>'
}
})
await nextTick()
// 验证组件正常渲染
expect(wrapper.vm.scrollMode).toBe('page')
})
test('should render scroll-view mode correctly', async () => {
const wrapper = mount(WdPullRefresh, {
props: {
modelValue: false,
scrollMode: 'scroll-view'
},
slots: {
default: '<div>Content</div>'
}
})
await nextTick()
// 验证 scroll-view 模式下的渲染
expect(wrapper.find('.wd-pull-refresh__scroll-view').exists()).toBe(true)
expect(wrapper.vm.scrollMode).toBe('scroll-view')
})
test('should use default scroll mode', async () => {
const wrapper = mount(WdPullRefresh, {
props: {
modelValue: false
},
slots: {
default: '<div>Content</div>'
}
})
await nextTick()
// 默认模式应该是 page
expect(wrapper.vm.scrollMode).toBe('page')
})
test('should expose correct methods', () => {
const wrapper = mount(WdPullRefresh, {
props: {
modelValue: false
},
slots: {
default: '<div>Content</div>'
}
})
// 检查暴露的方法
expect(typeof wrapper.vm.finish).toBe('function')
expect(wrapper.vm.scrollMode).toBeDefined()
})
test('should handle refresh correctly', async () => {
const onRefresh = vi.fn()
const wrapper = mount(WdPullRefresh, {
props: {
modelValue: false,
scrollMode: 'page'
},
slots: {
default: '<div>Content</div>'
}
})
wrapper.vm.$emit('refresh')
await nextTick()
// 检查是否可以正常触发刷新事件
expect(wrapper.emitted('refresh')).toBeTruthy()
})
})