pull-apifox 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +156 -23
  2. package/lib/index.js +250 -120
  3. package/package.json +9 -3
package/README.md CHANGED
@@ -10,7 +10,7 @@ npm install -g pull-apifox
10
10
 
11
11
  ## 快速开始
12
12
 
13
- ### 1. 在项目根目录创建 `apifox.config.json`
13
+ ### 1. 创建配置文件 `apifox.config.json`
14
14
 
15
15
  ```json
16
16
  {
@@ -21,7 +21,11 @@ npm install -g pull-apifox
21
21
  "importPath": "@/utils/request",
22
22
 
23
23
  "pull": [
24
- { "tags": ["移库单管理"], "name": "womeiTransfer", "prefix": "/chainova/womei" }
24
+ {
25
+ "tags": ["移库单管理"],
26
+ "name": "womeiTransfer",
27
+ "prefix": "/chainova/wms"
28
+ }
25
29
  ]
26
30
  }
27
31
  ```
@@ -34,45 +38,146 @@ pull-apifox
34
38
  pull-apifox ./path/to/apifox.config.json
35
39
  ```
36
40
 
37
- ## 配置项
41
+ ## 配置文件
42
+
43
+ ### 顶层配置
38
44
 
39
45
  | 字段 | 类型 | 默认值 | 说明 |
40
46
  |------|------|--------|------|
41
- | `projectId` | string | **必填** | Apifox 项目 ID |
42
- | `accessToken` | string | — | Apifox 令牌,填了走云端 API |
47
+ | `projectId` | string | **必填** | Apifox 项目 ID,从项目 URL 中获取 |
48
+ | `accessToken` | string | — | Apifox 访问令牌,填了走云端 API,不填走本地客户端 |
43
49
  | `localPort` | number | `4523` | 本地客户端端口 |
44
- | `output` | string | `./src/apifox` | 输出目录 |
45
- | `showReturns` | boolean | `false` | 是否生成返回值 JSDoc |
46
- | `importPath` | string | `@/utils/request` | request 导入路径 |
47
- | `pull` | array | **必填** | 拉取任务列表 |
50
+ | `output` | string | `./src/apifox` | 默认输出目录 |
51
+ | `showReturns` | boolean | `false` | 是否在 JSDoc 中展开返回值类型 |
52
+ | `importPath` | string | `@/utils/request` | 生成的 import 语句中的 request 路径 |
53
+ | `pull` | array | **必填** | 拉取任务列表,每项生成一个 JS 文件 |
54
+
55
+ ### pull 筛选规则
56
+
57
+ 每项支持三个筛选维度,**folder 为 AND 划定范围,tags 与 apis 为 OR 取并集**:
58
+
59
+ | 字段 | 类型 | 说明 | 示例 |
60
+ |------|------|------|------|
61
+ | `folder` | string | Apifox 接口文档 ID(数字)或 URL 路径前缀 | `"83863684"` 或 `"/movement-orders"` |
62
+ | `tags` | string[] | Apifox 标签,支持层级模糊匹配 | `["移库单管理"]` 可匹配 `"沃美/移库单管理"` |
63
+ | `apis` | string[] | 接口名关键词;以 `/` 开头时精确匹配路径 | `["login"]` 或 `["/storage/location/list"]` |
64
+
65
+ ### pull 输出配置
66
+
67
+ | 字段 | 类型 | 说明 |
68
+ |------|------|------|
69
+ | `name` | string | 生成的文件名,如 `"user"` → `user.js`。指定后所有命中接口合并到此文件 |
70
+ | `prefix` | string | 请求路径前缀,会加到每个 URL 前面 |
71
+ | `output` | string | 可选的独立输出目录,覆盖顶层 `output` |
72
+
73
+ ### 筛选逻辑详解
74
+
75
+ ```
76
+ folder AND (tags OR apis)
77
+ ```
78
+
79
+ - **folder** 为 AND:先划定范围(接口文档 ID 通过 Apifox API scope 限定,路径字符串通过 URL 前缀匹配)
80
+ - **tags 与 apis** 为 OR:只要满足任一条件即命中
81
+ - 未指定的维度视为「全部匹配」
82
+
83
+ **`apis` 匹配规则:**
84
+
85
+ | 关键词格式 | 匹配方式 | 示例 |
86
+ |-----------|---------|------|
87
+ | 以 `/` 开头 | **精确路径** | `"/storage/location/list"` 只匹配该路径 |
88
+ | 其他 | **模糊匹配** | `"login"` 匹配 operationId、summary、路径末段 |
89
+
90
+ **`tags` 匹配规则:**
91
+
92
+ Apifox 导出的标签通常包含层级路径,如 `沃美/移库单管理`。配置 `"移库单管理"` 即可匹配,支持:
93
+ - 完全相等:`"移库单管理"` = `"移库单管理"`
94
+ - 末级匹配:`"移库单管理"` 匹配 `"沃美/移库单管理"`
95
+ - 中间层级:`"沃美"` 匹配 `"沃美/移库单管理"`
96
+
97
+ **`folder` 规则:**
98
+
99
+ - 纯数字(如 `"83863684"`)→ 作为 Apifox 接口文档 ID,通过 API scope 限定拉取范围;API 不支持时退化到全量拉取
100
+ - 路径字符串(如 `"/movement-orders"`)→ URL 前缀匹配,子路径自动包含
101
+
102
+ ## 配置示例
103
+
104
+ ### 按接口文档 ID 拉取
105
+
106
+ ```json
107
+ {
108
+ "projectId": "1577893",
109
+ "accessToken": "afxp_xxxxxx",
110
+ "output": "./src/apifox",
111
+
112
+ "pull": [
113
+ {
114
+ "folder": "83863684",
115
+ "tags": ["移库单管理"],
116
+ "apis": ["/storage/location/list"],
117
+ "name": "womeiTransfer",
118
+ "prefix": "/chainova/wms"
119
+ }
120
+ ]
121
+ }
122
+ ```
123
+
124
+ > `folder: "83863684"` 限定接口文档范围,`tags` + `apis` 在范围内取并集。
125
+
126
+ ### 按路径前缀拉取
127
+
128
+ ```json
129
+ {
130
+ "pull": [
131
+ {
132
+ "folder": "/movement-orders",
133
+ "name": "movement",
134
+ "prefix": "/chainova/wms"
135
+ }
136
+ ]
137
+ }
138
+ ```
48
139
 
49
- ### pull 配置
140
+ > 匹配所有 `/movement-orders/xxx` 下的接口。
50
141
 
51
- 每项生成一个 JS 文件,三个筛选维度**取交集**:
142
+ ### 多组拉取
52
143
 
53
144
  ```json
54
145
  {
55
- "tags": ["移库单管理"], // Apifox 文件夹名,支持层级模糊匹配
56
- "folder": "/movement-orders", // URL 路径前缀匹配
57
- "apis": ["login", "order"], // 接口名关键词
58
- "name": "womeiTransfer", // 生成文件名
59
- "prefix": "/chainova/womei" // 请求路径前缀
146
+ "pull": [
147
+ {
148
+ "tags": ["移库单管理"],
149
+ "name": "womeiTransfer",
150
+ "prefix": "/chainova/wms"
151
+ },
152
+ {
153
+ "apis": ["/lite/logical/inventory/transaction/page"],
154
+ "name": "transaction",
155
+ "prefix": "/chainova/wms"
156
+ },
157
+ {
158
+ "tags": ["项目管理"],
159
+ "name": "projectManager",
160
+ "prefix": "/chainova/srm"
161
+ }
162
+ ]
60
163
  }
61
164
  ```
62
165
 
63
- > `tags` 对应 Apifox 文件夹层级路径(如 `沃美/移库单管理`),配 `"移库单管理"` 即可匹配。
166
+ > folder 的组会复用缓存,避免重复拉取。
64
167
 
65
- ## 获取 Token
168
+ ## 获取 Access Token
66
169
 
67
- Apifox 网页 → 头像 → 账号设置 → 访问令牌 → 新建令牌。
170
+ 1. 打开 [Apifox](https://app.apifox.com)
171
+ 2. 右上角头像 → **账号设置** → **访问令牌**
172
+ 3. 点击 **新建令牌**,复制填入 `accessToken`
68
173
 
69
174
  ## 生成结果
70
175
 
71
176
  ```
72
177
  src/apifox/
73
- ├── index.js # 入口文件,聚合导出
178
+ ├── index.js # 入口,聚合导出
74
179
  └── api/
75
- └── womeiTransfer.js # 接口函数,带 JSDoc
180
+ └── womeiTransfer.js # 接口函数,带完整 JSDoc
76
181
  ```
77
182
 
78
183
  ### 生成代码示例
@@ -93,17 +198,45 @@ import request from '@/utils/request';
93
198
  * @param {number} data.items[].quantity - 数量
94
199
  * @returns {Promise<object>} - 响应数据
95
200
  * @param {number} returns.code - 状态码
96
- * @param {string} returns.msg - 消息
201
+ * @param {string} returns.message - 返回消息
97
202
  */
98
203
  export function createMovementOrder(data) {
99
204
  return request({
100
- url: '/chainova/womei/movement-orders/create',
205
+ url: '/chainova/wms/movement-orders/create',
101
206
  method: 'POST',
102
207
  data,
103
208
  });
104
209
  }
105
210
  ```
106
211
 
212
+ ### 函数参数命名规则
213
+
214
+ | 参数位置 | 形参名 | 说明 |
215
+ |----------|--------|------|
216
+ | 路径参数 `{id}` | `id` | 直接展开为独立参数 |
217
+ | 查询参数 `?page=1` | `params` | 所有 query 合并为一个 object |
218
+ | 请求体 | `data` | body 对象 |
219
+
220
+ ## 在业务代码中使用
221
+
222
+ ```js
223
+ // 从入口聚合导入
224
+ import { createMovementOrder, getMovementOrderList } from '@/apifox';
225
+
226
+ // 或按文件导入
227
+ import { createMovementOrder } from '@/apifox/api/womeiTransfer';
228
+ ```
229
+
230
+ ## 更新接口
231
+
232
+ Apifox 上接口变更后,重新运行即可覆盖生成文件:
233
+
234
+ ```bash
235
+ pull-apifox
236
+ ```
237
+
238
+ 生成文件头部已标注 `由 pull-apifox 自动生成,请勿手动修改`。建议通过修改配置文件来调整输出,而不是直接改生成文件。
239
+
107
240
  ## License
108
241
 
109
242
  MIT
package/lib/index.js CHANGED
@@ -53,10 +53,11 @@ async function fetchSpec(projectId, scope, config) {
53
53
  const token = config.accessToken;
54
54
 
55
55
  if (token) {
56
+ // 云端 API
56
57
  const url = `https://api.apifox.com/api/v1/projects/${projectId}/export-openapi`;
57
58
  console.log(` 📡 云端 API: ${projectId}`);
58
59
  const body = scope
59
- ? { exportFormat: 'OpenAPI3.0', scope }
60
+ ? { exportFormat: 'OpenAPI3.0', scope: { type: 'SELECTED_FOLDERS', folderIds: [scope] } }
60
61
  : { exportFormat: 'OpenAPI3.0' };
61
62
 
62
63
  return request(url, {
@@ -68,6 +69,7 @@ async function fetchSpec(projectId, scope, config) {
68
69
  },
69
70
  });
70
71
  } else {
72
+ // 本地客户端
71
73
  const port = config.localPort || 4523;
72
74
  const url = `http://127.0.0.1:${port}/export/openapi/${projectId}?version=3.0`;
73
75
  console.log(` 📡 本地客户端: ${url}`);
@@ -75,7 +77,7 @@ async function fetchSpec(projectId, scope, config) {
75
77
  try {
76
78
  return await request(url, {
77
79
  method: 'POST',
78
- body: scope ? { exportFormat: 'OpenAPI3.0', scope } : { exportFormat: 'OpenAPI3.0' },
80
+ body: scope ? { exportFormat: 'OpenAPI3.0', scope: { type: 'SELECTED_FOLDERS', folderIds: [scope] } } : { exportFormat: 'OpenAPI3.0' },
79
81
  });
80
82
  } catch {
81
83
  return request(url);
@@ -85,9 +87,20 @@ async function fetchSpec(projectId, scope, config) {
85
87
 
86
88
  // ======================== 筛选引擎 ========================
87
89
 
90
+ /**
91
+ * 检查接口名是否匹配
92
+ * - 以 / 开头 → 精确路径匹配
93
+ * - 否则 → 模糊匹配 operationId / summary / 路径末段 / 完整路径
94
+ */
88
95
  function matchApiName(operation, route, keyword) {
89
96
  const kw = keyword.toLowerCase();
90
97
 
98
+ // 关键词以 / 开头 → 精确路径匹配
99
+ if (keyword.startsWith('/')) {
100
+ return route.toLowerCase() === kw;
101
+ }
102
+
103
+ // 模糊匹配
91
104
  if (operation.operationId && operation.operationId.toLowerCase().includes(kw)) return true;
92
105
  if (operation.summary && operation.summary.toLowerCase().includes(kw)) return true;
93
106
 
@@ -98,6 +111,10 @@ function matchApiName(operation, route, keyword) {
98
111
  return false;
99
112
  }
100
113
 
114
+ /**
115
+ * 检查接口是否命中 tags 筛选
116
+ * 支持层级匹配:"移库单管理" → 匹配 "沃美/移库单管理"
117
+ */
101
118
  function matchTags(operation, tags) {
102
119
  if (!tags || tags.length === 0) return true;
103
120
  const opTags = (operation.tags || []).map(t => t.toLowerCase());
@@ -107,22 +124,47 @@ function matchTags(operation, tags) {
107
124
  });
108
125
  }
109
126
 
127
+ /**
128
+ * 检查接口路径是否在指定文件夹下
129
+ * - 数字 ID(如 "83863684")→ 由 API scope 层面处理,此处直接放行
130
+ * - 路径字符串 → URL 前缀匹配(如 folder="/用户/登录" 匹配 /用户/登录/xxx)
131
+ */
110
132
  function matchFolder(route, folder) {
111
133
  if (!folder) return true;
134
+ // 数字 ID(Apifox 接口文档 ID),非 URL 路径,放行由 scope 处理
135
+ if (/^\d+$/.test(folder)) return true;
136
+ // URL 路径前缀匹配
112
137
  const normalizedRoute = route.startsWith('/') ? route : `/${route}`;
113
138
  const normalizedFolder = folder.startsWith('/') ? folder : `/${folder}`;
114
139
  return normalizedRoute === normalizedFolder || normalizedRoute.startsWith(normalizedFolder + '/');
115
140
  }
116
141
 
142
+ /**
143
+ * 筛选逻辑:
144
+ * - folder 为 AND(划定范围,数字 ID 由 API scope 处理)
145
+ * - tags 与 apis 之间为 OR(取并集)
146
+ * - 两者都未指定时通过所有接口
147
+ */
117
148
  function matchAll(route, methodObj, operation, filters) {
149
+ // folder 筛选(AND)
118
150
  if (filters.folder && !matchFolder(route, filters.folder)) return false;
119
- if (filters.tags && filters.tags.length > 0 && !matchTags(operation, filters.tags)) return false;
120
- if (filters.apis && filters.apis.length > 0) {
121
- if (!filters.apis.some(kw => matchApiName(operation, route, kw))) return false;
122
- }
123
- return true;
151
+
152
+ const hasTags = filters.tags && filters.tags.length > 0;
153
+ const hasApis = filters.apis && filters.apis.length > 0;
154
+
155
+ // 既没指定 tags 也没指定 apis → 全部通过
156
+ if (!hasTags && !hasApis) return true;
157
+
158
+ // tags 与 apis 取并集(OR)
159
+ const matchT = hasTags && matchTags(operation, filters.tags);
160
+ const matchA = hasApis && filters.apis.some(kw => matchApiName(operation, route, kw));
161
+
162
+ return matchT || matchA;
124
163
  }
125
164
 
165
+ /**
166
+ * 对 OpenAPI spec 执行筛选,返回新的 spec
167
+ */
126
168
  function filterSpec(spec, filters) {
127
169
  const result = JSON.parse(JSON.stringify(spec));
128
170
  const allCount = Object.keys(result.paths || {}).length;
@@ -181,6 +223,139 @@ function toFuncName(operation, method, route) {
181
223
  return toCamel(`${method}_${route.replace(/[\/{}]/g, '_')}`);
182
224
  }
183
225
 
226
+ /**
227
+ * 写入单个接口函数到 lines 数组
228
+ */
229
+ function writeApiFunc(lines, route, method, operation, spec, showReturns, pathPrefix, reqImport) {
230
+ const funcName = toFuncName(operation, method, route);
231
+
232
+ const pathParams = (operation.parameters || []).filter(p => p.in === 'path').map(p => p.name);
233
+ const queryParams = (operation.parameters || []).filter(p => p.in === 'query').map(p => p.name);
234
+ const hasBody = ['POST', 'PUT', 'PATCH'].includes(method) && operation.requestBody;
235
+
236
+ const paramParts = [];
237
+ if (pathParams.length) paramParts.push(pathParams.join(', '));
238
+ if (queryParams.length) paramParts.push('params');
239
+ if (hasBody) paramParts.push('data');
240
+
241
+ lines.push(buildJSDoc(operation, method, route, funcName, spec, showReturns));
242
+ lines.push(`export function ${funcName}(${paramParts.join(', ')}) {`);
243
+
244
+ const fullPath = pathPrefix + route;
245
+ const urlExpr = pathParams.length
246
+ ? '`' + fullPath.replace(/\{(\w+)\}/g, '${$1}') + '`'
247
+ : `'${fullPath}'`;
248
+
249
+ lines.push(` return request({`);
250
+ lines.push(` url: ${urlExpr},`);
251
+ lines.push(` method: '${method}',`);
252
+ if (queryParams.length) lines.push(` params,`);
253
+ if (hasBody) {
254
+ const ct = Object.keys(operation.requestBody?.content || { 'application/json': {} })[0];
255
+ lines.push(` data,`);
256
+ if (ct !== 'application/json') lines.push(` headers: { 'Content-Type': '${ct}' },`);
257
+ }
258
+ lines.push(` });`);
259
+ lines.push(`}\n`);
260
+ }
261
+
262
+ function generateJS(spec, outputDir, groupName, prefix, showReturns, importPath) {
263
+ const apiDir = path.join(outputDir, 'api');
264
+ fs.mkdirSync(apiDir, { recursive: true });
265
+
266
+ const paths = spec.paths || {};
267
+ const modules = {};
268
+ const pathPrefix = prefix || '';
269
+ const reqImport = importPath || '@/utils/request';
270
+
271
+ // 按 tag 分组;无 tag 时按路径首段分组
272
+ for (const [route, methods] of Object.entries(paths)) {
273
+ for (const [method, operation] of Object.entries(methods)) {
274
+ if (!operation || typeof operation !== 'object') continue;
275
+ let tags = operation.tags?.length ? operation.tags : null;
276
+ if (!tags) {
277
+ const seg = route.split('/').filter(Boolean)[0] || 'default';
278
+ tags = [seg];
279
+ }
280
+ for (const tag of tags) {
281
+ if (!modules[tag]) modules[tag] = [];
282
+ modules[tag].push({ route, method: method.toUpperCase(), operation });
283
+ }
284
+ }
285
+ }
286
+
287
+ const fileNames = [];
288
+
289
+ if (groupName) {
290
+ // 指定了 name → 所有接口合并到一个文件
291
+ const allApis = [];
292
+ const tagNames = [];
293
+ for (const [tag, apis] of Object.entries(modules)) {
294
+ allApis.push(...apis);
295
+ tagNames.push(tag);
296
+ }
297
+ fileNames.push(groupName);
298
+
299
+ const lines = [
300
+ '/**',
301
+ ` * ${tagNames.join(' / ')} 模块`,
302
+ ' * 由 pull-apifox 自动生成,请勿手动修改',
303
+ ' */',
304
+ '',
305
+ `import request from '${reqImport}';`,
306
+ '',
307
+ ];
308
+
309
+ for (const { route, method, operation } of allApis) {
310
+ writeApiFunc(lines, route, method, operation, spec, showReturns, pathPrefix, reqImport);
311
+ }
312
+
313
+ fs.writeFileSync(path.join(apiDir, `${groupName}.js`), lines.join('\n'), 'utf-8');
314
+ console.log(` ✅ ${groupName}.js (${allApis.length} 个接口)`);
315
+ } else {
316
+ // 未指定 name → 每个 tag 一个文件
317
+ for (const [tag, apis] of Object.entries(modules)) {
318
+ let fileName;
319
+ if (isAscii(tag) && tag !== 'default') {
320
+ fileName = tag.replace(/[^a-zA-Z0-9]/g, '_');
321
+ } else {
322
+ fileName = deriveModuleName(apis);
323
+ }
324
+ fileNames.push(fileName);
325
+
326
+ const lines = [
327
+ '/**',
328
+ ` * ${tag} 模块`,
329
+ ' * 由 pull-apifox 自动生成,请勿手动修改',
330
+ ' */',
331
+ '',
332
+ `import request from '${reqImport}';`,
333
+ '',
334
+ ];
335
+
336
+ for (const { route, method, operation } of apis) {
337
+ writeApiFunc(lines, route, method, operation, spec, showReturns, pathPrefix, reqImport);
338
+ }
339
+
340
+ fs.writeFileSync(path.join(apiDir, `${fileName}.js`), lines.join('\n'), 'utf-8');
341
+ console.log(` ✅ ${fileName}.js (${apis.length} 个接口)`);
342
+ }
343
+ }
344
+
345
+ // 入口文件
346
+ if (fileNames.length > 0) {
347
+ const indexLines = fileNames
348
+ .map(name => `export * from './api/${name}';`)
349
+ .join('\n');
350
+ fs.writeFileSync(path.join(outputDir, 'index.js'), indexLines + '\n', 'utf-8');
351
+ } else {
352
+ console.log(' ⚠ 没有匹配到接口,请检查筛选条件(folder/tags/apis)是否正确');
353
+ }
354
+ }
355
+
356
+ /**
357
+ * 解析 $ref 引用(支持 '#/components/schemas/XXX')
358
+ */
184
359
  function resolveRef(schema, fullSpec) {
185
360
  if (!schema) return schema;
186
361
  if (schema.$ref) {
@@ -192,6 +367,9 @@ function resolveRef(schema, fullSpec) {
192
367
  return schema;
193
368
  }
194
369
 
370
+ /**
371
+ * 将 OpenAPI schema 转为 JSDoc 类型字符串
372
+ */
195
373
  function toJSDocType(schema, fullSpec) {
196
374
  const resolved = resolveRef(schema, fullSpec);
197
375
  if (!resolved || !resolved.type) return 'any';
@@ -209,6 +387,9 @@ function toJSDocType(schema, fullSpec) {
209
387
  }
210
388
  }
211
389
 
390
+ /**
391
+ * 为接口函数生成完整 JSDoc 注释
392
+ */
212
393
  function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
213
394
  const lines = [];
214
395
  const allParams = operation.parameters || [];
@@ -220,6 +401,7 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
220
401
  : null;
221
402
  const bodySchema = resolveRef(rawBodySchema, fullSpec);
222
403
 
404
+ // 响应体 schema
223
405
  const rawRespSchema = showReturns
224
406
  ? (operation.responses?.['200']?.content?.['application/json']?.schema
225
407
  || operation.responses?.['201']?.content?.['application/json']?.schema
@@ -229,6 +411,7 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
229
411
 
230
412
  lines.push('/**');
231
413
 
414
+ // 标题:优先 summary > description > funcName
232
415
  const title = operation.summary || operation.description || funcName;
233
416
  lines.push(` * ${title}`);
234
417
  if (operation.summary && operation.description && operation.description !== operation.summary) {
@@ -238,6 +421,7 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
238
421
  lines.push(' *');
239
422
  lines.push(` * ${method.toUpperCase()} ${route}`);
240
423
 
424
+ // --- 路径参数 ---
241
425
  const pathParamDefs = allParams.filter(p => p.in === 'path');
242
426
  for (const p of pathParamDefs) {
243
427
  const type = toJSDocType(p.schema, fullSpec);
@@ -245,6 +429,7 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
245
429
  lines.push(` * @param {${type}} ${p.name}${desc ? ` - ${desc}` : ''}`);
246
430
  }
247
431
 
432
+ // --- 查询参数 → 合并为 params 对象 ---
248
433
  if (queryParamDefs.length > 0) {
249
434
  const hasRequired = queryParamDefs.some(p => p.required === true);
250
435
  lines.push(` * @param {object} ${hasRequired ? '' : '['}params${hasRequired ? '' : ']'} - 查询参数`);
@@ -256,14 +441,18 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
256
441
  }
257
442
  }
258
443
 
444
+ // --- 请求体 → data 参数 ---
259
445
  if (hasBody) {
260
446
  const bodyRequired = operation.requestBody.required !== false;
261
447
  const bodyDesc = operation.requestBody.description || '请求体';
262
448
  const type = bodySchema ? toJSDocType(bodySchema, fullSpec) : 'object';
263
449
  lines.push(` * @param {${type}} ${bodyRequired ? '' : '['}data${bodyRequired ? '' : ']'} - ${bodyDesc}`);
450
+
451
+ // 递归展开 body 内部属性
264
452
  expandProperties(bodySchema, 'data', bodySchema?.required || [], fullSpec, lines, 0);
265
453
  }
266
454
 
455
+ // --- 返回值 ---
267
456
  if (showReturns && respSchema) {
268
457
  const respType = toJSDocType(respSchema, fullSpec);
269
458
  lines.push(` * @returns {Promise<${respType}>} - 响应数据`);
@@ -276,6 +465,9 @@ function buildJSDoc(operation, method, route, funcName, fullSpec, showReturns) {
276
465
  return lines.join('\n');
277
466
  }
278
467
 
468
+ /**
469
+ * 递归展开 schema 属性,支持嵌套 object 和 object[] 类型
470
+ */
279
471
  function expandProperties(schema, prefix, requiredList, fullSpec, lines, depth) {
280
472
  if (depth > 3) return;
281
473
  const resolved = resolveRef(schema, fullSpec);
@@ -290,9 +482,12 @@ function expandProperties(schema, prefix, requiredList, fullSpec, lines, depth)
290
482
  const fullPath = prefix ? `${prefix}.${prop}` : prop;
291
483
  lines.push(` * @param {${propType}} ${req ? '' : '['}${fullPath}${req ? '' : ']'}${propDesc ? ` - ${propDesc}` : ''}`);
292
484
 
485
+ // 递归展开内嵌 object
293
486
  if (resolvedProp?.type === 'object' && resolvedProp.properties) {
294
487
  expandProperties(resolvedProp, fullPath, resolvedProp.required || [], fullSpec, lines, depth + 1);
295
- } else if (resolvedProp?.type === 'array' && resolvedProp.items) {
488
+ }
489
+ // 递归展开数组中的对象 (T[])
490
+ else if (resolvedProp?.type === 'array' && resolvedProp.items) {
296
491
  const resolvedItems = resolveRef(resolvedProp.items, fullSpec);
297
492
  if (resolvedItems?.type === 'object' && resolvedItems.properties) {
298
493
  expandProperties(resolvedItems, fullPath + '[]', resolvedItems.required || [], fullSpec, lines, depth + 1);
@@ -301,100 +496,6 @@ function expandProperties(schema, prefix, requiredList, fullSpec, lines, depth)
301
496
  }
302
497
  }
303
498
 
304
- function generateJS(spec, outputDir, groupName, prefix, showReturns, importPath) {
305
- const apiDir = path.join(outputDir, 'api');
306
- fs.mkdirSync(apiDir, { recursive: true });
307
-
308
- const paths = spec.paths || {};
309
- const modules = {};
310
- const pathPrefix = prefix || '';
311
- const reqImport = importPath || '@/utils/request';
312
-
313
- for (const [route, methods] of Object.entries(paths)) {
314
- for (const [method, operation] of Object.entries(methods)) {
315
- if (!operation || typeof operation !== 'object') continue;
316
- let tags = operation.tags?.length ? operation.tags : null;
317
- if (!tags) {
318
- const seg = route.split('/').filter(Boolean)[0] || 'default';
319
- tags = [seg];
320
- }
321
- for (const tag of tags) {
322
- if (!modules[tag]) modules[tag] = [];
323
- modules[tag].push({ route, method: method.toUpperCase(), operation });
324
- }
325
- }
326
- }
327
-
328
- const fileNames = [];
329
-
330
- for (const [tag, apis] of Object.entries(modules)) {
331
- let fileName;
332
- if (groupName) {
333
- fileName = groupName;
334
- } else if (isAscii(tag) && tag !== 'default') {
335
- fileName = tag.replace(/[^a-zA-Z0-9]/g, '_');
336
- } else {
337
- fileName = deriveModuleName(apis);
338
- }
339
- fileNames.push(fileName);
340
-
341
- const lines = [
342
- '/**',
343
- ` * ${tag} 模块`,
344
- ' * 由 pull-apifox 自动生成,请勿手动修改',
345
- ' */',
346
- '',
347
- `import request from '${reqImport}';`,
348
- '',
349
- ];
350
-
351
- for (const { route, method, operation } of apis) {
352
- const funcName = toFuncName(operation, method, route);
353
-
354
- const pathParams = (operation.parameters || []).filter(p => p.in === 'path').map(p => p.name);
355
- const queryParams = (operation.parameters || []).filter(p => p.in === 'query').map(p => p.name);
356
- const hasBody = ['POST', 'PUT', 'PATCH'].includes(method) && operation.requestBody;
357
-
358
- const paramParts = [];
359
- if (pathParams.length) paramParts.push(pathParams.join(', '));
360
- if (queryParams.length) paramParts.push('params');
361
- if (hasBody) paramParts.push('data');
362
-
363
- lines.push(buildJSDoc(operation, method, route, funcName, spec, showReturns));
364
- lines.push(`export function ${funcName}(${paramParts.join(', ')}) {`);
365
-
366
- const fullPath = pathPrefix + route;
367
- const urlExpr = pathParams.length
368
- ? '`' + fullPath.replace(/\{(\w+)\}/g, '${$1}') + '`'
369
- : `'${fullPath}'`;
370
-
371
- lines.push(' return request({');
372
- lines.push(` url: ${urlExpr},`);
373
- lines.push(` method: '${method}',`);
374
- if (queryParams.length) lines.push(' params,');
375
- if (hasBody) {
376
- const ct = Object.keys(operation.requestBody?.content || { 'application/json': {} })[0];
377
- lines.push(' data,');
378
- if (ct !== 'application/json') lines.push(` headers: { 'Content-Type': '${ct}' },`);
379
- }
380
- lines.push(' });');
381
- lines.push('}\n');
382
- }
383
-
384
- fs.writeFileSync(path.join(apiDir, `${fileName}.js`), lines.join('\n'), 'utf-8');
385
- console.log(` ✅ ${fileName}.js (${apis.length} 个接口)`);
386
- }
387
-
388
- if (fileNames.length > 0) {
389
- const indexLines = fileNames
390
- .map(name => `export * from './api/${name}';`)
391
- .join('\n');
392
- fs.writeFileSync(path.join(outputDir, 'index.js'), indexLines + '\n', 'utf-8');
393
- } else {
394
- console.log(' ⚠ 没有匹配到接口,请检查筛选条件(folder/tags/apis)是否正确');
395
- }
396
- }
397
-
398
499
  // ======================== 主流程 ========================
399
500
 
400
501
  async function main() {
@@ -407,7 +508,7 @@ async function main() {
407
508
  console.log(' pull-apifox 使用当前目录下的 apifox.config.json');
408
509
  console.log(' pull-apifox ./my-config.json 指定配置文件');
409
510
  console.log('');
410
- console.log('配置文档: https://github.com/xxx/pull-apifox');
511
+ console.log('配置文档: https://github.com/fanwb1212/pull-apifox');
411
512
  process.exit(0);
412
513
  }
413
514
 
@@ -431,34 +532,63 @@ async function main() {
431
532
  console.log(` 拉取组数: ${config.pull.length}`);
432
533
  console.log('');
433
534
 
434
- console.log('📥 拉取完整 OpenAPI 规范...');
435
- const fullSpec = await fetchSpec(config.projectId, null, config);
436
- const totalPaths = Object.keys(fullSpec.paths || {}).length;
437
- console.log(` 📊 项目共有 ${totalPaths} 个接口\n`);
438
-
439
- const allTags = new Set();
440
- for (const methods of Object.values(fullSpec.paths || {})) {
441
- for (const op of Object.values(methods)) {
442
- (op.tags || []).forEach(t => allTags.add(t));
443
- }
444
- }
445
-
446
535
  const baseOutput = path.resolve(config.output || './src/apifox');
536
+ const specCache = {}; // 按 folder scope 缓存
447
537
 
538
+ // 逐组拉取 + 筛选 + 生成
448
539
  for (let i = 0; i < config.pull.length; i++) {
449
540
  const group = config.pull[i];
450
- const filterLabel = group.folder || group.tags?.join(', ') || '全部接口';
451
- console.log(`📦 [${i + 1}/${config.pull.length}] ${filterLabel}`);
541
+ const scope = group.folder || null;
542
+ const scopeLabel = group.folder || group.tags?.join(', ') || group.apis?.join(', ') || '全部接口';
543
+ console.log(`📦 [${i + 1}/${config.pull.length}] ${scopeLabel}`);
544
+
545
+ // 拉取(同 folder 复用缓存)
546
+ let spec;
547
+ const cacheKey = scope || '__full__';
548
+ if (specCache[cacheKey]) {
549
+ spec = specCache[cacheKey];
550
+ console.log(` ♻ 复用缓存`);
551
+ } else {
552
+ // 先拉全量(作为回退)
553
+ const fullSpec = await fetchSpec(config.projectId, null, config);
554
+ const fullCount = Object.keys(fullSpec.paths || {}).length;
555
+
556
+ if (scope) {
557
+ // 尝试 scope 拉取
558
+ spec = await fetchSpec(config.projectId, scope, config);
559
+ const scopedCount = Object.keys(spec.paths || {}).length;
560
+ if (scopedCount < fullCount) {
561
+ console.log(` 📊 限定范围共 ${scopedCount} 个接口 (全量 ${fullCount})`);
562
+ } else {
563
+ console.log(` ⚠ scope 无效,使用全量 ${fullCount} 个接口`);
564
+ spec = fullSpec;
565
+ }
566
+ // 缓存全量供后续无 folder 的组复用
567
+ specCache['__full__'] = fullSpec;
568
+ } else {
569
+ spec = fullSpec;
570
+ console.log(` 📊 项目共有 ${fullCount} 个接口`);
571
+ }
572
+ specCache[cacheKey] = spec;
573
+ }
452
574
 
453
- const filtered = filterSpec(fullSpec, group);
575
+ // 筛选
576
+ const filtered = filterSpec(spec, group);
454
577
 
578
+ // 诊断
455
579
  if (Object.keys(filtered.paths || {}).length === 0) {
456
- const kw = group.folder || group.apis?.[0] || group.tags?.[0] || '';
580
+ const kw = group.apis?.[0] || group.tags?.[0] || '';
457
581
  if (kw) {
458
- const matchedPaths = Object.keys(fullSpec.paths || {}).filter(p => p.toLowerCase().includes(kw.toLowerCase()));
582
+ const matchedPaths = Object.keys(spec.paths || {}).filter(p => p.toLowerCase().includes(kw.toLowerCase()));
459
583
  if (matchedPaths.length > 0) {
460
584
  console.log(` 💡 路径中包含「${kw}」的接口: ${matchedPaths.slice(0, 10).join(', ')}${matchedPaths.length > 10 ? '...' : ''}`);
461
585
  }
586
+ const allTags = new Set();
587
+ for (const methods of Object.values(spec.paths || {})) {
588
+ for (const op of Object.values(methods)) {
589
+ (op.tags || []).forEach(t => allTags.add(t));
590
+ }
591
+ }
462
592
  const matchedTags = [...allTags].filter(t => t.toLowerCase().includes(kw.toLowerCase()));
463
593
  if (matchedTags.length > 0) {
464
594
  console.log(` 💡 标签中包含「${kw}」的: ${matchedTags.join(', ')}`);
package/package.json CHANGED
@@ -1,8 +1,14 @@
1
1
  {
2
2
  "name": "pull-apifox",
3
- "version": "1.0.0",
4
- "description": "从 Apifox 拉取接口并生成带 JSDoc 的 JS 请求代码",
5
- "keywords": ["apifox", "openapi", "codegen", "api", "jsdoc"],
3
+ "version": "1.1.1",
4
+ "description": "从 Apifox 拉取接口,按需筛选,自动生成带 JSDoc 的 JS 请求代码",
5
+ "keywords": [
6
+ "apifox",
7
+ "openapi",
8
+ "codegen",
9
+ "api",
10
+ "jsdoc"
11
+ ],
6
12
  "license": "MIT",
7
13
  "author": "fanwb1212",
8
14
  "main": "lib/index.js",