newCloud/scripts/extract_apis.js
ylj20011123 f651d3c91b update
2026-02-28 18:56:16 +08:00

672 lines
23 KiB
JavaScript
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.

/**
* 批量提取所有页面接口地址的自动化脚本
*
* 处理流程:
* 1. 解析 menu.json 提取所有 SYSTEMMODULE_URL
* 2. 解析 routes.ts 构建嵌套路径映射(子父级拼接)
* 3. 定位组件文件夹及所有子组件
* 4. 提取 import 的 service 文件
* 5. 在 service 文件中提取 request() 路径,并根据引用的 request 工具确定 prefix
* 6. 输出结果到 config/api_mapping.json 和 config/api_mapping.md
*/
const fs = require('fs');
const path = require('path');
// 项目根目录
const ROOT = path.resolve(__dirname, '..');
const SRC = path.join(ROOT, 'src');
const PAGES = path.join(SRC, 'pages');
// ============ 第一部分request prefix 映射 ============
// 根据 service 文件 import 的 request 来源确定 prefix
const REQUEST_PREFIX_MAP = {
'request': 'https://eshangtech.com:18900/EShangApiMain',
'requestUpLoad': 'http://220.180.35.180:8000/EShangApiMain',
'requestTest': 'http://dev.eshangtech.com:8900/EShangApiMain',
'requestTestTest': 'http://dev.eshangtech.com:8001/EShangApiMain',
'requestNoPrefix': 'https://eshangtech.com:18900/',
'requestPythonTest': 'http://192.168.1.207:8002/',
'requestNewTest': 'https://eshangtech.com:18900/EShangApiMain',
'requestNewJava': 'https://java.es.eshangtech.com:443',
'requestnew': '/EShangApiDashboard',
'requestEncryption': 'https://eshangtech.com:18900/MemberApi',
'requestJava': 'https://java.es.eshangtech.com',
'requestDashboard': 'https://eshangtech.com:18900/EshangApiDashboard',
'requestCodeBuilder': 'http://dev.eshangtech.com:8001/CodeBuilderApi',
'requestCode': 'https://eshangtech.com:18900/CommercialApi',
'requestAHYD': 'https://ahyd.eshangtech.com/EShangApiMain',
};
// ============ 第二部分:解析 routes.ts 构建路径映射 ============
/**
* 解析 routes.ts 文件,构建 fullPath → component 映射
* 处理嵌套路由的路径拼接
*/
function parseRoutes() {
const routesFile = path.join(ROOT, 'config', 'routes.ts');
const content = fs.readFileSync(routesFile, 'utf-8');
// 用正则提取所有路由对象(简化处理:逐行匹配 path 和 component
const routeMap = {};
// 使用栈来跟踪嵌套路径
const pathStack = [];
let braceDepth = 0;
const depthToPath = {}; // 记录每个深度对应的 path 值
const lines = content.split('\n');
let currentPath = null;
let currentComponent = null;
let currentObjStartDepth = -1;
for (const line of lines) {
const trimmed = line.trim();
// 计算花括号深度
for (const ch of trimmed) {
if (ch === '{') braceDepth++;
if (ch === '}') {
// 当离开一个层级时,清除该层级的 path
if (depthToPath[braceDepth]) {
delete depthToPath[braceDepth];
}
braceDepth--;
}
}
// 匹配 path 属性
const pathMatch = trimmed.match(/^path:\s*['"]([^'"]+)['"]/);
if (pathMatch) {
const pathVal = pathMatch[1];
depthToPath[braceDepth] = pathVal;
}
// 匹配 component 属性
const compMatch = trimmed.match(/^component:\s*['"]([^'"]+)['"]/);
if (compMatch) {
const comp = compMatch[1];
// 跳过 layout 组件
if (comp.includes('Layout') || comp.includes('layout')) continue;
// 构建完整路径:从 depthToPath 中按层级拼接
const fullPath = buildFullPath(depthToPath, braceDepth);
if (fullPath && fullPath !== '/') {
routeMap[fullPath] = comp;
}
}
}
return routeMap;
}
/**
* 根据深度映射构建完整路径
*/
function buildFullPath(depthToPath, currentDepth) {
// 收集所有当前有效的路径段
const segments = [];
const depths = Object.keys(depthToPath).map(Number).sort((a, b) => a - b);
for (const d of depths) {
if (d <= currentDepth) {
segments.push(depthToPath[d]);
}
}
if (segments.length === 0) return null;
// 拼接路径
let result = '';
for (const seg of segments) {
if (seg.startsWith('/')) {
// 绝对路径,重新开始
result = seg;
} else {
// 相对路径,拼接
if (result.endsWith('/')) {
result += seg;
} else {
result += '/' + seg;
}
}
}
return result;
}
// ============ 第三部分:备用路径匹配 ============
/**
* 当 routes.ts 中找不到完全匹配时,尝试通过 URL 路径推断组件位置
* 扫描 src/pages 目录查找匹配的文件夹
*/
function findComponentByUrl(menuUrl) {
// 从 URL 提取最后一段作为组件名
const segments = menuUrl.split('/').filter(Boolean);
if (segments.length === 0) return null;
const lastSegment = segments[segments.length - 1];
// 在 pages 目录下递归搜索包含该名称的文件夹
const found = findDirRecursive(PAGES, lastSegment);
return found;
}
function findDirRecursive(dir, targetName) {
if (!fs.existsSync(dir)) return null;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
// 不区分大小写匹配
if (entry.name.toLowerCase() === targetName.toLowerCase()) {
const fullPath = path.join(dir, entry.name);
// 确认有 index.tsx 或 index.ts
if (fs.existsSync(path.join(fullPath, 'index.tsx')) ||
fs.existsSync(path.join(fullPath, 'index.ts'))) {
return fullPath;
}
}
// 递归搜索子目录
const result = findDirRecursive(path.join(dir, entry.name), targetName);
if (result) return result;
}
}
return null;
}
// ============ 第四部分:定位组件并提取所有被引用的文件 ============
/**
* 根据 component 路径解析到实际文件夹
* 处理两种情况:
* 1. component 指向目录(如 './contract' → src/pages/contract/
* 2. component 指向文件(如 './contract/list' → src/pages/contract/list.tsx
* 此时返回文件所在的父目录
*/
function resolveComponentDir(componentPath) {
let resolved = componentPath.replace(/^\.\//, '');
const fullPath = path.join(PAGES, resolved);
// 情况1路径本身是目录如 src/pages/contract/
if (fs.existsSync(fullPath) && fs.statSync(fullPath).isDirectory()) {
return fullPath;
}
// 情况2路径指向文件如 src/pages/contract/list → 实际是 list.tsx
// 检查是否存在同名的 .tsx / .ts 文件
const extensions = ['.tsx', '.ts', '.jsx', '.js'];
for (const ext of extensions) {
if (fs.existsSync(fullPath + ext)) {
// 文件存在,返回其父目录作为组件目录
return path.dirname(fullPath);
}
}
// 情况3尝试去掉末尾的 /index
if (resolved.endsWith('/index')) {
resolved = resolved.replace(/\/index$/, '');
const altPath = path.join(PAGES, resolved);
if (fs.existsSync(altPath) && fs.statSync(altPath).isDirectory()) {
return altPath;
}
}
return null;
}
/**
* 获取组件目录下所有 .tsx / .ts 文件(包括 components 子目录)
*/
function getAllComponentFiles(dir) {
const files = [];
if (!fs.existsSync(dir)) return files;
function scan(currentDir) {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
scan(fullPath);
} else if (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) {
// 排除 service.ts 本身和 .d.ts
if (!entry.name.endsWith('.d.ts')) {
files.push(fullPath);
}
}
}
}
scan(dir);
return files;
}
// ============ 第五部分:提取 service 导入和接口路径 ============
/**
* 从组件文件中提取所有 import 的 service 路径
*/
function extractServiceImports(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const imports = [];
// 匹配各种 import 模式
// import { xxx } from '../xxx/service'
// import { xxx } from '@/pages/xxx/service'
// import { xxx } from '@/services/xxx'
const importRegex = /import\s+\{([^}]+)\}\s+from\s+['"](.*?)['"]/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
const functions = match[1].split(',').map(f => f.trim()).filter(Boolean);
let importPath = match[2];
// 只关注 service 文件、services 目录、options 目录的导入
if (importPath.includes('service') || importPath.includes('services') ||
importPath.includes('options')) {
imports.push({
functions,
importPath,
sourceFile: filePath
});
}
}
return imports;
}
/**
* 解析 import 路径到实际文件路径
*/
function resolveImportPath(importPath, sourceFile) {
let resolved;
if (importPath.startsWith('@/')) {
// @/ 别名 → src/
resolved = path.join(SRC, importPath.replace('@/', ''));
} else if (importPath.startsWith('.')) {
// 相对路径
resolved = path.resolve(path.dirname(sourceFile), importPath);
} else {
return null; // node_modules 等,跳过
}
// 尝试各种扩展名
const extensions = ['.ts', '.tsx', '/index.ts', '/index.tsx', '.js', '/index.js'];
for (const ext of extensions) {
const candidate = resolved + ext;
if (fs.existsSync(candidate)) {
return candidate;
}
}
// 如果直接存在
if (fs.existsSync(resolved)) {
if (fs.statSync(resolved).isDirectory()) {
for (const ext of ['/index.ts', '/index.tsx', '/index.js']) {
const idx = resolved + ext;
if (fs.existsSync(idx)) return idx;
}
}
return resolved;
}
return null;
}
// 缓存已解析的 service 文件
const serviceCache = {};
/**
* 从 service 文件中提取所有 request() 调用的路径
* 同时确定引用的是哪个 request 工具(确定 prefix
* 并记录每个 request() 调用所属的函数名
*/
function extractApisFromService(serviceFilePath) {
if (serviceCache[serviceFilePath]) {
return serviceCache[serviceFilePath];
}
if (!fs.existsSync(serviceFilePath)) {
serviceCache[serviceFilePath] = [];
return [];
}
const content = fs.readFileSync(serviceFilePath, 'utf-8');
const apis = [];
// 1. 确定该 service 文件使用的 request 来源
let prefix = REQUEST_PREFIX_MAP['request']; // 默认 prefix
// 匹配 import request from '@/utils/requestXXX'
const requestImportRegex = /import\s+\w+\s+from\s+['"]@\/utils\/(\w+)['"]/g;
let reqMatch;
while ((reqMatch = requestImportRegex.exec(content)) !== null) {
const requestName = reqMatch[1];
if (REQUEST_PREFIX_MAP[requestName] !== undefined) {
prefix = REQUEST_PREFIX_MAP[requestName];
}
}
// 也匹配相对路径的 request 导入
const relRequestRegex = /import\s+\w+\s+from\s+['"]\.\.?\/.*?(\w+)['"].*?/g;
while ((reqMatch = relRequestRegex.exec(content)) !== null) {
const fileName = reqMatch[1];
if (REQUEST_PREFIX_MAP[fileName] !== undefined) {
prefix = REQUEST_PREFIX_MAP[fileName];
}
}
// 2. 逐行扫描,追踪当前函数名,并提取 request() 调用
const lines = content.split('\n');
let currentFuncName = '(unknown)';
// 函数定义的正则匹配模式:
// export async function handleXxx(...)
// export function handleXxx(...)
// export const handleXxx = async (...)
// export const handleXxx = (...)
const funcDefRegex = /^export\s+(?:async\s+)?function\s+(\w+)|^export\s+const\s+(\w+)\s*=/;
for (const line of lines) {
const trimmed = line.trim();
// 检查是否是函数定义行
const funcMatch = trimmed.match(funcDefRegex);
if (funcMatch) {
currentFuncName = funcMatch[1] || funcMatch[2];
}
// 检查是否有 request() 调用
const requestCallRegex = /request\(\s*['"`]([^'"`\$]+)['"`]/g;
let apiMatch;
while ((apiMatch = requestCallRegex.exec(trimmed)) !== null) {
const apiPath = apiMatch[1];
// 构建完整 URL
let fullUrl;
if (prefix.endsWith('/')) {
fullUrl = prefix + apiPath.replace(/^\//, '');
} else {
fullUrl = prefix + apiPath;
}
apis.push({
path: apiPath,
fullUrl: fullUrl,
prefix: prefix,
functionName: currentFuncName,
serviceFile: path.relative(ROOT, serviceFilePath)
});
}
}
// 3. 也检查该 service 文件是否还 import 了其他 service二级引用
const serviceImports = extractServiceImports(serviceFilePath);
for (const imp of serviceImports) {
// 避免循环引用
const resolvedPath = resolveImportPath(imp.importPath, serviceFilePath);
if (resolvedPath && resolvedPath !== serviceFilePath && !serviceCache[resolvedPath]) {
const subApis = extractApisFromService(resolvedPath);
apis.push(...subApis);
}
}
serviceCache[serviceFilePath] = apis;
return apis;
}
// ============ 第六部分:解析 menu.json 提取所有模块 ============
function extractModules(items, parentPath = '') {
const modules = [];
for (const item of items) {
if (item.SYSTEMMODULE_URL && item.SYSTEMMODULE_URL !== '.' && item.SYSTEMMODULE_URL !== '/') {
// 跳过外部链接
if (!item.SYSTEMMODULE_URL.startsWith('http')) {
modules.push({
name: item.SYSTEMMODULE_NAME,
url: item.SYSTEMMODULE_URL,
parentMenu: parentPath
});
}
}
if (item.children) {
const menuName = item.SYSTEMMENU_NAME || '';
const newParent = parentPath ? `${parentPath} > ${menuName}` : menuName;
modules.push(...extractModules(item.children, newParent));
}
}
return modules;
}
// ============ 第七部分:主流程 ============
function main() {
console.log('🚀 开始批量提取页面接口...\n');
// 1. 读取 menu.json
const menuData = require(path.join(ROOT, 'config', 'menu.json'));
const modules = extractModules(menuData);
console.log(`📋 共发现 ${modules.length} 个有效页面模块\n`);
// 2. 解析 routes.ts 构建路径映射
const routeMap = parseRoutes();
console.log(`🗺️ 路由映射构建完成,共 ${Object.keys(routeMap).length} 条路由\n`);
// 3. 逐个处理每个模块
const results = [];
let matchedCount = 0;
let unmatchedCount = 0;
let totalApis = 0;
for (const mod of modules) {
const result = {
menuName: mod.name,
menuUrl: mod.url,
parentMenu: mod.parentMenu,
componentPath: null,
componentDir: null,
apiCount: 0,
apis: [],
status: 'unmatched'
};
// 3.1 在路由映射中查找 component
let component = routeMap[mod.url];
// 3.2 如果没有直接匹配,尝试带参数路径的匹配
if (!component) {
// 处理带动态参数的路由,比如 /setting/department/:id 匹配 /setting/department/userstype
for (const [routePath, comp] of Object.entries(routeMap)) {
if (routePath.includes(':')) {
const routeRegex = routePath.replace(/:[^/]+/g, '[^/]+');
if (new RegExp(`^${routeRegex}$`).test(mod.url)) {
component = comp;
break;
}
}
}
}
if (component) {
result.componentPath = component;
// 3.3 解析 component 路径到实际文件夹
const componentDir = resolveComponentDir(component);
if (componentDir) {
result.componentDir = path.relative(ROOT, componentDir);
result.status = 'matched';
matchedCount++;
// 3.4 获取所有组件文件
const componentFiles = getAllComponentFiles(componentDir);
// 3.5 提取所有 service 导入
const allApis = new Set(); // 用 Set 去重
const apiDetails = [];
for (const file of componentFiles) {
const serviceImports = extractServiceImports(file);
for (const imp of serviceImports) {
const resolvedService = resolveImportPath(imp.importPath, file);
if (resolvedService) {
const apis = extractApisFromService(resolvedService);
for (const api of apis) {
if (!allApis.has(api.fullUrl)) {
allApis.add(api.fullUrl);
apiDetails.push(api);
}
}
}
}
}
result.apiCount = apiDetails.length;
result.apis = apiDetails.map(a => ({
fullUrl: a.fullUrl,
path: a.path,
prefix: a.prefix,
functionName: a.functionName,
serviceFile: a.serviceFile
}));
totalApis += apiDetails.length;
} else {
result.status = 'dir_not_found';
unmatchedCount++;
}
} else {
// 3.6 备用:通过 URL 最后一段搜索
const fallbackDir = findComponentByUrl(mod.url);
if (fallbackDir) {
result.componentDir = path.relative(ROOT, fallbackDir);
result.status = 'matched_fallback';
matchedCount++;
const componentFiles = getAllComponentFiles(fallbackDir);
const allApis = new Set();
const apiDetails = [];
for (const file of componentFiles) {
const serviceImports = extractServiceImports(file);
for (const imp of serviceImports) {
const resolvedService = resolveImportPath(imp.importPath, file);
if (resolvedService) {
const apis = extractApisFromService(resolvedService);
for (const api of apis) {
if (!allApis.has(api.fullUrl)) {
allApis.add(api.fullUrl);
apiDetails.push(api);
}
}
}
}
}
result.apiCount = apiDetails.length;
result.apis = apiDetails.map(a => ({
fullUrl: a.fullUrl,
path: a.path,
prefix: a.prefix,
functionName: a.functionName,
serviceFile: a.serviceFile
}));
totalApis += apiDetails.length;
} else {
unmatchedCount++;
}
}
results.push(result);
}
// 4. 输出统计
console.log('========== 统计结果 ==========');
console.log(`✅ 匹配成功: ${matchedCount} 个页面`);
console.log(`❌ 未匹配: ${unmatchedCount} 个页面`);
console.log(`📡 总接口数: ${totalApis}`);
console.log('');
// 5. 输出 JSON 文件
const jsonOutput = path.join(ROOT, 'config', 'api_mapping.json');
fs.writeFileSync(jsonOutput, JSON.stringify(results, null, 2), 'utf-8');
console.log(`📄 JSON 已输出: ${jsonOutput}`);
// 6. 输出 Markdown 文件
const mdOutput = path.join(ROOT, 'config', 'api_mapping.md');
let md = '# 系统页面接口映射表\n\n';
md += `> 自动生成时间: ${new Date().toLocaleString()}\n\n`;
md += `> 总页面数: ${modules.length} | 匹配成功: ${matchedCount} | 未匹配: ${unmatchedCount} | 总接口数: ${totalApis}\n\n`;
// 按父级菜单分组
const groupedByParent = {};
for (const r of results) {
const parent = r.parentMenu || '未分类';
if (!groupedByParent[parent]) {
groupedByParent[parent] = [];
}
groupedByParent[parent].push(r);
}
for (const [parentMenu, items] of Object.entries(groupedByParent)) {
md += `## ${parentMenu}\n\n`;
for (const item of items) {
if (item.status === 'unmatched' || item.status === 'dir_not_found') {
md += `### ❌ ${item.menuName}\n`;
md += `- 路径: \`${item.menuUrl}\`\n`;
md += `- 状态: 未找到对应组件\n\n`;
continue;
}
md += `### ${item.menuName}\n`;
md += `- 路径: \`${item.menuUrl}\`\n`;
md += `- 组件: \`${item.componentDir || item.componentPath}\`\n`;
md += `- 调用了 **${item.apiCount}** 个接口\n\n`;
if (item.apis.length > 0) {
md += '| # | 函数名 | 接口路径 | 完整地址 | 来源 |\n';
md += '|---|--------|----------|----------|------|\n';
item.apis.forEach((api, idx) => {
md += `| ${idx + 1} | \`${api.functionName || '-'}\` | \`${api.path}\` | \`${api.fullUrl}\` | \`${api.serviceFile}\` |\n`;
});
md += '\n';
}
}
md += '---\n\n';
}
// 7. 添加未匹配页面汇总
const unmatched = results.filter(r => r.status === 'unmatched' || r.status === 'dir_not_found');
if (unmatched.length > 0) {
md += '## ⚠️ 未匹配页面汇总\n\n';
md += '| # | 页面名称 | 路径 | 所属菜单 |\n';
md += '|---|----------|------|----------|\n';
unmatched.forEach((item, idx) => {
md += `| ${idx + 1} | ${item.menuName} | \`${item.menuUrl}\` | ${item.parentMenu} |\n`;
});
md += '\n';
}
fs.writeFileSync(mdOutput, md, 'utf-8');
console.log(`📝 Markdown 已输出: ${mdOutput}`);
// 8. 打印部分结果预览
console.log('\n========== 结果预览(前 10 个有接口的页面)==========\n');
const withApis = results.filter(r => r.apiCount > 0).slice(0, 10);
for (const r of withApis) {
console.log(`📌 【${r.menuName}】调用了 ${r.apiCount} 个接口,分别为:`);
r.apis.forEach((api, idx) => {
console.log(` ${idx + 1}. ${api.fullUrl}`);
});
console.log('');
}
}
main();