hookehuyr

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>
# 首页图标列表
## OpenAPI Specification
```yaml
openapi: 3.0.1
info:
title: ''
version: 1.0.0
paths:
/srv/:
get:
summary: 首页图标列表
deprecated: false
description: ''
tags: []
parameters:
- name: f
in: query
description: ''
required: true
example: manulife
schema:
type: string
- name: a
in: query
description: ''
required: true
example: home_icon
schema:
type: string
- name: t
in: query
description: ''
required: false
example: icon
schema:
type: string
responses:
'200':
description: ''
content:
application/json:
schema:
type: object
properties:
code:
type: integer
msg:
type: integer
data:
type: array
items:
type: object
properties:
id:
type: integer
name:
type: string
seq:
type: integer
link:
type: string
icon:
type: string
required:
- id
- name
- seq
- link
- icon
x-apifox-orders:
- id
- name
- link
- icon
- seq
required:
- code
- msg
- data
x-apifox-orders:
- code
- msg
- data
example:
code: 1
msg: 0
data:
- id: 3134682
name: 计划书
seq: 1
link: /pages/plan/index
icon: >-
https://cdn.ipadbiz.cn/space_3079606/文件_FsINuaz2bYXHzNoAsNIfTV0SnPC-.png
- id: 3134691
name: 入职相关
seq: 2
link: /pages/category-list/index?cid=3129684
icon: >-
https://cdn.ipadbiz.cn/space_3079606/感叹号_FnljRoegoDuH-kkSum55sKCExoIl.png
- id: 3134697
name: 签单相关
seq: 3
link: /pages/category-list/index?cid=3134692
icon: >-
https://cdn.ipadbiz.cn/space_3079606/勾_Fvc4L0IOPkfDqxCcTHbVtwZCjIaR.png
- id: 3134699
name: 家办相关
seq: 4
link: /pages/category-list/index?cid=3134693
icon: >-
https://cdn.ipadbiz.cn/space_3079606/帮助_Fmf73oRbxLMAB7ptz7DheIWdidV_.png
- id: 3134702
name: 产品知识库
seq: 5
link: /pages/category-list/index?cid=3134694
icon: >-
https://cdn.ipadbiz.cn/space_3079606/file-list-3-fill_Fpv2uhm46FuDgHnmvkMM89eKv4eL.png
- id: 3134711
name: 工具箱
seq: 6
link: /pages/category-list/index?cid=3134695
icon: >-
https://cdn.ipadbiz.cn/space_3079606/服务_Fr6i22Usft4N7F-uEQQu4VRjlWpu.png
headers: {}
x-apifox-name: 成功
x-apifox-ordering: 0
security: []
x-apifox-folder: ''
x-apifox-status: released
x-run-in-apifox: https://app.apifox.com/web/project/7792797/apis/api-415955157-run
components:
schemas: {}
responses: {}
securitySchemes: {}
servers:
- url: https://manulife.onwall.cn
description: 正式环境
security: []
```
......@@ -381,7 +381,12 @@ function generateReturnJSDoc(responseSchema) {
returnDesc += ' * code: number; // 状态码\n';
returnDesc += ' * msg: string; // 消息\n';
if (data && data.properties) {
if (data) {
const dataType = data.type || 'any';
const dataDesc = data.description || data.title || '';
// 处理对象类型的 data
if (dataType === 'object' && data.properties) {
returnDesc += ' * data: {\n';
Object.entries(data.properties).forEach(([key, value]) => {
......@@ -410,6 +415,28 @@ function generateReturnJSDoc(responseSchema) {
});
returnDesc += ' * };\n';
}
// 处理数组类型的 data(你的情况)
else if (dataType === 'array' && data.items && data.items.properties) {
returnDesc += ' * data: Array<{\n';
Object.entries(data.items.properties).forEach(([key, value]) => {
const type = value.type || 'any';
const desc = value.description || value.title || '';
returnDesc += ` * ${key}: ${type}; // ${desc}\n`;
});
returnDesc += ' * }>;\n';
}
// 处理简单数组类型
else if (dataType === 'array' && data.items) {
const itemType = data.items.type || 'any';
returnDesc += ` * data: Array<${itemType}>;\n`;
}
// 其他类型
else {
returnDesc += ` * data: ${dataType};\n`;
}
} else {
returnDesc += ' * data: any;\n';
}
......@@ -709,11 +736,14 @@ function compareAPIChanges(openAPIDir) {
try {
const newDocs = parseOpenAPIPath(moduleDir);
if (newDocs && newDocs.length > 0) {
// 使用 extractAPIInfo 提取 API 信息
const apiInfos = newDocs.map(doc => extractAPIInfo(doc));
console.log(` 包含 ${apiInfos.length} 个新增接口:`);
apiInfos.forEach(api => {
console.log(` • ${api.method} ${api.path} - ${api.summary || api.name}`);
// 显示新增接口信息
console.log(` 包含 ${newDocs.length} 个新增接口:`);
newDocs.forEach(doc => {
const path = Object.keys(doc.paths || {})[0] || '';
const method = Object.keys(doc.paths?.[path] || {})[0] || '';
const apiInfo = doc.paths?.[path]?.[method];
const summary = apiInfo?.summary || doc.info?.title || '未命名接口';
console.log(` • ${method?.toUpperCase()} ${path} - ${summary}`);
});
}
} catch (error) {
......
import { fn, fetch } from '@/api/fn';
const Api = {
HomeIcon: '/srv/?a=home_icon&t=icon',
}
/**
* @description 首页图标列表
* @remark
* @param {Object} params 请求参数
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: Array<{
* id: integer; //
* name: string; //
* seq: integer; //
* link: string; //
* icon: string; //
* }>;
* }>}
*/
export const homeIconAPI = (params) => fn(fetch.get(Api.HomeIcon, params));
......@@ -13,7 +13,12 @@ const Api = {
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: any;
* data: Array<{
* id: integer; // 消息id
* note: string; // 消息内容
* created_time: string; // 发消息的时间
* status: string; // send=以发送未读取,read=已读取
* }>;
* }>}
*/
export const detailAPI = (params) => fn(fetch.get(Api.Detail, params));
......@@ -27,7 +32,12 @@ export const detailAPI = (params) => fn(fetch.get(Api.Detail, params));
* @returns {Promise<{
* code: number; // 状态码
* msg: string; // 消息
* data: any;
* data: Array<{
* id: integer; // 消息id
* note: string; // 消息内容
* created_time: string; // 发消息的时间
* status: string; // send=以发送未读取,read=已读取
* }>;
* }>}
*/
export const myListAPI = (params) => fn(fetch.get(Api.MyList, params));
......
......@@ -91,9 +91,20 @@
</view>
<!-- 空状态 -->
<view v-if="currentList.length === 0">
<view v-if="currentList.length === 0 && !loading">
<nut-empty description="暂无相关资料" image="empty" />
</view>
<!-- 加载更多状态 -->
<view v-if="currentList.length > 0" class="load-more-container">
<view v-if="loadingMore" class="load-more-loading">
<view class="loading-spinner"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</view>
<view v-else-if="!hasMore" class="load-more-finished">
<text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text>
</view>
</view>
</view>
</view>
</view>
......@@ -102,7 +113,7 @@
<script setup>
import { ref, computed, nextTick } from 'vue'
import { useLoad } from '@tarojs/taro'
import { useLoad, useReachBottom } from '@tarojs/taro'
import NavHeader from '@/components/NavHeader.vue'
import SearchBar from '@/components/SearchBar.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
......@@ -123,9 +134,31 @@ const listRenderKey = ref(0)
const loading = ref(false)
/**
* API 返回的原始数据
* 加载更多状态
* @description 区分首次加载和加载更多
*/
const loadingMore = ref(false)
/**
* 每页数量
*/
const pageSize = 10
/**
* 当前页码(从0开始)
*/
const currentPage = ref(0)
/**
* 是否有更多数据
*/
const data = ref(null)
const hasMore = ref(true)
/**
* 各分类的分页状态缓存
* @description Map<categoryId, { currentPage, hasMore }>
*/
const categoryPageCache = ref(new Map())
/**
* 初始分类ID(从页面参数获取)
......@@ -217,10 +250,18 @@ const transformDocItem = (doc) => {
* @param {string} params.cid - 分类ID(可选)
* @param {string} params.child_id - 子分类ID(可选)
* @param {string} params.keyword - 搜索关键词(可选)
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
*/
const fetchMaterialList = async (params = {}) => {
const fetchMaterialList = async (params = {}, isLoadMore = false) => {
try {
// 如果是加载更多,使用 loadingMore 状态,否则使用 loading 状态
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
}
console.log('[Material List] 请求参数:', params)
......@@ -229,7 +270,7 @@ const fetchMaterialList = async (params = {}) => {
if (res.code === 1 && res.data) {
// 如果是初始请求(没有 child_id),保存完整的分类信息
if (!params.child_id) {
if (!params.child_id && !params.keyword) {
data.value = res.data
console.log('[Material List] 数据:', res.data)
console.log('[Material List] 分类数量:', res.data.children?.length)
......@@ -238,22 +279,55 @@ const fetchMaterialList = async (params = {}) => {
// 处理并缓存"全部"列表
if (res.data.list?.length) {
const allListData = res.data.list.map(transformDocItem)
if (isLoadMore) {
// 加载更多:追加数据
allList.value = [...allList.value, ...allListData]
categoryListCache.value.set('all', allList.value)
} else {
// 首次加载:替换数据
allList.value = allListData
categoryListCache.value.set('all', allListData)
}
// 判断是否还有更多数据
hasMore.value = allListData.length >= params.limit
} else {
if (isLoadMore) {
hasMore.value = false
} else {
allList.value = []
}
}
} else {
// 是子分类请求,缓存该分类的列表数据
const childId = params.child_id
// 是子分类请求或搜索请求
const cacheKey = params.child_id || params.keyword || 'search'
if (res.data.list?.length) {
const listData = res.data.list.map(transformDocItem)
categoryListCache.value.set(childId, listData)
// 更新当前显示的列表
if (isLoadMore) {
// 加载更多:追加数据
const existingData = categoryListCache.value.get(cacheKey) || []
const newData = [...existingData, ...listData]
categoryListCache.value.set(cacheKey, newData)
currentList.value = newData
} else {
// 首次加载:替换数据
categoryListCache.value.set(cacheKey, listData)
currentList.value = listData
}
// 判断是否还有更多数据
hasMore.value = listData.length >= params.limit
} else {
if (isLoadMore) {
hasMore.value = false
} else {
// 该分类没有数据
currentList.value = []
}
}
}
} else {
Taro.showToast({
title: res.msg || '获取资料列表失败',
......@@ -269,8 +343,12 @@ const fetchMaterialList = async (params = {}) => {
duration: 2000
})
} finally {
if (isLoadMore) {
loadingMore.value = false
} else {
loading.value = false
}
}
}
/**
......@@ -289,8 +367,16 @@ useLoad(async (options) => {
pageTitle.value = options.title
}
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 获取资料列表(初始请求)
await fetchMaterialList({ cid: options.id })
await fetchMaterialList({
cid: options.id,
page: 0,
limit: pageSize
})
// 初始化当前列表为"全部"列表(等待请求完成后)
currentList.value = allList.value
......@@ -304,6 +390,16 @@ const onTabClick = async (id) => {
activeTabId.value = id
listVisible.value = false
// 恢复或初始化该分类的分页状态
const pageState = categoryPageCache.value.get(id)
if (pageState) {
currentPage.value = pageState.currentPage
hasMore.value = pageState.hasMore
} else {
currentPage.value = 0
hasMore.value = true
}
// 判断是否是"全部" tab
if (id === 'all') {
// 显示"全部"列表(从缓存或 allList)
......@@ -315,10 +411,18 @@ const onTabClick = async (id) => {
// 从缓存中获取
currentList.value = categoryListCache.value.get(id)
} else {
// 调用接口获取该分类的列表
// 调用接口获取该分类的列表(第一页)
await fetchMaterialList({
cid: initialCategoryId.value,
child_id: id
child_id: id,
page: 0,
limit: pageSize
})
// 保存分页状态
categoryPageCache.value.set(id, {
currentPage: 0,
hasMore: hasMore.value
})
}
}
......@@ -330,6 +434,68 @@ const onTabClick = async (id) => {
}
/**
* 触底加载更多
* @description 使用防抖避免频繁触发
*/
let loadMoreTimer = null
useReachBottom(() => {
// 如果正在加载或没有更多数据,不执行
if (loadingMore.value || loading.value || !hasMore.value) {
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(async () => {
console.log('[Material List] 触底加载更多')
// 页码 +1
currentPage.value += 1
// 构建请求参数
const params = {
cid: initialCategoryId.value,
page: currentPage.value,
limit: pageSize
}
// 判断当前状态:搜索、子分类、或全部
const isSearching = searchValue.value.trim() !== ''
if (isSearching) {
// 搜索模式
params.keyword = searchValue.value.trim()
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
} else {
// 非搜索模式:如果当前选中的是子分类,添加 child_id 参数
if (activeTabId.value !== 'all') {
params.child_id = activeTabId.value
}
}
// 加载下一页数据
await fetchMaterialList(params, true) // true 表示加载更多
// 保存更新后的分页状态
let cacheKey
if (isSearching) {
cacheKey = params.keyword
} else {
cacheKey = activeTabId.value !== 'all' ? activeTabId.value : 'all'
}
categoryPageCache.value.set(cacheKey, {
currentPage: currentPage.value,
hasMore: hasMore.value
})
}, 300)
})
/**
* 搜索处理函数
* @description 根据 child_id 和 keyword 调用接口查询列表
*/
......@@ -351,16 +517,27 @@ const onSearch = async () => {
// 如果缓存中没有,调用接口获取
await fetchMaterialList({
cid: initialCategoryId.value,
child_id: activeTabId.value
child_id: activeTabId.value,
page: 0,
limit: pageSize
})
}
}
// 恢复分页状态
const pageState = categoryPageCache.value.get(activeTabId.value)
if (pageState) {
currentPage.value = pageState.currentPage
hasMore.value = pageState.hasMore
}
return
}
// 构建请求参数
const params = {
cid: initialCategoryId.value
cid: initialCategoryId.value,
page: 0,
limit: pageSize
}
// 如果当前选中的是子分类,添加 child_id 参数
......@@ -371,6 +548,10 @@ const onSearch = async () => {
// 添加搜索关键词
params.keyword = searchValue.value.trim()
// 重置分页状态
currentPage.value = 0
hasMore.value = true
// 调用接口搜索
try {
loading.value = true
......@@ -380,8 +561,15 @@ const onSearch = async () => {
if (res.data.list?.length) {
const listData = res.data.list.map(transformDocItem)
currentList.value = listData
// 缓存搜索结果
categoryListCache.value.set(params.keyword, listData)
// 判断是否还有更多数据
hasMore.value = listData.length >= pageSize
} else {
currentList.value = []
hasMore.value = false
}
} else {
Taro.showToast({
......@@ -566,4 +754,41 @@ const onDelete = (item) => {
:deep(.nut-tabs__content) {
display: none;
}
// 加载更多容器
.load-more-container {
display: flex;
justify-content: center;
align-items: center;
padding: 40rpx 0;
min-height: 80rpx;
}
.load-more-loading {
display: flex;
align-items: center;
justify-content: center;
}
.load-more-finished {
display: flex;
align-items: center;
justify-content: center;
}
// 自定义加载动画
.loading-spinner {
width: 32rpx;
height: 32rpx;
border: 4rpx solid #E5E7EB;
border-top-color: #2563EB;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
......