hookehuyr

refactor(plan): 优化计划书页面滚动加载并清理调试代码

- 将列表容器从 view 改为 scroll-view 组件以支持滚动加载
- 实现 scroll-view 的 @scrolltolower 事件处理
- 添加防抖机制避免频繁触发加载(300ms)
- 移除所有 console.log 调试语句
- 优化代码结构,移除未使用的函数参数

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -5,6 +5,15 @@
---
## [2026-02-06] - 修复计划书页面触底加载更多不触发
### 修复
- 修复 `src/pages/plan/index.vue` 列表区域滚动到底部不触发加载更多的问题
- 列表区域改为使用 `scroll-view``scrolltolower` 事件触发加载更多,并保留 `useReachBottom` 作为兜底
- 修复 `scroll-view` 内列表项宽度溢出导致右侧被裁切的问题
---
## [2026-02-06] - 修复401重定向死循环和返回报错
### 修复
......
/*
* @Date: 2026-01-29 22:24:28
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-01-29 22:54:03
* @LastEditTime: 2026-02-06 20:54:49
* @FilePath: /manulife-weapp/src/pages/plan/index.config.js
* @Description: 我的计划书页面配置文件
*/
export default definePageConfig({
navigationBarTitleText: '我的计划书',
navigationStyle: 'custom'
navigationStyle: 'custom',
enablePullDownRefresh: true,
})
/**
* 页面级触底事件处理
* @description 这是一个独立的页面级方法,作为 useReachBottom hook 的备用方案
* @returns {void}
*/
export function onReachBottom() {
console.log('[Plan] ⭐⭐⭐ [配置文件] 页面级触底事件触发!⭐⭐⭐')
console.log('[Plan] 如果看到这条日志,说明配置文件中的 onReachBottom 已工作')
console.log('[Plan] 当前时间:', new Date().toISOString())
}
......
<!--
* @Date: 2026-01-31
* @Description: 我的计划书 - 已改造为 NutTabs 版本
* @Date: 2026-02-06
* @Description: 我的计划书 - 支持滚动加载更多(参考 week-hot-material)
-->
<template>
<view class="h-screen bg-gray-50 flex flex-col">
<view class="bg-gray-50 z-10">
<!-- Navigation Header -->
<NavHeader title="我的计划书" />
<!-- Search Bar -->
<view class="px-[24rpx] py-[16rpx] bg-white">
<SearchBar
v-model="searchValue"
placeholder="搜索计划书名称、客户姓名..."
:show-clear="true"
@search="onSearch"
/>
</view>
<!-- NavHeader -->
<NavHeader title="我的计划书" />
<!-- Search Bar -->
<view class="px-[24rpx] py-[16rpx] bg-white">
<SearchBar
v-model="searchValue"
placeholder="搜索计划书名称、客户姓名..."
:show-clear="true"
variant="rounded"
@search="onSearch"
@clear="onClear"
/>
</view>
<!-- Tabs Container -->
<view class="bg-white mt-[2rpx]">
<nut-tabs v-model="activeTabId">
<!-- 自定义标签栏 -->
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
<!-- Tabs Container -->
<view class="bg-white mt-[2rpx]">
<nut-tabs v-model="activeTabId">
<template #titles>
<view class="filter-tabs-wrapper">
<view
v-for="item in tabsData"
:key="item.id"
:class="[
'filter-tab-item',
activeTabId === item.id ? 'filter-tab-active' : 'filter-tab-inactive'
]"
@tap="onTabClick(item.id)"
>
<text class="filter-tab-text">{{ item.name }}</text>
</view>
</template>
</nut-tabs>
</view>
</view>
</template>
</nut-tabs>
</view>
<!-- Plan List -->
<view
<!-- 可滚动列表区域(支持触底加载更多) -->
<scroll-view
v-if="listVisible"
:key="listRenderKey"
class="flex-1 min-h-0 overflow-y-auto px-[24rpx] py-[24rpx] pb-[200rpx]"
:scroll-y="true"
class="flex-1 min-h-0 w-full"
style="height: 100%;"
@scrolltolower="onScrollToLower"
>
<view v-for="(item, index) in filteredList" :key="index"
class="bg-white rounded-[24rpx] p-[24rpx] mb-[24rpx] shadow-sm plan-item"
:style="{ animationDelay: `${index * 50}ms` }">
<!-- Header -->
<view class="flex justify-between items-start mb-[16rpx]">
<view class="flex-1">
<view class="text-[30rpx] font-bold text-gray-900 leading-normal mb-[8rpx]">{{ item.title }}</view>
<view class="flex items-center gap-[12rpx]">
<view class="bg-blue-50 text-blue-600 text-[22rpx] px-[12rpx] py-[4rpx] rounded-[8rpx]" v-if="item.tag">
{{ item.tag }}
<view class="px-[24rpx] py-[24rpx] pb-[calc(env(safe-area-inset-bottom))] box-border w-full">
<!-- 加载状态 -->
<view v-if="loading && currentList.length === 0" class="flex items-center justify-center py-[60rpx]">
<view class="loading-spinner"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[28rpx]">加载中...</text>
</view>
<view v-else class="flex flex-col gap-[24rpx] w-full box-border">
<!-- Plan List -->
<view
v-for="(item, index) in currentList"
:key="item.id"
class="bg-white rounded-[24rpx] p-[24rpx] shadow-sm plan-item w-full box-border"
:style="{ animationDelay: `${index * 50}ms` }"
>
<!-- Header -->
<view class="flex justify-between items-start mb-[16rpx]">
<view class="flex-1">
<view class="text-[30rpx] font-bold text-gray-900 leading-normal mb-[8rpx]">{{ item.title }}</view>
<view class="flex items-center gap-[12rpx]">
<view class="bg-blue-50 text-blue-600 text-[22rpx] px-[12rpx] py-[4rpx] rounded-[8rpx]" v-if="item.tag">
{{ item.tag }}
</view>
</view>
</view>
<view class="ml-[24rpx]">
<!-- Status Badge or Icon could go here -->
</view>
</view>
</view>
<view class="ml-[24rpx]">
<!-- Status Badge or Icon could go here -->
</view>
</view>
<!-- Info -->
<view class="flex justify-between items-center text-gray-500 text-[24rpx] mb-[24rpx]">
<text>{{ item.client }}</text>
<text>{{ item.date }}</text>
</view>
<!-- Info -->
<view class="flex justify-between items-center text-gray-500 text-[24rpx] mb-[24rpx]">
<text>{{ item.client }}</text>
<text>{{ item.date }}</text>
</view>
<!-- Divider -->
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<!-- Divider -->
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<!-- Actions -->
<ListItemActions
:viewable="true"
:deletable="true"
@view="onView(item)"
@delete="onDelete(item)"
/>
</view>
<!-- Actions -->
<ListItemActions
:viewable="true"
:deletable="true"
@view="onView(item)"
@delete="onDelete(item)"
/>
</view>
<!-- 空状态 -->
<view v-if="currentList.length === 0 && !loading && !loadingMore">
<nut-empty description="暂无相关计划书" image="empty" />
</view>
<!-- Empty State -->
<view v-if="filteredList.length === 0">
<nut-empty description="暂无相关计划书" image="empty" />
<!-- 加载更多提示 -->
<view v-if="currentList.length > 0" class="flex items-center justify-center py-[40rpx]">
<view v-if="loadingMore" class="flex items-center">
<view class="loading-spinner-small"></view>
<text class="ml-[12rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</view>
<view v-else-if="!hasMore" class="text-[#9CA3AF] text-[24rpx]">
没有更多了
</view>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- TabBar -->
<!-- <TabBar current="" /> -->
......@@ -96,12 +123,11 @@
<script setup>
import { ref, computed, nextTick } from 'vue'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import { useFileOperation } from '@/composables/useFileOperation'
import IconFont from '@/components/IconFont.vue'
import NavHeader from '@/components/NavHeader.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import SearchBar from '@/components/SearchBar.vue'
import Taro from '@tarojs/taro'
const { viewFile } = useFileOperation()
......@@ -109,6 +135,12 @@ const searchValue = ref('')
const activeTabId = ref('')
const listVisible = ref(true)
const listRenderKey = ref(0)
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(true)
const currentList = ref([])
const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量(临时增加到 20 条以便测试滚动)
/**
* Tab 数据源
......@@ -121,12 +153,13 @@ const tabsData = ref([
])
/**
* Mock 数据:计划书列表
* Mock 数据:计划书列表(扩展到30条用于测试分页)
*
* @description 使用真实 PDF 文件进行测试
* downloadUrl 使用 Mozilla 的公开 PDF 测试文件
*/
const allList = ref([
// 第1页 (0-9)
{
id: 1,
title: '家庭财富传承保障计划(分红)',
......@@ -134,7 +167,6 @@ const allList = ref([
date: '2024-03-15 10:12',
tag: '年金保险',
status: 'generated',
// 文档信息
fileName: '家庭财富传承保障计划(分红).pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
......@@ -145,7 +177,6 @@ const allList = ref([
date: '2024-03-14 12:01',
tag: '',
status: 'processing',
// 生成中的文档没有下载地址
fileName: '',
downloadUrl: ''
},
......@@ -156,7 +187,6 @@ const allList = ref([
date: '2024-03-13 09:23',
tag: '年金保险',
status: 'generated',
// 文档信息
fileName: '企业高管年金计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
......@@ -167,7 +197,6 @@ const allList = ref([
date: '2024-03-12 15:12',
tag: '年金保险',
status: 'generated',
// 文档信息
fileName: '家庭财富传承保障计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
......@@ -231,6 +260,7 @@ const allList = ref([
fileName: '企业员工福利保障计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
// 第2页 (10-19)
{
id: 11,
title: '家族信托规划方案',
......@@ -250,30 +280,198 @@ const allList = ref([
status: 'generated',
fileName: '企业接班人保障方案.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 13,
title: '高端医疗健康保障计划',
client: '客户:张*敏',
date: '2024-03-07 14:30',
tag: '健康保障',
status: 'generated',
fileName: '高端医疗健康保障计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 14,
title: '子女教育金储备计划',
client: '客户:马*丽',
date: '2024-03-07 10:15',
tag: '教育金',
status: 'processing',
fileName: '',
downloadUrl: ''
},
{
id: 15,
title: '家庭财富增值规划',
client: '客户:林*涛',
date: '2024-03-06 16:45',
tag: '资产配置',
status: 'generated',
fileName: '家庭财富增值规划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 16,
title: '企业年金优化方案',
client: '客户:吴*华',
date: '2024-03-06 11:20',
tag: '年金保险',
status: 'generated',
fileName: '企业年金优化方案.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 17,
title: '个人税收筹划方案',
client: '客户:郑*强',
date: '2024-03-05 15:10',
tag: '税务筹划',
status: 'processing',
fileName: '',
downloadUrl: ''
},
{
id: 18,
title: '全面家庭保障计划',
client: '客户:黄*明',
date: '2024-03-05 09:50',
tag: '健康保障',
status: 'generated',
fileName: '全面家庭保障计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 19,
title: '子女教育金累积计划',
client: '客户:谢*芳',
date: '2024-03-04 14:25',
tag: '教育金',
status: 'generated',
fileName: '子女教育金累积计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 20,
title: '退休养老规划方案',
client: '客户:冯*伟',
date: '2024-03-04 10:40',
tag: '养老规划',
status: 'generated',
fileName: '退休养老规划方案.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
// 第3页 (20-29)
{
id: 21,
title: '企业财产保险规划',
client: '客户:袁*杰',
date: '2024-03-03 16:20',
tag: '团体保障',
status: 'generated',
fileName: '企业财产保险规划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 22,
title: '家庭财产保全计划',
client: '客户:彭*婷',
date: '2024-03-03 11:05',
tag: '传承保障',
status: 'processing',
fileName: '',
downloadUrl: ''
},
{
id: 23,
title: '高端医疗保险方案',
client: '客户:曹*强',
date: '2024-03-02 15:35',
tag: '健康保障',
status: 'generated',
fileName: '高端医疗保险方案.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 24,
title: '企业资产传承规划',
client: '客户:许*华',
date: '2024-03-02 10:15',
tag: '家族信托',
status: 'generated',
fileName: '企业资产传承规划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 25,
title: '子女海外教育金计划',
client: '客户:邓*丽',
date: '2024-03-01 14:50',
tag: '教育金',
status: 'generated',
fileName: '子女海外教育金计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 26,
title: '个人财富保全方案',
client: '客户:萧*敏',
date: '2024-03-01 09:30',
tag: '资产配置',
status: 'processing',
fileName: '',
downloadUrl: ''
},
{
id: 27,
title: '企业员工激励计划',
client: '客户:田*勇',
date: '2024-02-29 16:10',
tag: '团体保障',
status: 'generated',
fileName: '企业员工激励计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 28,
title: '家庭健康保障计划',
client: '客户:董*华',
date: '2024-02-29 11:45',
tag: '健康保障',
status: 'generated',
fileName: '家庭健康保障计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 29,
title: '退休收入保障计划',
client: '客户:潘*婷',
date: '2024-02-28 15:20',
tag: '养老规划',
status: 'generated',
fileName: '退休收入保障计划.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
},
{
id: 30,
title: '企业税务优化方案',
client: '客户:袁*明',
date: '2024-02-28 10:00',
tag: '税务筹划',
status: 'generated',
fileName: '企业税务优化方案.pdf',
downloadUrl: 'https://raw.githubusercontent.com/mozilla/pdf.js/ba2edeae/web/compressed.tracemonkey-pldi-09.pdf'
}
])
/**
* 初始化数据分布
* @description 根据分类规则将 allList 中的数据分配到各个 tab 中
*/
const initTabsData = () => {
tabsData.value.forEach((tab) => {
if (tab.id === '') {
tab.list = [...allList.value]
} else {
tab.list = allList.value.filter(item => item.status === tab.id)
}
})
}
/**
* 根据标签页和搜索关键词过滤列表
* 获取当前 tab 的完整列表(过滤后的)
*/
const filteredList = computed(() => {
// 找到当前选中的 tab
const getFilteredList = () => {
const currentTab = tabsData.value.find(tab => tab.id === activeTabId.value)
if (!currentTab) return []
if (!currentTab) {
return []
}
let result = currentTab.list
......@@ -287,28 +485,168 @@ const filteredList = computed(() => {
}
return result
})
}
/**
* 加载计划书列表(模拟分页)
* @param {number} page - 页码(从0开始)
* @param {number} limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
*/
const fetchPlanList = async (page = 0, limit = pageSize, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
// 模拟网络延迟(方便测试加载状态)
// await new Promise(resolve => setTimeout(resolve, 500))
// 获取过滤后的完整列表
const fullList = getFilteredList()
// 模拟分页:根据 page 和 limit 获取对应的数据
const start = page * limit
const end = start + limit
const pageData = fullList.slice(start, end)
if (isLoadMore) {
// 加载更多:追加数据
currentList.value = [...currentList.value, ...pageData]
} else {
// 首次加载或刷新:替换数据
currentList.value = pageData
}
// 判断是否还有更多数据
// 如果返回的数据量少于请求的量,说明没有更多了
hasMore.value = pageData.length >= limit
} catch (error) {
Taro.showToast({
title: '加载失败',
icon: 'error',
duration: 2000
})
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
* 初始化数据分布
* @description 根据分类规则将 allList 中的数据分配到各个 tab 中
*/
const initTabsData = () => {
tabsData.value.forEach((tab) => {
if (tab.id === '') {
tab.list = [...allList.value]
} else {
tab.list = allList.value.filter(item => item.status === tab.id)
}
})
}
/**
* Tab 点击处理
*/
const onTabClick = (id) => {
if (activeTabId.value === id) {
return
}
activeTabId.value = id
listVisible.value = false
// 重置分页状态
currentPage.value = 0
hasMore.value = true
nextTick(() => {
listRenderKey.value += 1
listVisible.value = true
// 重新加载数据
fetchPlanList(0, pageSize, false)
})
}
/**
* 搜索处理
*/
const onSearch = (val) => {
console.log('Search:', val)
const onSearch = () => {
// 重置分页状态
currentPage.value = 0
hasMore.value = true
listRenderKey.value += 1
// 重新加载数据
fetchPlanList(0, pageSize, false)
}
/**
* 清空搜索
*/
const onClear = () => {
// 重置分页状态
currentPage.value = 0
hasMore.value = true
listRenderKey.value += 1
// 重新加载数据
fetchPlanList(0, pageSize, false)
}
/**
* 页面加载时初始化数据
*/
useLoad(() => {
// 初始化数据分布
initTabsData()
// 加载第一页数据
fetchPlanList(0, pageSize, false)
})
/**
* 触底加载更多
* @description 使用防抖避免频繁触发
*/
let loadMoreTimer = null
const triggerLoadMore = () => {
if (loading.value || loadingMore.value || !hasMore.value) {
return
}
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(async () => {
const newPage = currentPage.value + 1
currentPage.value = newPage
await fetchPlanList(newPage, pageSize, true)
}, 300)
}
const onScrollToLower = () => {
triggerLoadMore()
}
useReachBottom(() => {
triggerLoadMore()
})
/**
* 查看计划书文档
*
* @description 使用 useFileOperation 的 viewFile 功能查看 PDF 文档
......@@ -346,18 +684,20 @@ const onDelete = (item) => {
allList.value.splice(index, 1)
// 重新初始化 tabsData
initTabsData()
// 重置分页状态并重新加载
currentPage.value = 0
hasMore.value = true
fetchPlanList(0, pageSize, false)
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
}
})
}
// 初始化数据
initTabsData()
</script>
<style lang="less">
/* 列表项进入动画 */
@keyframes slideIn {
from {
opacity: 0;
......@@ -374,6 +714,34 @@ initTabsData()
animation: slideIn 0.5s cubic-bezier(0.2, 0.8, 0.2, 1) backwards;
}
/* 加载动画 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.loading-spinner {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-spinner-small {
width: 32rpx;
height: 32rpx;
border: 3rpx solid #E5E7EB;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
// FilterTabs 风格的标签栏
.filter-tabs-wrapper {
display: flex;
......