hookehuyr

feat: 添加 API 生成脚本并更新打卡接口

- 新增 OpenAPI 转 API 生成脚本 (scripts/)
- 更新打卡 API 端点 (map → map_activity)
- 添加 .gitignore 规则忽略 CLAUDE.md 和 .claude/
- 新增 npm run api:generate 命令

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -14,3 +14,6 @@ cypress.json
src/test
.idea
map
CLAUDE.md
.claude/
......
......@@ -28,7 +28,8 @@
"dev_upload": "npm run build_tar && npm run scp-dev && npm run dec-dev && npm run remove_tar && npm run remove_dist",
"oa_upload": "npm run build_tar && npm run scp-oa && npm run dec-oa && npm run remove_tar",
"xys_upload": "npm run build_tar && npm run scp-xys && npm run dec-xys && npm run remove_tar",
"walk_upload": "npm run build_tar && npm run scp-walk && npm run dec-walk && npm run remove_tar"
"walk_upload": "npm run build_tar && npm run scp-walk && npm run dec-walk && npm run remove_tar",
"api:generate": "node scripts/generateApiFromOpenAPI.js"
},
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
......
/**
* API 对比工具
*
* 功能:
* 1. 对比两个 OpenAPI 文档的差异
* 2. 检测破坏性变更
* 3. 生成详细的变更报告
*
* 使用方式:
* node scripts/apiDiff.js <oldPath> <newPath>
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
/**
* 从 Markdown 文件中提取 YAML
*/
function extractYAMLFromMarkdown(content) {
const yamlRegex = /```yaml\s*\n([\s\S]*?)\n```/;
const match = content.match(yamlRegex);
return match ? match[1] : null;
}
/**
* 解析 OpenAPI 文档(支持 .md 和目录)
*/
function parseOpenAPIPath(filePath) {
const stat = fs.statSync(filePath);
if (stat.isFile()) {
// 单个文件
if (filePath.endsWith('.md')) {
const content = fs.readFileSync(filePath, 'utf8');
const yamlContent = extractYAMLFromMarkdown(content);
if (!yamlContent) {
throw new Error(`文件 ${filePath} 中未找到 YAML 代码块`);
}
return [yaml.load(yamlContent)];
} else if (filePath.endsWith('.js')) {
// TODO: 支持对比生成的 JS 文件(需要解析 AST)
throw new Error('暂不支持对比生成的 JS 文件,请对比 OpenAPI 文档');
} else {
throw new Error(`不支持的文件类型: ${filePath}`);
}
} else if (stat.isDirectory()) {
// 目录,读取所有 .md 文件
const files = fs.readdirSync(filePath).filter(f => f.endsWith('.md'));
const docs = [];
files.forEach(file => {
const fullPath = path.join(filePath, file);
const content = fs.readFileSync(fullPath, 'utf8');
const yamlContent = extractYAMLFromMarkdown(content);
if (yamlContent) {
const doc = yaml.load(yamlContent);
// 保存文件名用于标识
doc._fileName = path.basename(file, '.md');
docs.push(doc);
}
});
return docs;
}
}
/**
* 从 OpenAPI 文档提取 API 信息
*/
function extractAPIInfo(openapiDoc) {
const path = Object.keys(openapiDoc.paths)[0];
const method = Object.keys(openapiDoc.paths[path])[0];
const apiInfo = openapiDoc.paths[path][method];
// 提取参数
const queryParams = (apiInfo.parameters || [])
.filter(p => p.in === 'query' && p.name !== 'a' && p.name !== 'f')
.map(p => ({
name: p.name,
type: p.schema?.type || 'any',
required: p.required || false,
description: p.description || '',
}));
// 提取 body 参数
const bodyParams = [];
if (apiInfo.requestBody && apiInfo.requestBody.content) {
const content = apiInfo.requestBody.content['application/x-www-form-urlencoded'] ||
apiInfo.requestBody.content['application/json'];
if (content && content.schema && content.schema.properties) {
Object.entries(content.schema.properties).forEach(([key, value]) => {
if (key !== 'a' && key !== 'f') {
bodyParams.push({
name: key,
type: value.type || 'any',
required: content.schema.required?.includes(key) || false,
description: value.description || '',
});
}
});
}
}
// 提取响应结构
const responseSchema = apiInfo.responses?.['200']?.content?.['application/json']?.schema;
return {
name: openapiDoc._fileName || 'unknown',
path,
method: method.toUpperCase(),
queryParams: new Set(queryParams.map(p => p.name)),
bodyParams: new Set(bodyParams.map(p => p.name)),
requiredQueryParams: new Set(queryParams.filter(p => p.required).map(p => p.name)),
requiredBodyParams: new Set(bodyParams.filter(p => p.required).map(p => p.name)),
allQueryParams: queryParams,
allBodyParams: bodyParams,
responseSchema,
summary: apiInfo.summary || '',
};
}
/**
* 对比两个 API 信息
*/
function compareAPI(oldAPI, newAPI) {
const changes = {
breaking: [],
nonBreaking: [],
};
// 检查 HTTP 方法变更
if (oldAPI.method !== newAPI.method) {
changes.breaking.push(`HTTP 方法变更: ${oldAPI.method}${newAPI.method}`);
}
// 检查 GET 参数变更
oldAPI.allQueryParams.forEach(oldParam => {
const newParam = newAPI.allQueryParams.find(p => p.name === oldParam.name);
if (!newParam) {
// 参数被删除
if (oldAPI.requiredQueryParams.has(oldParam.name)) {
changes.breaking.push(`删除必填 query 参数: ${oldParam.name}`);
} else {
changes.nonBreaking.push(`删除可选 query 参数: ${oldParam.name}`);
}
} else {
// 参数类型变更
if (oldParam.type !== newParam.type) {
changes.breaking.push(`query 参数类型变更: ${oldParam.name} (${oldParam.type}${newParam.type})`);
}
// 可选 → 必填
if (!oldParam.required && newParam.required) {
changes.breaking.push(`query 参数变为必填: ${newParam.name}`);
}
// 必填 → 可选
if (oldParam.required && !newParam.required) {
changes.nonBreaking.push(`query 参数变为可选: ${newParam.name}`);
}
}
});
// 检查新增 GET 参数
newAPI.allQueryParams.forEach(newParam => {
const oldParam = oldAPI.allQueryParams.find(p => p.name === newParam.name);
if (!oldParam) {
if (newParam.required) {
changes.breaking.push(`新增必填 query 参数: ${newParam.name}`);
} else {
changes.nonBreaking.push(`新增可选 query 参数: ${newParam.name}`);
}
}
});
// 检查 POST body 参数变更
oldAPI.allBodyParams.forEach(oldParam => {
const newParam = newAPI.allBodyParams.find(p => p.name === oldParam.name);
if (!newParam) {
// 参数被删除
if (oldAPI.requiredBodyParams.has(oldParam.name)) {
changes.breaking.push(`删除必填 body 参数: ${oldParam.name}`);
} else {
changes.nonBreaking.push(`删除可选 body 参数: ${oldParam.name}`);
}
} else {
// 参数类型变更
if (oldParam.type !== newParam.type) {
changes.breaking.push(`body 参数类型变更: ${oldParam.name} (${oldParam.type}${newParam.type})`);
}
// 可选 → 必填
if (!oldParam.required && newParam.required) {
changes.breaking.push(`body 参数变为必填: ${newParam.name}`);
}
// 必填 → 可选
if (oldParam.required && !newParam.required) {
changes.nonBreaking.push(`body 参数变为可选: ${newParam.name}`);
}
}
});
// 检查新增 body 参数
newAPI.allBodyParams.forEach(newParam => {
const oldParam = oldAPI.allBodyParams.find(p => p.name === newParam.name);
if (!oldParam) {
if (newParam.required) {
changes.breaking.push(`新增必填 body 参数: ${newParam.name}`);
} else {
changes.nonBreaking.push(`新增可选 body 参数: ${newParam.name}`);
}
}
});
return changes;
}
/**
* 生成变更报告
*/
function generateReport(oldDocs, newDocs, format = 'text') {
const oldAPIs = oldDocs.map(extractAPIInfo);
const newAPIs = newDocs.map(extractAPIInfo);
const oldAPIsMap = new Map(oldAPIs.map(api => [api.name, api]));
const newAPIsMap = new Map(newAPIs.map(api => [api.name, api]));
const addedAPIs = [];
const removedAPIs = [];
const modifiedAPIs = [];
// 检测新增接口
newAPIs.forEach(api => {
if (!oldAPIsMap.has(api.name)) {
addedAPIs.push(api);
}
});
// 检测删除接口
oldAPIs.forEach(api => {
if (!newAPIsMap.has(api.name)) {
removedAPIs.push(api);
}
});
// 检测修改接口
newAPIs.forEach(api => {
const oldAPI = oldAPIsMap.get(api.name);
if (oldAPI) {
const changes = compareAPI(oldAPI, api);
if (changes.breaking.length > 0 || changes.nonBreaking.length > 0) {
modifiedAPIs.push({
name: api.name,
summary: api.summary,
changes,
});
}
}
});
// 统计
const totalBreaking = modifiedAPIs.reduce(
(sum, api) => sum + api.changes.breaking.length,
0
);
// 生成文本报告
if (format === 'text') {
const lines = [];
lines.push('=== API 变更检测报告 ===\n');
lines.push(`📦 对比范围: ${oldAPIs.length} 个旧接口 → ${newAPIs.length} 个新接口`);
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
if (addedAPIs.length > 0) {
lines.push(`✅ 新增接口 (${addedAPIs.length}):`);
addedAPIs.forEach(api => {
lines.push(` + ${api.name} - ${api.summary}`);
});
lines.push('');
}
if (modifiedAPIs.length > 0) {
lines.push(`⚠️ 修改接口 (${modifiedAPIs.length}):`);
modifiedAPIs.forEach(api => {
lines.push(` ↪ ${api.name} - ${api.summary}`);
api.changes.breaking.forEach(change => {
lines.push(` ✗ [破坏性] ${change}`);
});
api.changes.nonBreaking.forEach(change => {
lines.push(` ✓ [非破坏性] ${change}`);
});
});
lines.push('');
}
if (removedAPIs.length > 0) {
lines.push(`❌ 删除接口 (${removedAPIs.length}):`);
removedAPIs.forEach(api => {
lines.push(` - ${api.name} - ${api.summary}`);
});
lines.push('');
}
lines.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
lines.push(`总计: ${addedAPIs.length} 新增, ${modifiedAPIs.length} 修改, ${removedAPIs.length} 删除`);
if (totalBreaking > 0) {
lines.push(`⚠️ 检测到 ${totalBreaking} 个破坏性变更,请仔细检查业务逻辑!`);
} else if (addedAPIs.length > 0 || modifiedAPIs.length > 0 || removedAPIs.length > 0) {
lines.push('✅ 未检测到破坏性变更');
} else {
lines.push('✅ 无接口变更');
}
return lines.join('\n');
}
// 生成 JSON 报告
if (format === 'json') {
return JSON.stringify({
summary: {
added: addedAPIs.length,
modified: modifiedAPIs.length,
removed: removedAPIs.length,
breakingChanges: totalBreaking,
},
added: addedAPIs.map(api => ({
name: api.name,
summary: api.summary,
method: api.method,
path: api.path,
})),
modified: modifiedAPIs.map(api => ({
name: api.name,
summary: api.summary,
breakingChanges: api.changes.breaking,
nonBreakingChanges: api.changes.nonBreaking,
})),
removed: removedAPIs.map(api => ({
name: api.name,
summary: api.summary,
method: api.method,
path: api.path,
})),
}, null, 2);
}
}
/**
* 主函数
*/
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('用法: node scripts/apiDiff.js <oldPath> <newPath>');
console.error('示例:');
console.error(' node scripts/apiDiff.js docs/api-specs/user/ docs/api-specs/user-new/');
console.error(' node scripts/apiDiff.js docs/api-specs/user/api1.md docs/api-specs/user/api1-new.md');
process.exit(1);
}
const [oldPath, newPath] = args;
if (!fs.existsSync(oldPath)) {
console.error(`❌ 旧路径不存在: ${oldPath}`);
process.exit(1);
}
if (!fs.existsSync(newPath)) {
console.error(`❌ 新路径不存在: ${newPath}`);
process.exit(1);
}
try {
const oldDocs = parseOpenAPIPath(oldPath);
const newDocs = parseOpenAPIPath(newPath);
const format = process.env.API_DIFF_FORMAT || 'text';
const report = generateReport(oldDocs, newDocs, format);
console.log(report);
// 如果有破坏性变更,返回退出码 1
const oldAPIs = oldDocs.map(extractAPIInfo);
const newAPIs = newDocs.map(extractAPIInfo);
const oldAPIsMap = new Map(oldAPIs.map(api => [api.name, api]));
const newAPIsMap = new Map(newAPIs.map(api => [api.name, api]));
let totalBreaking = 0;
newAPIs.forEach(api => {
const oldAPI = oldAPIsMap.get(api.name);
if (oldAPI) {
const changes = compareAPI(oldAPI, api);
totalBreaking += changes.breaking.length;
}
});
// 严格模式:任何变更都返回 1
const strictMode = process.env.API_DIFF_STRICT === 'true';
const hasChanges = oldAPIs.length !== newAPIs.length ||
newAPIs.some(api => !oldAPIsMap.has(api.name)) ||
oldAPIs.some(api => !newAPIsMap.has(api.name));
if (totalBreaking > 0 || (strictMode && hasChanges)) {
process.exit(1);
} else {
process.exit(0);
}
} catch (error) {
console.error(`❌ 对比失败: ${error.message}`);
process.exit(1);
}
}
// 如果直接运行此脚本
if (require.main === module) {
main();
}
module.exports = {
compareAPI,
generateReport,
parseOpenAPIPath,
extractAPIInfo,
};
/**
* 从 OpenAPI 文档自动生成 API 接口文件
*
* 功能:
* 1. 扫描 docs/api-specs 目录
* 2. 解析每个 .md 文件中的 OpenAPI YAML 规范
* 3. 提取 API 信息并生成对应的 JavaScript API 文件
* 4. 保存到 src/api/ 目录
*
* 目录结构:
* docs/api-specs/
* ├── module1/
* │ ├── api1.md
* │ └── api2.md
* └── module2/
* └── api3.md
*
* 生成到:
* src/api/
* ├── module1.js
* └── module2.js
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const { generateReport, parseOpenAPIPath } = require('./apiDiff');
/**
* 提取 Markdown 文件中的 YAML 代码块
* @param {string} content - Markdown 文件内容
* @returns {string|null} - YAML 字符串或 null
*/
function extractYAMLFromMarkdown(content) {
const yamlRegex = /```yaml\s*\n([\s\S]*?)\n```/;
const match = content.match(yamlRegex);
return match ? match[1] : null;
}
/**
* 将字符串转换为驼峰命名
* @param {string} str - 输入字符串
* @returns {string} - 驼峰命名字符串
*/
function toCamelCase(str) {
return str
.replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ''))
.replace(/^(.)/, (c) => c.toLowerCase());
}
/**
* 将字符串转换为帕斯卡命名(首字母大写)
* @param {string} str - 输入字符串
* @returns {string} - 帕斯卡命名字符串
*/
function toPascalCase(str) {
const camelCase = toCamelCase(str);
return camelCase.charAt(0).toUpperCase() + camelCase.slice(1);
}
/**
* 简单的英文到中文翻译
* @param {string} text - 英文文本
* @returns {string} - 中文文本
*/
function translateToChinese(text) {
if (!text) return '';
// 常见技术术语翻译字典(优先级从高到低)
const dictionary = {
// 短语和完整句子
'mini program authorization': '小程序授权',
'login flow design': '登录流程设计',
// 短语
'mini program': '小程序',
'mini-program': '小程序',
'wechat': '微信',
'weixin': '微信',
'openid': 'OpenID',
// 动词和常用词
'authorize': '授权',
'authorization': '授权',
'login': '登录',
'logout': '登出',
'register': '注册',
'call': '调用',
'return': '返回',
'using': '使用',
'bound to': '绑定到',
'according to': '根据',
// 名词
'user': '用户',
'code': '授权码',
'token': '令牌',
'session': '会话',
'request': '请求',
'response': '响应',
'interface': '接口',
'api': '接口',
'account': '账号',
'openid': 'OpenID',
// 描述性词汇
'first': '首先',
'then': '然后',
'if': '如果',
'else': '否则',
'need': '需要',
'should': '应该',
'must': '必须',
// 状态
'success': '成功',
'fail': '失败',
'error': '错误',
'empty': '空',
'non-empty': '非空',
// 属性
'avatar': '头像',
'name': '姓名',
'id': 'ID',
'info': '信息',
'data': '数据',
'flow': '流程',
'design': '设计',
// 其他
'internal': '内部',
'automatically': '自动',
'specify': '指定',
'testing': '测试',
'used for': '用于',
'bound with': '绑定',
};
let translated = text;
// 按照字典进行替换(优先匹配长词)
const sortedKeys = Object.keys(dictionary).sort((a, b) => b.length - a.length);
sortedKeys.forEach(key => {
const regex = new RegExp(key, 'gi');
translated = translated.replace(regex, dictionary[key]);
});
return translated;
}
/**
* 格式化描述文本(翻译+格式化)
* @param {string} description - 原始描述
* @returns {string} - 格式化后的描述
*/
function formatDescription(description) {
if (!description) return '';
// 移除 markdown 格式符号(如 # 标题)
let formatted = description
.replace(/^#+\s*/gm, '') // 移除标题符号
.replace(/\*\*(.*?)\*\*/g, '$1') // 移除加粗
.replace(/\*(.*?)\*/g, '$1') // 移除斜体
.replace(/`([^`]+)`/g, '$1') // 移除行内代码
.trim();
// 先进行整句翻译(常见句式)
formatted = translateSentences(formatted);
return formatted;
}
/**
* 翻译常见句式
* @param {string} text - 文本
* @returns {string} - 翻译后的文本
*/
function translateSentences(text) {
if (!text) return '';
// 常见句式翻译(按优先级排序,长的先匹配)
const sentences = {
// 完整句子
'# 登录流程设计': '# 登录流程设计',
'# Login Flow Design': '# 登录流程设计',
// 常见句式
'Authorize mini program first': '先进行小程序授权',
'If user is empty, call login API': '如果返回 user 为空,则需要调用登录接口',
'If user is not empty, no need to call login API': '如果返回 user 非空,则不需要调用登录接口',
'the authorization API will automatically login using the account bound to openid': '授权接口内部按照 openid 绑定的账号,自动登录',
'Specify an openid for testing': '指定一个 openid 用来测试',
'User information bound to openid': 'openid 绑定的用户信息',
'0=fail, 1=success': '0=失败,1=成功',
};
let translated = text;
// 按长度排序(长句优先)
const sortedKeys = Object.keys(sentences).sort((a, b) => b.length - a.length);
sortedKeys.forEach(key => {
const regex = new RegExp(escapeRegExp(key), 'gi');
translated = translated.replace(regex, sentences[key]);
});
// 最后进行单词级别的补充翻译
translated = translateWords(translated);
return translated;
}
/**
* 转义正则表达式特殊字符
* @param {string} string - 字符串
* @returns {string} - 转义后的字符串
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 单词级别的翻译(补充)
* @param {string} text - 文本
* @returns {string} - 翻译后的文本
*/
function translateWords(text) {
const dictionary = {
'mini program': '小程序',
'wechat': '微信',
'openid': 'OpenID',
'user': '用户',
'authorization': '授权',
'login': '登录',
'avatar': '头像',
'name': '姓名',
};
let translated = text;
Object.entries(dictionary).forEach(([key, value]) => {
const regex = new RegExp(key, 'gi');
translated = translated.replace(regex, value);
});
return translated;
}
/**
* 解析对象属性,生成字段描述
* @param {object} properties - 属性对象
* @param {number} indent - 缩进级别
* @returns {string} - 字段描述字符串
*/
function parseProperties(properties, indent = 0) {
if (!properties) return '';
const lines = [];
const prefix = ' '.repeat(indent);
Object.entries(properties).forEach(([key, value]) => {
const type = value.type || 'any';
const desc = value.description || value.title || '';
const required = value.required ? '' : ' (可选)';
// 基本类型
if (type !== 'object' && type !== 'array') {
lines.push(`${prefix}${key}: ${type}${required} - ${desc}`);
}
// 对象类型
else if (type === 'object' && value.properties) {
lines.push(`${prefix}${key}: {`);
lines.push(`${prefix} // ${desc}`);
lines.push(parseProperties(value.properties, indent + 2));
lines.push(prefix + '}');
}
// 数组类型
else if (type === 'array' && value.items) {
const itemType = value.items.type || 'any';
if (itemType === 'object' && value.items.properties) {
lines.push(`${prefix}${key}: Array<{`);
lines.push(`${prefix} // ${desc}`);
lines.push(parseProperties(value.items.properties, indent + 2));
lines.push(prefix + '}>');
} else {
lines.push(`${prefix}${key}: Array<${itemType}>${required} - ${desc}`);
}
}
});
return lines.join('\n');
}
/**
* 从 requestBody 中提取参数
* @param {object} requestBody - requestBody 对象
* @returns {Array} - 参数数组
*/
function extractRequestParams(requestBody) {
if (!requestBody || !requestBody.content) {
return [];
}
// 获取内容类型(可能是 application/x-www-form-urlencoded 或 application/json)
const content = requestBody.content['application/x-www-form-urlencoded'] ||
requestBody.content['application/json'];
if (!content || !content.schema || !content.schema.properties) {
return [];
}
const params = [];
Object.entries(content.schema.properties).forEach(([key, value]) => {
params.push({
name: key,
type: value.type || 'any',
description: value.description || '',
example: value.example || '',
required: content.schema.required?.includes(key) || false,
});
});
return params;
}
/**
* 生成 JSDoc 参数注释
* @param {Array} parameters - parameters 数组(GET 请求)
* @param {Array} bodyParams - requestBody 参数数组(POST 请求)
* @param {string} method - HTTP 方法
* @returns {string} - JSDoc 参数注释
*/
function generateParamJSDoc(parameters, bodyParams, method) {
const lines = [' * @param {Object} params 请求参数'];
// POST 请求使用 body 参数
if (method === 'POST' && bodyParams && bodyParams.length > 0) {
// 过滤掉 a、f 和 t 参数(这些参数已硬编码到 URL 中)
const filteredParams = bodyParams.filter(p => p.name !== 'a' && p.name !== 'f' && p.name !== 't');
filteredParams.forEach((param) => {
const type = param.type || 'any';
const desc = param.description || '';
const required = param.required ? '' : ' (可选)';
lines.push(` * @param {${type}} params.${param.name}${required} ${desc}`);
});
}
// GET 请求使用 query 参数
else if (method === 'GET' && parameters && parameters.length > 0) {
// 只保留 query 参数,过滤 header 参数和 a、f、t 参数
const queryParams = parameters.filter(p =>
p.in === 'query' &&
p.name !== 'a' &&
p.name !== 'f' &&
p.name !== 't'
);
queryParams.forEach((param) => {
const type = param.schema?.type || 'any';
const desc = param.description || '';
const required = param.required ? '' : ' (可选)';
lines.push(` * @param {${type}} params.${param.name}${required} ${desc}`);
});
}
return lines.join('\n');
}
/**
* 递归生成属性字段的 JSDoc 注释
* @param {object} properties - 属性对象
* @param {number} indent - 缩进级别(空格数)
* @returns {string} - JSDoc 注释
*/
function generatePropertiesJSDoc(properties, indent = 0) {
const lines = [];
const prefix = ' '.repeat(indent);
Object.entries(properties).forEach(([key, value]) => {
const type = value.type || 'any';
const desc = value.description || value.title || '';
// 处理嵌套对象
if (type === 'object' && value.properties) {
lines.push(`${prefix}${key}: {\n`);
// 递归处理嵌套对象的属性
lines.push(generatePropertiesJSDoc(value.properties, indent + 2));
lines.push(`${prefix}};\n`);
}
// 处理数组(元素是对象)
else if (type === 'array' && value.items && value.items.properties) {
lines.push(`${prefix}${key}: Array<{\n`);
// 递归处理数组元素的属性
lines.push(generatePropertiesJSDoc(value.items.properties, indent + 2));
lines.push(`${prefix}}>;\n`);
}
// 处理简单数组
else if (type === 'array' && value.items) {
const itemType = value.items.type || 'any';
lines.push(`${prefix}${key}: Array<${itemType}>; // ${desc}\n`);
}
// 处理基本类型
else {
lines.push(`${prefix}${key}: ${type}; // ${desc}\n`);
}
});
return lines.join('');
}
/**
* 生成 JSDoc 返回值注释
* @param {object} responseSchema - 响应 schema
* @returns {string} - JSDoc 返回值注释
*/
function generateReturnJSDoc(responseSchema) {
if (!responseSchema || !responseSchema.properties) {
return ' * @returns {Promise<{code:number,data:any,msg:string}>} 标准返回';
}
const { code, msg, data } = responseSchema.properties;
let returnDesc = ' * @returns {Promise<{\n';
returnDesc += ' * code: number; // 状态码\n';
returnDesc += ' * msg: string; // 消息\n';
if (data) {
const dataType = data.type || 'any';
const dataDesc = data.description || data.title || '';
// 处理对象类型的 data
if (dataType === 'object' && data.properties) {
returnDesc += ' * data: {\n';
// 使用递归函数处理 data 的所有属性
returnDesc += generatePropertiesJSDoc(data.properties, 4);
returnDesc += ' * };\n';
}
// 处理数组类型的 data(元素是对象)
else if (dataType === 'array' && data.items && data.items.properties) {
returnDesc += ' * data: Array<{\n';
returnDesc += generatePropertiesJSDoc(data.items.properties, 4);
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';
}
returnDesc += ' * }>}';
return returnDesc;
}
/**
* 解析 OpenAPI 文档并提取 API 信息
* @param {object} openapiDoc - 解析后的 OpenAPI 对象
* @param {string} fileName - 文件名(用作 API 名称)
* @returns {object} - 提取的 API 信息
*/
function parseOpenAPIDocument(openapiDoc, fileName) {
try {
const path = Object.keys(openapiDoc.paths)[0];
const method = Object.keys(openapiDoc.paths[path])[0];
const apiInfo = openapiDoc.paths[path][method];
// 提取 query 参数
const parameters = apiInfo.parameters || [];
const queryParams = {};
let actionValue = '';
let typeValue = ''; // t 参数
// 提取 body 参数(用于 POST 请求)
const requestBody = apiInfo.requestBody;
const bodyParams = extractRequestParams(requestBody);
// 对于 POST 请求,从 requestBody 中提取 action 和 type
if (requestBody && bodyParams.length > 0) {
const actionParam = bodyParams.find(p => p.name === 'a');
if (actionParam) {
actionValue = actionParam.example || '';
}
const typeParam = bodyParams.find(p => p.name === 't');
if (typeParam) {
typeValue = typeParam.example || '';
}
}
// 对于 GET 请求,从 query 参数中提取 action 和 type
if (parameters.length > 0) {
parameters.forEach((param) => {
if (param.in === 'query') {
queryParams[param.name] = param.example || param.schema?.default || '';
// 提取 action 参数(通常是 'a' 参数)
if (param.name === 'a' && !actionValue) {
actionValue = param.example || '';
}
// 提取 type 参数('t' 参数)
if (param.name === 't' && !typeValue) {
typeValue = param.example || '';
}
}
});
}
// 提取响应结构
const responseSchema = apiInfo.responses?.['200']?.content?.['application/json']?.schema;
return {
summary: apiInfo.summary || fileName,
description: apiInfo.description || '',
method: method.toUpperCase(),
action: actionValue,
type: typeValue, // 保存 t 参数
queryParams,
parameters, // 保存完整的参数信息用于生成 JSDoc(GET 请求)
bodyParams, // 保存 requestBody 参数用于生成 JSDoc(POST 请求)
responseSchema, // 保存响应结构用于生成 JSDoc
fileName,
};
} catch (error) {
console.error(`解析 OpenAPI 文档失败: ${error.message}`);
return null;
}
}
/**
* 生成 API 文件内容
* @param {string} moduleName - 模块名称
* @param {Array} apis - API 信息数组
* @returns {string} - 生成的文件内容
*/
function generateApiFileContent(moduleName, apis) {
const imports = `import { fn, fetch } from '@/api/fn';\n\n`;
const apiConstants = [];
const apiFunctions = [];
apis.forEach((api) => {
// 生成常量名(帕斯卡命名)
const constantName = toPascalCase(api.fileName);
// 生成函数名(驼峰命名 + API 后缀)
const functionName = toCamelCase(api.fileName) + 'API';
// 构建 URL,包含 a 和 t 参数
let url = '/srv/?';
const params = [];
if (api.action) params.push(`a=${api.action}`);
if (api.type) params.push(`t=${api.type}`);
url += params.join('&');
// 添加常量定义
apiConstants.push(
` ${constantName}: '${url}',`
);
// 生成详细的 JSDoc 注释
const paramJSDoc = generateParamJSDoc(api.parameters, api.bodyParams, api.method);
const returnJSDoc = generateReturnJSDoc(api.responseSchema);
// 添加函数定义
const fetchMethod = api.method === 'GET' ? 'fetch.get' : 'fetch.post';
// 格式化描述
const formattedDesc = formatDescription(api.description);
// 生成 JSDoc 注释(包含描述)
const comment = `/**
* @description ${api.summary}
* @remark ${formattedDesc}
${paramJSDoc}
${returnJSDoc}
*/`;
apiFunctions.push(`${comment}\nexport const ${functionName} = (params) => fn(${fetchMethod}(Api.${constantName}, params));`);
});
return `${imports}const Api = {\n${apiConstants.join('\n')}\n}\n\n${apiFunctions.join('\n\n')}\n`;
}
/**
* 扫描目录并处理所有 OpenAPI 文档
* @param {string} openAPIDir - OpenAPI 文档目录
* @param {string} outputDir - 输出目录
*/
function scanAndGenerate(openAPIDir, outputDir) {
if (!fs.existsSync(openAPIDir)) {
console.error(`OpenAPI 目录不存在: ${openAPIDir}`);
return;
}
// 确保输出目录存在
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// 扫描第一级目录(模块)
const modules = fs.readdirSync(openAPIDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
console.log(`找到 ${modules.length} 个模块: ${modules.join(', ')}`);
modules.forEach((moduleName) => {
const moduleDir = path.join(openAPIDir, moduleName);
const apiFiles = fs.readdirSync(moduleDir)
.filter(file => file.endsWith('.md') && file !== 'CLAUDE.md');
if (apiFiles.length === 0) {
console.log(`模块 ${moduleName} 中没有找到 .md 文件`);
return;
}
console.log(`\n处理模块: ${moduleName}`);
console.log(`找到 ${apiFiles.length} 个 API 文档`);
const apis = [];
apiFiles.forEach((fileName) => {
const filePath = path.join(moduleDir, fileName);
const content = fs.readFileSync(filePath, 'utf8');
const yamlContent = extractYAMLFromMarkdown(content);
if (!yamlContent) {
console.warn(` ⚠️ ${fileName}: 未找到 YAML 代码块`);
return;
}
try {
const openapiDoc = yaml.load(yamlContent);
const apiName = path.basename(fileName, '.md');
const apiInfo = parseOpenAPIDocument(openapiDoc, apiName);
if (apiInfo) {
apis.push(apiInfo);
console.log(` ✓ ${apiName}: ${apiInfo.summary}`);
}
} catch (error) {
console.error(` ✗ ${fileName}: 解析失败 - ${error.message}`);
}
});
// 生成并保存 API 文件
if (apis.length > 0) {
const fileContent = generateApiFileContent(moduleName, apis);
const outputPath = path.join(outputDir, `${moduleName}.js`);
fs.writeFileSync(outputPath, fileContent, 'utf8');
console.log(` 📝 生成文件: ${outputPath}`);
}
});
console.log('\n✅ API 文档生成完成!');
// 对比新旧 API
console.log('\n🔍 开始检测 API 变更...\n');
compareAPIChanges(openAPIDir);
}
/**
* 备份 OpenAPI 文档目录
* @param {string} sourceDir - 源目录
* @returns {string} - 备份目录路径
*/
function backupOpenAPIDir(sourceDir) {
const backupBaseDir = path.resolve(__dirname, '../.tmp');
const backupDir = path.join(backupBaseDir, 'api-specs-backup');
// 创建备份目录
if (!fs.existsSync(backupBaseDir)) {
fs.mkdirSync(backupBaseDir, { recursive: true });
}
// 删除旧备份
if (fs.existsSync(backupDir)) {
fs.rmSync(backupDir, { recursive: true, force: true });
}
// 复制目录
copyDirectory(sourceDir, backupDir);
return backupDir;
}
/**
* 递归复制目录
* @param {string} src - 源路径
* @param {string} dest - 目标路径
*/
function copyDirectory(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDirectory(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
/**
* 对比新旧 API 变更
* @param {string} openAPIDir - OpenAPI 文档目录
*/
function compareAPIChanges(openAPIDir) {
const backupDir = path.resolve(__dirname, '../.tmp/api-specs-backup');
const tempDir = path.resolve(__dirname, '../.tmp/api-specs-temp');
// 检查是否存在临时备份(上一次的版本)
if (!fs.existsSync(tempDir)) {
console.log('ℹ️ 首次运行,已建立基线。下次运行将检测 API 变更。');
// 将当前备份移动到临时目录,作为下次对比的基线
if (fs.existsSync(backupDir)) {
fs.renameSync(backupDir, tempDir);
}
return;
}
// 扫描模块
const modules = fs.readdirSync(openAPIDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
let hasChanges = false;
const moduleReports = [];
modules.forEach((moduleName) => {
const moduleDir = path.join(openAPIDir, moduleName);
const tempModuleDir = path.join(tempDir, moduleName);
// 如果临时备份中不存在该模块,说明是新增模块
if (!fs.existsSync(tempModuleDir)) {
console.log(`\n📦 新增模块: ${moduleName}`);
// 解析新增模块的接口
try {
const newDocs = parseOpenAPIPath(moduleDir);
if (newDocs && newDocs.length > 0) {
// 显示新增接口信息
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) {
console.error(` ⚠️ 解析模块失败: ${error.message}`);
}
hasChanges = true;
return;
}
// 读取当前和临时备份的文档
const currentFiles = fs.readdirSync(moduleDir).filter(f => f.endsWith('.md') && f !== 'CLAUDE.md');
const tempFiles = fs.readdirSync(tempModuleDir).filter(f => f.endsWith('.md') && f !== 'CLAUDE.md');
// 检查是否有文件变更
const hasNewFiles = currentFiles.some(f => !tempFiles.includes(f));
const hasRemovedFiles = tempFiles.some(f => !currentFiles.includes(f));
const hasModifiedFiles = currentFiles.some(f => {
if (!tempFiles.includes(f)) return false;
const currentContent = fs.readFileSync(path.join(moduleDir, f), 'utf8');
const tempContent = fs.readFileSync(path.join(tempModuleDir, f), 'utf8');
return currentContent !== tempContent;
});
if (hasNewFiles || hasRemovedFiles || hasModifiedFiles) {
hasChanges = true;
moduleReports.push({ moduleName, moduleDir, tempModuleDir });
}
});
// 检查删除的模块
const tempModules = fs.existsSync(tempDir)
? fs.readdirSync(tempDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name)
: [];
const deletedModules = tempModules.filter(m => !modules.includes(m));
if (deletedModules.length > 0) {
hasChanges = true;
console.log(`\n❌ 删除模块: ${deletedModules.join(', ')}`);
}
if (!hasChanges) {
console.log('✅ 未检测到 API 变更');
// 更新基线
if (fs.existsSync(backupDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
fs.renameSync(backupDir, tempDir);
}
return;
}
// 逐个模块对比
console.log('');
moduleReports.forEach(({ moduleName, moduleDir, tempModuleDir }) => {
try {
const oldDocs = parseOpenAPIPath(tempModuleDir);
const newDocs = parseOpenAPIPath(moduleDir);
const report = generateReport(oldDocs, newDocs, 'text');
console.log(report);
console.log('');
} catch (error) {
console.error(`⚠️ 模块 ${moduleName} 对比失败: ${error.message}`);
}
});
// 更新基线:将当前备份作为下次对比的基准
console.log('📝 更新 API 基线...');
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
if (fs.existsSync(backupDir)) {
fs.renameSync(backupDir, tempDir);
}
}
// 执行生成
const openAPIDir = path.resolve(__dirname, '../docs/api-specs');
const outputDir = path.resolve(__dirname, '../src/api');
console.log('=== OpenAPI 转 API 文档生成器 ===\n');
console.log(`输入目录: ${openAPIDir}`);
console.log(`输出目录: ${outputDir}\n`);
// 备份当前的 OpenAPI 文档(用于下次对比)
if (fs.existsSync(openAPIDir)) {
console.log('💾 备份当前 OpenAPI 文档...');
backupOpenAPIDir(openAPIDir);
console.log('');
}
scanAndGenerate(openAPIDir, outputDir);
/*
* @Date: 2025-09-04 16:44:18
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-04 16:53:10
* @LastEditTime: 2026-02-09 16:33:11
* @FilePath: /map-demo/src/api/checkin.js
* @Description: 文件描述
*/
import { fn, fetch } from '@/api/fn';
const Api = {
IS_CHECKED: '/srv/?f=walk&a=map&t=is_checked',
CHECKIN: '/srv/?f=walk&a=map&t=checkin',
IS_CHECKED: '/srv/?f=walk&a=map_activity&t=is_checked',
CHECKIN: '/srv/?f=walk&a=map_activity&t=checkin',
};
/**
......
......@@ -7,5 +7,6 @@
| ID | Time | T | Title | Read |
|----|------|---|-------|------|
| #4119 | 2:13 PM | 🔵 | page_details data source identified in checkin info component | ~328 |
| #3978 | 11:52 AM | 🔵 | Code duplication identified in src/views directory structure | ~320 |
</claude-mem-context>
\ No newline at end of file
......