fix(api): 修复 OpenAPI 生成器并添加无限滚动功能
## 主要修改
### 1. 修复 OpenAPI 生成器 (scripts/generateApiFromOpenAPI.js)
- 修复 extractAPIInfo is not defined 错误
- 增强 generateReturnJSDoc 函数,支持正确解析数组类型的 data 字段
- 新增对 Array<{...}> 类型的完整字段描述生成
### 2. 优化 API 文档 JSDoc 注释
- news.js: data 字段从 any 改为详细的 Array<{...}> 类型
- home.js: 新增 home 模块 API(首页图标列表)
### 3. 添加无限滚动功能 (src/pages/material-list/index.vue)
- 实现 useReachBottom 触底加载更多
- 添加分页状态管理(currentPage, hasMore, loadingMore)
- 支持各分类独立的分页缓存
- 优化加载状态显示(加载中/没有更多了)
- 添加自定义加载动画
## 技术细节
- 使用防抖(300ms)避免频繁触发加载
- 区分首次加载和加载更多的状态
- 正确处理搜索、子分类、全部列表的分页逻辑
- 保存并恢复各分类的分页状态
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Showing
5 changed files
with
486 additions
and
56 deletions
docs/api-specs/home/home_icon.md
0 → 100644
| 1 | +# 首页图标列表 | ||
| 2 | + | ||
| 3 | +## OpenAPI Specification | ||
| 4 | + | ||
| 5 | +```yaml | ||
| 6 | +openapi: 3.0.1 | ||
| 7 | +info: | ||
| 8 | + title: '' | ||
| 9 | + version: 1.0.0 | ||
| 10 | +paths: | ||
| 11 | + /srv/: | ||
| 12 | + get: | ||
| 13 | + summary: 首页图标列表 | ||
| 14 | + deprecated: false | ||
| 15 | + description: '' | ||
| 16 | + tags: [] | ||
| 17 | + parameters: | ||
| 18 | + - name: f | ||
| 19 | + in: query | ||
| 20 | + description: '' | ||
| 21 | + required: true | ||
| 22 | + example: manulife | ||
| 23 | + schema: | ||
| 24 | + type: string | ||
| 25 | + - name: a | ||
| 26 | + in: query | ||
| 27 | + description: '' | ||
| 28 | + required: true | ||
| 29 | + example: home_icon | ||
| 30 | + schema: | ||
| 31 | + type: string | ||
| 32 | + - name: t | ||
| 33 | + in: query | ||
| 34 | + description: '' | ||
| 35 | + required: false | ||
| 36 | + example: icon | ||
| 37 | + schema: | ||
| 38 | + type: string | ||
| 39 | + responses: | ||
| 40 | + '200': | ||
| 41 | + description: '' | ||
| 42 | + content: | ||
| 43 | + application/json: | ||
| 44 | + schema: | ||
| 45 | + type: object | ||
| 46 | + properties: | ||
| 47 | + code: | ||
| 48 | + type: integer | ||
| 49 | + msg: | ||
| 50 | + type: integer | ||
| 51 | + data: | ||
| 52 | + type: array | ||
| 53 | + items: | ||
| 54 | + type: object | ||
| 55 | + properties: | ||
| 56 | + id: | ||
| 57 | + type: integer | ||
| 58 | + name: | ||
| 59 | + type: string | ||
| 60 | + seq: | ||
| 61 | + type: integer | ||
| 62 | + link: | ||
| 63 | + type: string | ||
| 64 | + icon: | ||
| 65 | + type: string | ||
| 66 | + required: | ||
| 67 | + - id | ||
| 68 | + - name | ||
| 69 | + - seq | ||
| 70 | + - link | ||
| 71 | + - icon | ||
| 72 | + x-apifox-orders: | ||
| 73 | + - id | ||
| 74 | + - name | ||
| 75 | + - link | ||
| 76 | + - icon | ||
| 77 | + - seq | ||
| 78 | + required: | ||
| 79 | + - code | ||
| 80 | + - msg | ||
| 81 | + - data | ||
| 82 | + x-apifox-orders: | ||
| 83 | + - code | ||
| 84 | + - msg | ||
| 85 | + - data | ||
| 86 | + example: | ||
| 87 | + code: 1 | ||
| 88 | + msg: 0 | ||
| 89 | + data: | ||
| 90 | + - id: 3134682 | ||
| 91 | + name: 计划书 | ||
| 92 | + seq: 1 | ||
| 93 | + link: /pages/plan/index | ||
| 94 | + icon: >- | ||
| 95 | + https://cdn.ipadbiz.cn/space_3079606/文件_FsINuaz2bYXHzNoAsNIfTV0SnPC-.png | ||
| 96 | + - id: 3134691 | ||
| 97 | + name: 入职相关 | ||
| 98 | + seq: 2 | ||
| 99 | + link: /pages/category-list/index?cid=3129684 | ||
| 100 | + icon: >- | ||
| 101 | + https://cdn.ipadbiz.cn/space_3079606/感叹号_FnljRoegoDuH-kkSum55sKCExoIl.png | ||
| 102 | + - id: 3134697 | ||
| 103 | + name: 签单相关 | ||
| 104 | + seq: 3 | ||
| 105 | + link: /pages/category-list/index?cid=3134692 | ||
| 106 | + icon: >- | ||
| 107 | + https://cdn.ipadbiz.cn/space_3079606/勾_Fvc4L0IOPkfDqxCcTHbVtwZCjIaR.png | ||
| 108 | + - id: 3134699 | ||
| 109 | + name: 家办相关 | ||
| 110 | + seq: 4 | ||
| 111 | + link: /pages/category-list/index?cid=3134693 | ||
| 112 | + icon: >- | ||
| 113 | + https://cdn.ipadbiz.cn/space_3079606/帮助_Fmf73oRbxLMAB7ptz7DheIWdidV_.png | ||
| 114 | + - id: 3134702 | ||
| 115 | + name: 产品知识库 | ||
| 116 | + seq: 5 | ||
| 117 | + link: /pages/category-list/index?cid=3134694 | ||
| 118 | + icon: >- | ||
| 119 | + https://cdn.ipadbiz.cn/space_3079606/file-list-3-fill_Fpv2uhm46FuDgHnmvkMM89eKv4eL.png | ||
| 120 | + - id: 3134711 | ||
| 121 | + name: 工具箱 | ||
| 122 | + seq: 6 | ||
| 123 | + link: /pages/category-list/index?cid=3134695 | ||
| 124 | + icon: >- | ||
| 125 | + https://cdn.ipadbiz.cn/space_3079606/服务_Fr6i22Usft4N7F-uEQQu4VRjlWpu.png | ||
| 126 | + headers: {} | ||
| 127 | + x-apifox-name: 成功 | ||
| 128 | + x-apifox-ordering: 0 | ||
| 129 | + security: [] | ||
| 130 | + x-apifox-folder: '' | ||
| 131 | + x-apifox-status: released | ||
| 132 | + x-run-in-apifox: https://app.apifox.com/web/project/7792797/apis/api-415955157-run | ||
| 133 | +components: | ||
| 134 | + schemas: {} | ||
| 135 | + responses: {} | ||
| 136 | + securitySchemes: {} | ||
| 137 | +servers: | ||
| 138 | + - url: https://manulife.onwall.cn | ||
| 139 | + description: 正式环境 | ||
| 140 | +security: [] | ||
| 141 | + | ||
| 142 | +``` |
| ... | @@ -381,35 +381,62 @@ function generateReturnJSDoc(responseSchema) { | ... | @@ -381,35 +381,62 @@ function generateReturnJSDoc(responseSchema) { |
| 381 | returnDesc += ' * code: number; // 状态码\n'; | 381 | returnDesc += ' * code: number; // 状态码\n'; |
| 382 | returnDesc += ' * msg: string; // 消息\n'; | 382 | returnDesc += ' * msg: string; // 消息\n'; |
| 383 | 383 | ||
| 384 | - if (data && data.properties) { | 384 | + if (data) { |
| 385 | - returnDesc += ' * data: {\n'; | 385 | + const dataType = data.type || 'any'; |
| 386 | - | 386 | + const dataDesc = data.description || data.title || ''; |
| 387 | - Object.entries(data.properties).forEach(([key, value]) => { | 387 | + |
| 388 | - const type = value.type || 'any'; | 388 | + // 处理对象类型的 data |
| 389 | - const desc = value.description || value.title || ''; | 389 | + if (dataType === 'object' && data.properties) { |
| 390 | - | 390 | + returnDesc += ' * data: {\n'; |
| 391 | - if (type === 'object' && value.properties) { | 391 | + |
| 392 | - returnDesc += ` * ${key}: {\n`; | 392 | + Object.entries(data.properties).forEach(([key, value]) => { |
| 393 | - Object.entries(value.properties).forEach(([subKey, subValue]) => { | 393 | + const type = value.type || 'any'; |
| 394 | - const subType = subValue.type || 'any'; | 394 | + const desc = value.description || value.title || ''; |
| 395 | - const subDesc = subValue.description || subValue.title || ''; | 395 | + |
| 396 | - returnDesc += ` * ${subKey}: ${subType}; // ${subDesc}\n`; | 396 | + if (type === 'object' && value.properties) { |
| 397 | - }); | 397 | + returnDesc += ` * ${key}: {\n`; |
| 398 | - returnDesc += ` * };\n`; | 398 | + Object.entries(value.properties).forEach(([subKey, subValue]) => { |
| 399 | - } else if (type === 'array' && value.items && value.items.properties) { | 399 | + const subType = subValue.type || 'any'; |
| 400 | - returnDesc += ` * ${key}: Array<{\n`; | 400 | + const subDesc = subValue.description || subValue.title || ''; |
| 401 | - Object.entries(value.items.properties).forEach(([subKey, subValue]) => { | 401 | + returnDesc += ` * ${subKey}: ${subType}; // ${subDesc}\n`; |
| 402 | - const subType = subValue.type || 'any'; | 402 | + }); |
| 403 | - const subDesc = subValue.description || subValue.title || ''; | 403 | + returnDesc += ` * };\n`; |
| 404 | - returnDesc += ` * ${subKey}: ${subType}; // ${subDesc}\n`; | 404 | + } else if (type === 'array' && value.items && value.items.properties) { |
| 405 | - }); | 405 | + returnDesc += ` * ${key}: Array<{\n`; |
| 406 | - returnDesc += ` * }>;\n`; | 406 | + Object.entries(value.items.properties).forEach(([subKey, subValue]) => { |
| 407 | - } else { | 407 | + const subType = subValue.type || 'any'; |
| 408 | + const subDesc = subValue.description || subValue.title || ''; | ||
| 409 | + returnDesc += ` * ${subKey}: ${subType}; // ${subDesc}\n`; | ||
| 410 | + }); | ||
| 411 | + returnDesc += ` * }>;\n`; | ||
| 412 | + } else { | ||
| 413 | + returnDesc += ` * ${key}: ${type}; // ${desc}\n`; | ||
| 414 | + } | ||
| 415 | + }); | ||
| 416 | + | ||
| 417 | + returnDesc += ' * };\n'; | ||
| 418 | + } | ||
| 419 | + // 处理数组类型的 data(你的情况) | ||
| 420 | + else if (dataType === 'array' && data.items && data.items.properties) { | ||
| 421 | + returnDesc += ' * data: Array<{\n'; | ||
| 422 | + | ||
| 423 | + Object.entries(data.items.properties).forEach(([key, value]) => { | ||
| 424 | + const type = value.type || 'any'; | ||
| 425 | + const desc = value.description || value.title || ''; | ||
| 408 | returnDesc += ` * ${key}: ${type}; // ${desc}\n`; | 426 | returnDesc += ` * ${key}: ${type}; // ${desc}\n`; |
| 409 | - } | 427 | + }); |
| 410 | - }); | ||
| 411 | 428 | ||
| 412 | - returnDesc += ' * };\n'; | 429 | + returnDesc += ' * }>;\n'; |
| 430 | + } | ||
| 431 | + // 处理简单数组类型 | ||
| 432 | + else if (dataType === 'array' && data.items) { | ||
| 433 | + const itemType = data.items.type || 'any'; | ||
| 434 | + returnDesc += ` * data: Array<${itemType}>;\n`; | ||
| 435 | + } | ||
| 436 | + // 其他类型 | ||
| 437 | + else { | ||
| 438 | + returnDesc += ` * data: ${dataType};\n`; | ||
| 439 | + } | ||
| 413 | } else { | 440 | } else { |
| 414 | returnDesc += ' * data: any;\n'; | 441 | returnDesc += ' * data: any;\n'; |
| 415 | } | 442 | } |
| ... | @@ -709,11 +736,14 @@ function compareAPIChanges(openAPIDir) { | ... | @@ -709,11 +736,14 @@ function compareAPIChanges(openAPIDir) { |
| 709 | try { | 736 | try { |
| 710 | const newDocs = parseOpenAPIPath(moduleDir); | 737 | const newDocs = parseOpenAPIPath(moduleDir); |
| 711 | if (newDocs && newDocs.length > 0) { | 738 | if (newDocs && newDocs.length > 0) { |
| 712 | - // 使用 extractAPIInfo 提取 API 信息 | 739 | + // 显示新增接口信息 |
| 713 | - const apiInfos = newDocs.map(doc => extractAPIInfo(doc)); | 740 | + console.log(` 包含 ${newDocs.length} 个新增接口:`); |
| 714 | - console.log(` 包含 ${apiInfos.length} 个新增接口:`); | 741 | + newDocs.forEach(doc => { |
| 715 | - apiInfos.forEach(api => { | 742 | + const path = Object.keys(doc.paths || {})[0] || ''; |
| 716 | - console.log(` • ${api.method} ${api.path} - ${api.summary || api.name}`); | 743 | + const method = Object.keys(doc.paths?.[path] || {})[0] || ''; |
| 744 | + const apiInfo = doc.paths?.[path]?.[method]; | ||
| 745 | + const summary = apiInfo?.summary || doc.info?.title || '未命名接口'; | ||
| 746 | + console.log(` • ${method?.toUpperCase()} ${path} - ${summary}`); | ||
| 717 | }); | 747 | }); |
| 718 | } | 748 | } |
| 719 | } catch (error) { | 749 | } catch (error) { | ... | ... |
src/api/home.js
0 → 100644
| 1 | +import { fn, fetch } from '@/api/fn'; | ||
| 2 | + | ||
| 3 | +const Api = { | ||
| 4 | + HomeIcon: '/srv/?a=home_icon&t=icon', | ||
| 5 | +} | ||
| 6 | + | ||
| 7 | +/** | ||
| 8 | + * @description 首页图标列表 | ||
| 9 | + * @remark | ||
| 10 | + * @param {Object} params 请求参数 | ||
| 11 | + * @returns {Promise<{ | ||
| 12 | + * code: number; // 状态码 | ||
| 13 | + * msg: string; // 消息 | ||
| 14 | + * data: Array<{ | ||
| 15 | + * id: integer; // | ||
| 16 | + * name: string; // | ||
| 17 | + * seq: integer; // | ||
| 18 | + * link: string; // | ||
| 19 | + * icon: string; // | ||
| 20 | + * }>; | ||
| 21 | + * }>} | ||
| 22 | + */ | ||
| 23 | +export const homeIconAPI = (params) => fn(fetch.get(Api.HomeIcon, params)); |
| ... | @@ -13,7 +13,12 @@ const Api = { | ... | @@ -13,7 +13,12 @@ const Api = { |
| 13 | * @returns {Promise<{ | 13 | * @returns {Promise<{ |
| 14 | * code: number; // 状态码 | 14 | * code: number; // 状态码 |
| 15 | * msg: string; // 消息 | 15 | * msg: string; // 消息 |
| 16 | - * data: any; | 16 | + * data: Array<{ |
| 17 | + * id: integer; // 消息id | ||
| 18 | + * note: string; // 消息内容 | ||
| 19 | + * created_time: string; // 发消息的时间 | ||
| 20 | + * status: string; // send=以发送未读取,read=已读取 | ||
| 21 | + * }>; | ||
| 17 | * }>} | 22 | * }>} |
| 18 | */ | 23 | */ |
| 19 | export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); | 24 | export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); |
| ... | @@ -27,7 +32,12 @@ export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); | ... | @@ -27,7 +32,12 @@ export const detailAPI = (params) => fn(fetch.get(Api.Detail, params)); |
| 27 | * @returns {Promise<{ | 32 | * @returns {Promise<{ |
| 28 | * code: number; // 状态码 | 33 | * code: number; // 状态码 |
| 29 | * msg: string; // 消息 | 34 | * msg: string; // 消息 |
| 30 | - * data: any; | 35 | + * data: Array<{ |
| 36 | + * id: integer; // 消息id | ||
| 37 | + * note: string; // 消息内容 | ||
| 38 | + * created_time: string; // 发消息的时间 | ||
| 39 | + * status: string; // send=以发送未读取,read=已读取 | ||
| 40 | + * }>; | ||
| 31 | * }>} | 41 | * }>} |
| 32 | */ | 42 | */ |
| 33 | export const myListAPI = (params) => fn(fetch.get(Api.MyList, params)); | 43 | export const myListAPI = (params) => fn(fetch.get(Api.MyList, params)); | ... | ... |
| ... | @@ -91,9 +91,20 @@ | ... | @@ -91,9 +91,20 @@ |
| 91 | </view> | 91 | </view> |
| 92 | 92 | ||
| 93 | <!-- 空状态 --> | 93 | <!-- 空状态 --> |
| 94 | - <view v-if="currentList.length === 0"> | 94 | + <view v-if="currentList.length === 0 && !loading"> |
| 95 | <nut-empty description="暂无相关资料" image="empty" /> | 95 | <nut-empty description="暂无相关资料" image="empty" /> |
| 96 | </view> | 96 | </view> |
| 97 | + | ||
| 98 | + <!-- 加载更多状态 --> | ||
| 99 | + <view v-if="currentList.length > 0" class="load-more-container"> | ||
| 100 | + <view v-if="loadingMore" class="load-more-loading"> | ||
| 101 | + <view class="loading-spinner"></view> | ||
| 102 | + <text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text> | ||
| 103 | + </view> | ||
| 104 | + <view v-else-if="!hasMore" class="load-more-finished"> | ||
| 105 | + <text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text> | ||
| 106 | + </view> | ||
| 107 | + </view> | ||
| 97 | </view> | 108 | </view> |
| 98 | </view> | 109 | </view> |
| 99 | </view> | 110 | </view> |
| ... | @@ -102,7 +113,7 @@ | ... | @@ -102,7 +113,7 @@ |
| 102 | 113 | ||
| 103 | <script setup> | 114 | <script setup> |
| 104 | import { ref, computed, nextTick } from 'vue' | 115 | import { ref, computed, nextTick } from 'vue' |
| 105 | -import { useLoad } from '@tarojs/taro' | 116 | +import { useLoad, useReachBottom } from '@tarojs/taro' |
| 106 | import NavHeader from '@/components/NavHeader.vue' | 117 | import NavHeader from '@/components/NavHeader.vue' |
| 107 | import SearchBar from '@/components/SearchBar.vue' | 118 | import SearchBar from '@/components/SearchBar.vue' |
| 108 | import ListItemActions from '@/components/ListItemActions/index.vue' | 119 | import ListItemActions from '@/components/ListItemActions/index.vue' |
| ... | @@ -123,9 +134,31 @@ const listRenderKey = ref(0) | ... | @@ -123,9 +134,31 @@ const listRenderKey = ref(0) |
| 123 | const loading = ref(false) | 134 | const loading = ref(false) |
| 124 | 135 | ||
| 125 | /** | 136 | /** |
| 126 | - * API 返回的原始数据 | 137 | + * 加载更多状态 |
| 138 | + * @description 区分首次加载和加载更多 | ||
| 127 | */ | 139 | */ |
| 128 | -const data = ref(null) | 140 | +const loadingMore = ref(false) |
| 141 | + | ||
| 142 | +/** | ||
| 143 | + * 每页数量 | ||
| 144 | + */ | ||
| 145 | +const pageSize = 10 | ||
| 146 | + | ||
| 147 | +/** | ||
| 148 | + * 当前页码(从0开始) | ||
| 149 | + */ | ||
| 150 | +const currentPage = ref(0) | ||
| 151 | + | ||
| 152 | +/** | ||
| 153 | + * 是否有更多数据 | ||
| 154 | + */ | ||
| 155 | +const hasMore = ref(true) | ||
| 156 | + | ||
| 157 | +/** | ||
| 158 | + * 各分类的分页状态缓存 | ||
| 159 | + * @description Map<categoryId, { currentPage, hasMore }> | ||
| 160 | + */ | ||
| 161 | +const categoryPageCache = ref(new Map()) | ||
| 129 | 162 | ||
| 130 | /** | 163 | /** |
| 131 | * 初始分类ID(从页面参数获取) | 164 | * 初始分类ID(从页面参数获取) |
| ... | @@ -217,10 +250,18 @@ const transformDocItem = (doc) => { | ... | @@ -217,10 +250,18 @@ const transformDocItem = (doc) => { |
| 217 | * @param {string} params.cid - 分类ID(可选) | 250 | * @param {string} params.cid - 分类ID(可选) |
| 218 | * @param {string} params.child_id - 子分类ID(可选) | 251 | * @param {string} params.child_id - 子分类ID(可选) |
| 219 | * @param {string} params.keyword - 搜索关键词(可选) | 252 | * @param {string} params.keyword - 搜索关键词(可选) |
| 253 | + * @param {number} params.page - 页码(从0开始) | ||
| 254 | + * @param {number} params.limit - 每页数量 | ||
| 255 | + * @param {boolean} isLoadMore - 是否为加载更多 | ||
| 220 | */ | 256 | */ |
| 221 | -const fetchMaterialList = async (params = {}) => { | 257 | +const fetchMaterialList = async (params = {}, isLoadMore = false) => { |
| 222 | try { | 258 | try { |
| 223 | - loading.value = true | 259 | + // 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态 |
| 260 | + if (isLoadMore) { | ||
| 261 | + loadingMore.value = true | ||
| 262 | + } else { | ||
| 263 | + loading.value = true | ||
| 264 | + } | ||
| 224 | 265 | ||
| 225 | console.log('[Material List] 请求参数:', params) | 266 | console.log('[Material List] 请求参数:', params) |
| 226 | 267 | ||
| ... | @@ -229,7 +270,7 @@ const fetchMaterialList = async (params = {}) => { | ... | @@ -229,7 +270,7 @@ const fetchMaterialList = async (params = {}) => { |
| 229 | 270 | ||
| 230 | if (res.code === 1 && res.data) { | 271 | if (res.code === 1 && res.data) { |
| 231 | // 如果是初始请求(没有 child_id),保存完整的分类信息 | 272 | // 如果是初始请求(没有 child_id),保存完整的分类信息 |
| 232 | - if (!params.child_id) { | 273 | + if (!params.child_id && !params.keyword) { |
| 233 | data.value = res.data | 274 | data.value = res.data |
| 234 | console.log('[Material List] 数据:', res.data) | 275 | console.log('[Material List] 数据:', res.data) |
| 235 | console.log('[Material List] 分类数量:', res.data.children?.length) | 276 | console.log('[Material List] 分类数量:', res.data.children?.length) |
| ... | @@ -238,20 +279,53 @@ const fetchMaterialList = async (params = {}) => { | ... | @@ -238,20 +279,53 @@ const fetchMaterialList = async (params = {}) => { |
| 238 | // 处理并缓存"全部"列表 | 279 | // 处理并缓存"全部"列表 |
| 239 | if (res.data.list?.length) { | 280 | if (res.data.list?.length) { |
| 240 | const allListData = res.data.list.map(transformDocItem) | 281 | const allListData = res.data.list.map(transformDocItem) |
| 241 | - allList.value = allListData | 282 | + |
| 242 | - categoryListCache.value.set('all', allListData) | 283 | + if (isLoadMore) { |
| 284 | + // 加载更多:追加数据 | ||
| 285 | + allList.value = [...allList.value, ...allListData] | ||
| 286 | + categoryListCache.value.set('all', allList.value) | ||
| 287 | + } else { | ||
| 288 | + // 首次加载:替换数据 | ||
| 289 | + allList.value = allListData | ||
| 290 | + categoryListCache.value.set('all', allListData) | ||
| 291 | + } | ||
| 292 | + | ||
| 293 | + // 判断是否还有更多数据 | ||
| 294 | + hasMore.value = allListData.length >= params.limit | ||
| 295 | + } else { | ||
| 296 | + if (isLoadMore) { | ||
| 297 | + hasMore.value = false | ||
| 298 | + } else { | ||
| 299 | + allList.value = [] | ||
| 300 | + } | ||
| 243 | } | 301 | } |
| 244 | } else { | 302 | } else { |
| 245 | - // 是子分类请求,缓存该分类的列表数据 | 303 | + // 是子分类请求或搜索请求 |
| 246 | - const childId = params.child_id | 304 | + const cacheKey = params.child_id || params.keyword || 'search' |
| 305 | + | ||
| 247 | if (res.data.list?.length) { | 306 | if (res.data.list?.length) { |
| 248 | const listData = res.data.list.map(transformDocItem) | 307 | const listData = res.data.list.map(transformDocItem) |
| 249 | - categoryListCache.value.set(childId, listData) | 308 | + |
| 250 | - // 更新当前显示的列表 | 309 | + if (isLoadMore) { |
| 251 | - currentList.value = listData | 310 | + // 加载更多:追加数据 |
| 311 | + const existingData = categoryListCache.value.get(cacheKey) || [] | ||
| 312 | + const newData = [...existingData, ...listData] | ||
| 313 | + categoryListCache.value.set(cacheKey, newData) | ||
| 314 | + currentList.value = newData | ||
| 315 | + } else { | ||
| 316 | + // 首次加载:替换数据 | ||
| 317 | + categoryListCache.value.set(cacheKey, listData) | ||
| 318 | + currentList.value = listData | ||
| 319 | + } | ||
| 320 | + | ||
| 321 | + // 判断是否还有更多数据 | ||
| 322 | + hasMore.value = listData.length >= params.limit | ||
| 252 | } else { | 323 | } else { |
| 253 | - // 该分类没有数据 | 324 | + if (isLoadMore) { |
| 254 | - currentList.value = [] | 325 | + hasMore.value = false |
| 326 | + } else { | ||
| 327 | + currentList.value = [] | ||
| 328 | + } | ||
| 255 | } | 329 | } |
| 256 | } | 330 | } |
| 257 | } else { | 331 | } else { |
| ... | @@ -269,7 +343,11 @@ const fetchMaterialList = async (params = {}) => { | ... | @@ -269,7 +343,11 @@ const fetchMaterialList = async (params = {}) => { |
| 269 | duration: 2000 | 343 | duration: 2000 |
| 270 | }) | 344 | }) |
| 271 | } finally { | 345 | } finally { |
| 272 | - loading.value = false | 346 | + if (isLoadMore) { |
| 347 | + loadingMore.value = false | ||
| 348 | + } else { | ||
| 349 | + loading.value = false | ||
| 350 | + } | ||
| 273 | } | 351 | } |
| 274 | } | 352 | } |
| 275 | 353 | ||
| ... | @@ -289,8 +367,16 @@ useLoad(async (options) => { | ... | @@ -289,8 +367,16 @@ useLoad(async (options) => { |
| 289 | pageTitle.value = options.title | 367 | pageTitle.value = options.title |
| 290 | } | 368 | } |
| 291 | 369 | ||
| 370 | + // 重置分页状态 | ||
| 371 | + currentPage.value = 0 | ||
| 372 | + hasMore.value = true | ||
| 373 | + | ||
| 292 | // 获取资料列表(初始请求) | 374 | // 获取资料列表(初始请求) |
| 293 | - await fetchMaterialList({ cid: options.id }) | 375 | + await fetchMaterialList({ |
| 376 | + cid: options.id, | ||
| 377 | + page: 0, | ||
| 378 | + limit: pageSize | ||
| 379 | + }) | ||
| 294 | 380 | ||
| 295 | // 初始化当前列表为"全部"列表(等待请求完成后) | 381 | // 初始化当前列表为"全部"列表(等待请求完成后) |
| 296 | currentList.value = allList.value | 382 | currentList.value = allList.value |
| ... | @@ -304,6 +390,16 @@ const onTabClick = async (id) => { | ... | @@ -304,6 +390,16 @@ const onTabClick = async (id) => { |
| 304 | activeTabId.value = id | 390 | activeTabId.value = id |
| 305 | listVisible.value = false | 391 | listVisible.value = false |
| 306 | 392 | ||
| 393 | + // 恢复或初始化该分类的分页状态 | ||
| 394 | + const pageState = categoryPageCache.value.get(id) | ||
| 395 | + if (pageState) { | ||
| 396 | + currentPage.value = pageState.currentPage | ||
| 397 | + hasMore.value = pageState.hasMore | ||
| 398 | + } else { | ||
| 399 | + currentPage.value = 0 | ||
| 400 | + hasMore.value = true | ||
| 401 | + } | ||
| 402 | + | ||
| 307 | // 判断是否是"全部" tab | 403 | // 判断是否是"全部" tab |
| 308 | if (id === 'all') { | 404 | if (id === 'all') { |
| 309 | // 显示"全部"列表(从缓存或 allList) | 405 | // 显示"全部"列表(从缓存或 allList) |
| ... | @@ -315,10 +411,18 @@ const onTabClick = async (id) => { | ... | @@ -315,10 +411,18 @@ const onTabClick = async (id) => { |
| 315 | // 从缓存中获取 | 411 | // 从缓存中获取 |
| 316 | currentList.value = categoryListCache.value.get(id) | 412 | currentList.value = categoryListCache.value.get(id) |
| 317 | } else { | 413 | } else { |
| 318 | - // 调用接口获取该分类的列表 | 414 | + // 调用接口获取该分类的列表(第一页) |
| 319 | await fetchMaterialList({ | 415 | await fetchMaterialList({ |
| 320 | cid: initialCategoryId.value, | 416 | cid: initialCategoryId.value, |
| 321 | - child_id: id | 417 | + child_id: id, |
| 418 | + page: 0, | ||
| 419 | + limit: pageSize | ||
| 420 | + }) | ||
| 421 | + | ||
| 422 | + // 保存分页状态 | ||
| 423 | + categoryPageCache.value.set(id, { | ||
| 424 | + currentPage: 0, | ||
| 425 | + hasMore: hasMore.value | ||
| 322 | }) | 426 | }) |
| 323 | } | 427 | } |
| 324 | } | 428 | } |
| ... | @@ -330,6 +434,68 @@ const onTabClick = async (id) => { | ... | @@ -330,6 +434,68 @@ const onTabClick = async (id) => { |
| 330 | } | 434 | } |
| 331 | 435 | ||
| 332 | /** | 436 | /** |
| 437 | + * 触底加载更多 | ||
| 438 | + * @description 使用防抖避免频繁触发 | ||
| 439 | + */ | ||
| 440 | +let loadMoreTimer = null | ||
| 441 | +useReachBottom(() => { | ||
| 442 | + // 如果正在加载或没有更多数据,不执行 | ||
| 443 | + if (loadingMore.value || loading.value || !hasMore.value) { | ||
| 444 | + return | ||
| 445 | + } | ||
| 446 | + | ||
| 447 | + // 防抖:300ms 内只触发一次 | ||
| 448 | + if (loadMoreTimer) { | ||
| 449 | + clearTimeout(loadMoreTimer) | ||
| 450 | + } | ||
| 451 | + | ||
| 452 | + loadMoreTimer = setTimeout(async () => { | ||
| 453 | + console.log('[Material List] 触底加载更多') | ||
| 454 | + | ||
| 455 | + // 页码 +1 | ||
| 456 | + currentPage.value += 1 | ||
| 457 | + | ||
| 458 | + // 构建请求参数 | ||
| 459 | + const params = { | ||
| 460 | + cid: initialCategoryId.value, | ||
| 461 | + page: currentPage.value, | ||
| 462 | + limit: pageSize | ||
| 463 | + } | ||
| 464 | + | ||
| 465 | + // 判断当前状态:搜索、子分类、或全部 | ||
| 466 | + const isSearching = searchValue.value.trim() !== '' | ||
| 467 | + | ||
| 468 | + if (isSearching) { | ||
| 469 | + // 搜索模式 | ||
| 470 | + params.keyword = searchValue.value.trim() | ||
| 471 | + if (activeTabId.value !== 'all') { | ||
| 472 | + params.child_id = activeTabId.value | ||
| 473 | + } | ||
| 474 | + } else { | ||
| 475 | + // 非搜索模式:如果当前选中的是子分类,添加 child_id 参数 | ||
| 476 | + if (activeTabId.value !== 'all') { | ||
| 477 | + params.child_id = activeTabId.value | ||
| 478 | + } | ||
| 479 | + } | ||
| 480 | + | ||
| 481 | + // 加载下一页数据 | ||
| 482 | + await fetchMaterialList(params, true) // true 表示加载更多 | ||
| 483 | + | ||
| 484 | + // 保存更新后的分页状态 | ||
| 485 | + let cacheKey | ||
| 486 | + if (isSearching) { | ||
| 487 | + cacheKey = params.keyword | ||
| 488 | + } else { | ||
| 489 | + cacheKey = activeTabId.value !== 'all' ? activeTabId.value : 'all' | ||
| 490 | + } | ||
| 491 | + categoryPageCache.value.set(cacheKey, { | ||
| 492 | + currentPage: currentPage.value, | ||
| 493 | + hasMore: hasMore.value | ||
| 494 | + }) | ||
| 495 | + }, 300) | ||
| 496 | +}) | ||
| 497 | + | ||
| 498 | +/** | ||
| 333 | * 搜索处理函数 | 499 | * 搜索处理函数 |
| 334 | * @description 根据 child_id 和 keyword 调用接口查询列表 | 500 | * @description 根据 child_id 和 keyword 调用接口查询列表 |
| 335 | */ | 501 | */ |
| ... | @@ -351,16 +517,27 @@ const onSearch = async () => { | ... | @@ -351,16 +517,27 @@ const onSearch = async () => { |
| 351 | // 如果缓存中没有,调用接口获取 | 517 | // 如果缓存中没有,调用接口获取 |
| 352 | await fetchMaterialList({ | 518 | await fetchMaterialList({ |
| 353 | cid: initialCategoryId.value, | 519 | cid: initialCategoryId.value, |
| 354 | - child_id: activeTabId.value | 520 | + child_id: activeTabId.value, |
| 521 | + page: 0, | ||
| 522 | + limit: pageSize | ||
| 355 | }) | 523 | }) |
| 356 | } | 524 | } |
| 357 | } | 525 | } |
| 526 | + | ||
| 527 | + // 恢复分页状态 | ||
| 528 | + const pageState = categoryPageCache.value.get(activeTabId.value) | ||
| 529 | + if (pageState) { | ||
| 530 | + currentPage.value = pageState.currentPage | ||
| 531 | + hasMore.value = pageState.hasMore | ||
| 532 | + } | ||
| 358 | return | 533 | return |
| 359 | } | 534 | } |
| 360 | 535 | ||
| 361 | // 构建请求参数 | 536 | // 构建请求参数 |
| 362 | const params = { | 537 | const params = { |
| 363 | - cid: initialCategoryId.value | 538 | + cid: initialCategoryId.value, |
| 539 | + page: 0, | ||
| 540 | + limit: pageSize | ||
| 364 | } | 541 | } |
| 365 | 542 | ||
| 366 | // 如果当前选中的是子分类,添加 child_id 参数 | 543 | // 如果当前选中的是子分类,添加 child_id 参数 |
| ... | @@ -371,6 +548,10 @@ const onSearch = async () => { | ... | @@ -371,6 +548,10 @@ const onSearch = async () => { |
| 371 | // 添加搜索关键词 | 548 | // 添加搜索关键词 |
| 372 | params.keyword = searchValue.value.trim() | 549 | params.keyword = searchValue.value.trim() |
| 373 | 550 | ||
| 551 | + // 重置分页状态 | ||
| 552 | + currentPage.value = 0 | ||
| 553 | + hasMore.value = true | ||
| 554 | + | ||
| 374 | // 调用接口搜索 | 555 | // 调用接口搜索 |
| 375 | try { | 556 | try { |
| 376 | loading.value = true | 557 | loading.value = true |
| ... | @@ -380,8 +561,15 @@ const onSearch = async () => { | ... | @@ -380,8 +561,15 @@ const onSearch = async () => { |
| 380 | if (res.data.list?.length) { | 561 | if (res.data.list?.length) { |
| 381 | const listData = res.data.list.map(transformDocItem) | 562 | const listData = res.data.list.map(transformDocItem) |
| 382 | currentList.value = listData | 563 | currentList.value = listData |
| 564 | + | ||
| 565 | + // 缓存搜索结果 | ||
| 566 | + categoryListCache.value.set(params.keyword, listData) | ||
| 567 | + | ||
| 568 | + // 判断是否还有更多数据 | ||
| 569 | + hasMore.value = listData.length >= pageSize | ||
| 383 | } else { | 570 | } else { |
| 384 | currentList.value = [] | 571 | currentList.value = [] |
| 572 | + hasMore.value = false | ||
| 385 | } | 573 | } |
| 386 | } else { | 574 | } else { |
| 387 | Taro.showToast({ | 575 | Taro.showToast({ |
| ... | @@ -566,4 +754,41 @@ const onDelete = (item) => { | ... | @@ -566,4 +754,41 @@ const onDelete = (item) => { |
| 566 | :deep(.nut-tabs__content) { | 754 | :deep(.nut-tabs__content) { |
| 567 | display: none; | 755 | display: none; |
| 568 | } | 756 | } |
| 757 | + | ||
| 758 | +// 加载更多容器 | ||
| 759 | +.load-more-container { | ||
| 760 | + display: flex; | ||
| 761 | + justify-content: center; | ||
| 762 | + align-items: center; | ||
| 763 | + padding: 40rpx 0; | ||
| 764 | + min-height: 80rpx; | ||
| 765 | +} | ||
| 766 | + | ||
| 767 | +.load-more-loading { | ||
| 768 | + display: flex; | ||
| 769 | + align-items: center; | ||
| 770 | + justify-content: center; | ||
| 771 | +} | ||
| 772 | + | ||
| 773 | +.load-more-finished { | ||
| 774 | + display: flex; | ||
| 775 | + align-items: center; | ||
| 776 | + justify-content: center; | ||
| 777 | +} | ||
| 778 | + | ||
| 779 | +// 自定义加载动画 | ||
| 780 | +.loading-spinner { | ||
| 781 | + width: 32rpx; | ||
| 782 | + height: 32rpx; | ||
| 783 | + border: 4rpx solid #E5E7EB; | ||
| 784 | + border-top-color: #2563EB; | ||
| 785 | + border-radius: 50%; | ||
| 786 | + animation: spin 0.8s linear infinite; | ||
| 787 | +} | ||
| 788 | + | ||
| 789 | +@keyframes spin { | ||
| 790 | + to { | ||
| 791 | + transform: rotate(360deg); | ||
| 792 | + } | ||
| 793 | +} | ||
| 569 | </style> | 794 | </style> | ... | ... |
-
Please register or login to post a comment