Files
klp-mono/apps/hand-factory/pages/todo/index.vue
王文昊 07848446aa feat(todo页面): 优化待办列表滚动加载体验
1. 新增scroll-view高度动态计算适配不同设备
2. 将原scrolltolower触发改为滑动手势触发加载更多
3. 添加加载冷却期防止重复触发
4. 新增底部滑动提示动画
5. 重构加载更多逻辑并添加调试日志
2026-05-23 17:49:12 +08:00

659 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="todo-container">
<!-- 自定义导航栏 -->
<view class="custom-nav-bar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="nav-content">
<text class="nav-title">待办事项</text>
</view>
</view>
<!-- Tab切换 -->
<view class="tab-bar">
<view
v-for="tab in tabs"
:key="tab.key"
class="tab-item"
:class="{ active: activeTab === tab.key }"
@click="handleTabChange(tab.key)"
>
<text class="tab-text">{{ tab.label }}</text>
<text v-if="tab.badge > 0" class="tab-badge">{{ tab.badge }}</text>
</view>
</view>
<!-- 筛选栏 -->
<filter-bar
:loading="loading"
:total="total"
@search="handleSearch"
@reset="handleReset"
/>
<!-- 列表内容 -->
<scroll-view
scroll-y
class="list-container"
:style="{ height: scrollViewHeight + 'px' }"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scroll="onScroll"
lower-threshold="100"
>
<!-- 待贴标签列表 -->
<view v-if="activeTab === 'label'" class="coil-list">
<coil-card
v-for="item in list"
:key="item.coilId"
:data="item"
@relabel="handleRelabel"
@view-record="handleViewRecord"
@view-detail="handleViewDetail"
/>
</view>
<!-- 其他Tab占位 -->
<view v-else class="placeholder-page">
<text class="placeholder-icon">🚧</text>
<text class="placeholder-text">功能开发中</text>
</view>
<!-- 空状态 -->
<view v-if="activeTab === 'label' && list.length === 0 && !loading" class="empty-state">
<text class="empty-icon">📭</text>
<text class="empty-text">暂无待办事项</text>
</view>
<!-- 加载状态 -->
<view v-if="loading && list.length === 0" class="loading-state">
<uni-load-more status="loading" />
</view>
<!-- 加载更多提示 -->
<view v-if="activeTab === 'label' && list.length > 0 && list.length < total" class="load-more-wrapper">
<view v-if="isAtBottom && canLoadMore" class="bottom-hint">
<text class="hint-text"> 向上滑动加载更多</text>
</view>
<view v-else @click="onLoadMore">
<uni-load-more
:status="loadMoreStatus"
:content-text="{ contentdown: '点击加载更多', contentrefresh: '加载中...', contentnomore: '没有更多了' }"
/>
</view>
</view>
</scroll-view>
<!-- 记录弹窗 -->
<record-popup ref="recordPopup" />
<!-- 重贴标签弹窗 -->
<relabel-popup ref="relabelPopup" @success="handleRelabelSuccess" />
</view>
</template>
<script>
import { listMaterialCoil } from '@/api/wms/coil'
import FilterBar from './components/filter-bar.vue'
import CoilCard from './components/coil-card.vue'
import RecordPopup from './components/record-popup.vue'
import RelabelPopup from './components/relabel-popup.vue'
export default {
components: {
FilterBar,
CoilCard,
RecordPopup,
RelabelPopup
},
data() {
return {
statusBarHeight: 0,
scrollViewHeight: 0, // scroll-view 计算高度
tabs: [
{ key: 'label', label: '待贴标签', badge: 0 },
{ key: 'inspect', label: '检验任务', badge: 0 },
{ key: 'approval', label: '质保书审批', badge: 0 },
{ key: 'other', label: '其他代办', badge: 0 }
],
activeTab: 'label',
list: [],
loading: false,
refreshing: false,
loadMoreCooldown: false, // 加载更多冷却期标志
isAtBottom: false, // 是否滑动到底部
canLoadMore: false, // 是否可以加载更多(需要再次向上滑动)
lastScrollTop: 0, // 上次滚动位置
query: {
pageNum: 1,
pageSize: 10,
enterCoilNo: '',
currentCoilNo: '',
itemName: '',
itemSpecification: '',
itemMaterial: '',
itemManufacturer: '',
hasTransferType: true // 待贴标签只显示有调拨类型的钢卷
},
total: 0
}
},
created() {
// 获取状态栏高度
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 0
// 计算 scroll-view 高度
this.calcScrollViewHeight()
},
onReady() {
// 页面就绪后再次计算高度(确保布局完成)
this.$nextTick(() => {
this.calcScrollViewHeight()
})
},
computed: {
loadMoreStatus() {
if (this.loading || this.loadMoreCooldown) return 'loading'
if (this.list.length >= this.total) return 'noMore'
return 'more'
}
},
onLoad() {
this.fetchList()
},
methods: {
// 计算 scroll-view 高度
calcScrollViewHeight() {
const systemInfo = uni.getSystemInfoSync()
const windowHeight = systemInfo.windowHeight
const statusBarHeight = systemInfo.statusBarHeight || 0
// 各区域高度单位px
const navBarHeight = 44 // 导航栏高度
const tabBarHeight = 50 // Tab栏高度
const filterBarHeight = 60 // 筛选栏高度(留有余量)
const bottomSafeArea = systemInfo.safeAreaInsets?.bottom || 0 // 底部安全区域
const tabBarOffset = 50 // 底部TabBar高度
// 计算 scroll-view 可用高度确保最小高度为200px
let calcHeight = windowHeight - statusBarHeight - navBarHeight - tabBarHeight - filterBarHeight - bottomSafeArea - tabBarOffset
this.scrollViewHeight = Math.max(calcHeight, 200)
console.log('scroll-view 高度计算:', {
windowHeight,
statusBarHeight,
navBarHeight,
tabBarHeight,
filterBarHeight,
bottomSafeArea,
tabBarOffset,
calcHeight,
scrollViewHeight: this.scrollViewHeight
})
},
// 切换Tab
handleTabChange(key) {
this.activeTab = key
if (key === 'label') {
this.fetchList()
}
},
// 数据字段映射 - 将后端字段映射到前端使用的字段
// 后端 WmsMaterialCoilVo 字段coilId, enterCoilNo, currentCoilNo,
// itemName, specification, material, manufacturer,
// actualWarehouseName, warehouseName, remark, transferType
mapDataFields(row) {
return {
// 钢卷ID
coilId: row.coilId,
// 钢卷号
enterCoilNo: row.enterCoilNo || '-',
currentCoilNo: row.currentCoilNo || '-',
// 产品信息(注意后端使用 specification/material/manufacturer非 item 前缀)
itemName: row.itemName || '-',
itemSpecification: row.specification || '-',
itemMaterial: row.material || '-',
itemManufacturer: row.manufacturer || '-',
// 库区信息
actualWarehouseName: row.actualWarehouseName || row.warehouseName || '-',
// 其他字段
remark: row.remark || '-',
transferType: row.transferType || '',
// 改判原因(后端 WmsMaterialCoilVo 无此字段,仅通过 listWithRejudge 接口传入)
changeReason: row.changeReason || ''
}
},
// 获取列表数据
async fetchList(isLoadMore = false) {
if (this.loading) return
this.loading = true
try {
console.log('开始获取列表,查询参数:', this.query)
const res = await listMaterialCoil(this.query)
console.log('获取列表响应:', res)
const rows = res.rows || []
this.total = res.total || 0
// 映射数据字段
const mappedRows = rows.map(row => this.mapDataFields(row))
console.log('映射后的数据:', { mappedRows, total: this.total })
if (isLoadMore) {
this.list = [...this.list, ...mappedRows]
} else {
this.list = mappedRows
}
// 更新待贴标签数量
const labelTab = this.tabs.find(t => t.key === 'label')
if (labelTab) {
labelTab.badge = this.total
}
} catch (error) {
console.error('获取列表失败:', error)
uni.showToast({
title: '获取数据失败: ' + (error.message || '未知错误'),
icon: 'none'
})
} finally {
this.loading = false
this.refreshing = false
// 延迟解除冷却期,防止立即再次触发加载
setTimeout(() => {
this.loadMoreCooldown = false
console.log('加载冷却期结束')
}, 500)
}
},
// 搜索
handleSearch(form) {
this.query = {
...this.query,
...form,
pageNum: 1
}
this.fetchList()
},
// 重置
handleReset() {
this.query = {
pageNum: 1,
pageSize: 10,
enterCoilNo: '',
currentCoilNo: '',
itemName: '',
itemSpecification: '',
itemMaterial: '',
itemManufacturer: '',
hasTransferType: true
}
this.fetchList()
},
// 下拉刷新
onRefresh() {
this.refreshing = true
this.query.pageNum = 1
this.fetchList()
},
// 滚动事件处理
onScroll(e) {
const scrollTop = e.detail.scrollTop
const scrollHeight = e.detail.scrollHeight
const clientHeight = this.scrollViewHeight
// 防止无效值
if (!scrollHeight || !clientHeight) return
// 判断是否滑动到底部(距离底部小于 150px
const distanceToBottom = scrollHeight - scrollTop - clientHeight
const isNearBottom = distanceToBottom < 150
// 判断滑动方向(增加最小变化阈值,防止微小抖动)
const scrollDelta = scrollTop - this.lastScrollTop
const isScrollingDown = scrollDelta > 3
const isScrollingUp = scrollDelta < -3
console.log('滚动事件:', {
scrollTop,
scrollHeight,
clientHeight,
distanceToBottom,
isNearBottom,
isScrollingDown,
isScrollingUp,
isAtBottom: this.isAtBottom,
canLoadMore: this.canLoadMore
})
// 如果滑动到底部,标记状态但不加载
if (isNearBottom && !this.isAtBottom) {
console.log('滑动到底部,等待再次向上滑动触发加载')
this.isAtBottom = true
this.canLoadMore = true
}
// 如果已经到底部,并且用户开始向上滑动,触发加载
if (this.isAtBottom && this.canLoadMore && isScrollingUp) {
console.log('底部后向上滑动,触发加载更多')
this.canLoadMore = false
this.doLoadMore()
}
// 如果离开底部区域,重置状态
if (!isNearBottom && this.isAtBottom) {
console.log('离开底部区域')
this.isAtBottom = false
this.canLoadMore = false
}
// 只有滑动方向明确时才更新 lastScrollTop
if (Math.abs(scrollDelta) > 3) {
this.lastScrollTop = scrollTop
}
},
// 实际执行加载更多
doLoadMore() {
console.log('执行加载更多', {
listLength: this.list.length,
total: this.total,
loading: this.loading,
loadMoreCooldown: this.loadMoreCooldown,
pageNum: this.query.pageNum
})
// 防抖保护:如果正在加载、冷却期或已到最后一页,直接返回
if (this.list.length >= this.total || this.loading || this.loadMoreCooldown) {
console.log('加载更多被阻止:已到最后一页、正在加载中或冷却期内')
return
}
// 设置冷却期,防止连续触发
this.loadMoreCooldown = true
// 增加短暂延迟,防止 scrolltolower 立即重复触发
if (this._loadMoreTimer) {
clearTimeout(this._loadMoreTimer)
}
this._loadMoreTimer = setTimeout(() => {
this.query.pageNum++
console.log('加载第', this.query.pageNum, '页')
this.fetchList(true)
}, 150)
},
// 加载更多(点击按钮时调用)
onLoadMore() {
this.doLoadMore()
},
// 重贴标签
handleRelabel(coilInfo) {
this.$refs.relabelPopup.open(coilInfo)
},
// 重贴标签成功回调
handleRelabelSuccess() {
this.fetchList()
},
// 查看记录
handleViewRecord(coilInfo) {
const coilId = coilInfo.coilId
if (!coilId) {
uni.showToast({
title: '无法获取钢卷ID',
icon: 'none'
})
return
}
this.$refs.recordPopup.open(coilId)
},
// 查看详情
handleViewDetail(coilInfo) {
const coilId = coilInfo.coilId
if (!coilId) {
uni.showToast({
title: '无法获取钢卷ID',
icon: 'none'
})
return
}
uni.navigateTo({
url: `/pages/todo/coil-detail?coilId=${coilId}`
})
}
}
}
</script>
<style scoped lang="scss">
.todo-container {
min-height: 100vh;
background: #f5f7fa;
display: flex;
flex-direction: column;
.custom-nav-bar {
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
.nav-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #333333;
}
}
}
.tab-bar {
display: flex;
background: #ffffff;
padding: 0 10rpx;
border-bottom: 1rpx solid #f0f0f0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.tab-item {
flex: 0 0 auto;
min-width: 140rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20rpx 16rpx;
position: relative;
.tab-text {
font-size: 26rpx;
color: #666666;
white-space: nowrap;
line-height: 1.2;
}
.tab-badge {
position: absolute;
top: 8rpx;
right: 8rpx;
min-width: 32rpx;
height: 32rpx;
line-height: 32rpx;
text-align: center;
background: #ff4d4f;
color: #ffffff;
font-size: 20rpx;
border-radius: 16rpx;
padding: 0 8rpx;
}
&.active {
.tab-text {
color: #1a73e8;
font-weight: 600;
}
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40rpx;
height: 4rpx;
background: #1a73e8;
border-radius: 2rpx;
}
}
}
}
.list-container {
padding: 20rpx;
box-sizing: border-box;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
.coil-list {
padding-bottom: 40rpx;
}
.load-more-wrapper {
padding: 30rpx 0;
display: flex;
justify-content: center;
align-items: center;
&:active {
opacity: 0.7;
}
}
.bottom-hint {
padding: 30rpx 0 50rpx;
display: flex;
justify-content: center;
align-items: center;
.hint-text {
font-size: 28rpx;
color: #1a73e8;
font-weight: 500;
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {
0%, 100% {
opacity: 0.5;
transform: translateY(0);
}
50% {
opacity: 1;
transform: translateY(-3rpx);
}
}
.placeholder-page {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
.placeholder-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
}
.placeholder-text {
font-size: 32rpx;
color: #999999;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
.empty-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
}
.empty-text {
font-size: 32rpx;
color: #999999;
}
}
.loading-state {
padding: 100rpx 0;
}
}
}
/* 浏览器环境适配 */
@media screen and (min-width: 768px) {
.todo-container {
max-width: 750rpx;
margin: 0 auto;
.tab-bar {
.tab-item {
min-width: 160rpx;
.tab-text {
font-size: 28rpx;
}
}
}
}
}
/* H5 浏览器特定样式 */
/* #ifdef H5 */
.todo-container {
.tab-bar {
.tab-item {
cursor: pointer;
&:hover {
background: rgba(26, 115, 232, 0.05);
}
}
}
.coil-card {
cursor: pointer;
&:hover {
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.1);
}
}
}
/* #endif */
</style>