Generate TS Interface

Generate TS interfaces from YApi with robust nested array and anonymous node handling (I prefix, remove List, skip method prefix)

Від 20.10.2025. Дивіться остання версія.

// ==UserScript==
// @name         Generate TS Interface
// @namespace    http://tampermonkey.net/
// @version      1.8.0
// @description  Generate TS interfaces from YApi with robust nested array and anonymous node handling (I prefix, remove List, skip method prefix)
// @author       ihopeful
// @match        *://*/project/*/interface/api/*
// @grant        GM_setClipboard
// @icon         https://yapi.pro/image/favicon.png
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const typeMap = {
    string: 'string',
    number: 'number',
    integer: 'number',
    boolean: 'boolean',
    object: 'Record<string, any>',
    'object []': 'Record<string, any>[]',
    array: 'any[]'
  };

  const toTsType = t => {
    if (!t) return 'any';
    const lower = t.toLowerCase();
    if (lower.includes('array')) return 'any[]';
    if (lower.includes('object')) return 'Record<string, any>';
    return typeMap[lower] || 'any';
  };

  const capitalize = str => str.charAt(0).toUpperCase() + str.slice(1);

  const ignoreNames = ['List', 'Data', 'Response'];
  let anonCount = 1;
  const interfaces = [];

  /** 自动展开所有折叠行 */
  function expandAll(callback) {
    const icons = Array.from(document.querySelectorAll('span.ant-table-row-expand-icon'))
      .filter(el => el.className.includes('ant-table-row-collapsed'));
    if (icons.length === 0) {
      if (callback) callback();
      return;
    }
    icons.forEach(icon => {
      icon.scrollIntoView();
      icon.click();
    });
    setTimeout(() => expandAll(callback), 500);
  }

  /** 安全生成接口名 */
  function safeInterfaceName(name, parentName, isArray) {
    let base;

    if (!name) {
      // 匿名对象用父名 + DTO
      base = `${parentName.replace(/^I/, '')}DTO`;
    } else {
      base = capitalize(name);
      if (base.endsWith('List')) base = base.slice(0, -4);
    }

    let interfaceName = 'I' + base;

    // 重名检测
    if (interfaces.some(i => i.startsWith(`interface ${interfaceName} `))) {
      interfaceName += anonCount++;
    }

    return interfaceName;
  }

  /** 解析请求参数 */
  function parseRequest(prefix) {
    const rows = document.querySelectorAll('.colQuery table tbody tr');
    if (!rows.length) return null;

    const props = Array.from(rows).map(tr => {
      const tds = tr.querySelectorAll('td');
      const name = tds[0]?.innerText.trim();
      const required = (tds[1]?.innerText || '').includes('是');
      const comment = tds[3]?.innerText.trim() || name;
      const type = 'string | number';
      return `  /** ${comment} */\n  ${name}${required ? '' : '?'}: ${type};`;
    });

    return `interface ${prefix}RequestQuery {\n${props.join('\n')}\n}`;
  }

  /** 解析响应参数 */
  function parseResponse(prefix) {
    const rows = document.querySelectorAll('h2.interface-title + div table tbody tr');
    if (!rows.length) return null;

    const root = { name: 'Response', children: [] };
    const stack = [root];

    Array.from(rows).forEach(tr => {
      const levelClass = Array.from(tr.classList).find(c => c.includes('ant-table-row-level-'));
      const level = levelClass ? parseInt(levelClass.split('-').pop(), 10) : 0;
      const tds = tr.querySelectorAll('td');
      const name = tds[0]?.innerText.trim();
      const typeRaw = tds[1]?.innerText.trim();
      const required = (tds[2]?.innerText || '').includes('必须');
      const comment = tds[4]?.innerText.trim() || name;
      const type = toTsType(typeRaw);

      const node = { name, type, required, comment, children: [], rawType: typeRaw || '' };
      while (stack.length > level + 1) stack.pop();
      stack[stack.length - 1].children.push(node);
      stack.push(node);
    });

   function genInterface(nodes, parentName, rootPrefix = parentName.replace(/^I/, '')) {
  let res = '';

  nodes.forEach(n => {
    const pad = '  ';
    const comment = `/** ${n.comment || ''} */\n`;
    const isArray = n.rawType.includes('[]');
    const hasChildren = n.children.length > 0;

    if (hasChildren) {
      // ✅ 若为数组 + 匿名对象子项 → 提升为 DTO[]
      if (isArray && n.children.length === 1 && (!n.children[0].name || n.children[0].name === '')) {
        const child = n.children[0];
        const dtoName = `I${rootPrefix}${capitalize(n.name.replace(/List$/, ''))}DTO`;
        interfaces.push(`interface ${dtoName} {\n${genInterface(child.children, dtoName, rootPrefix)}\n}`);
        n.type = `${dtoName}[]`;
        res += `${pad}${comment}${n.name}${n.required ? '' : '?'}: ${n.type};\n`;
      } else {
        // ✅ 子对象用 rootPrefix + 当前字段名 生成
        const interfaceName = `I${rootPrefix}${capitalize(n.name.replace(/List$/, ''))}`;
        interfaces.push(`interface ${interfaceName} {\n${genInterface(n.children, interfaceName, rootPrefix)}\n}`);
        n.type = interfaceName + (isArray ? '[]' : '');
        res += `${pad}${comment}${n.name}${n.required ? '' : '?'}: ${n.type};\n`;
      }
    } else {
      res += `${pad}${comment}${n.name}${n.required ? '' : '?'}: ${n.type};\n`;
    }
  });

  return res.trim();
}
    interfaces.push(`interface ${prefix}Response {\n${genInterface(root.children, prefix)}\n}`);
    return interfaces.join('\n\n');
  }

  /** 获取接口名和 URL */
  function parseInterfaceName() {
    const nameEl = document.querySelector('.panel-view .colName');
    return nameEl?.innerText.trim() || '接口名称';
  }

  function parseApiUrl() {
    const urlEl = document.querySelector('.panel-view .colValue .colValue:nth-child(2)');
    if (!urlEl) return { method: 'get', url: '' };
    const method = urlEl.previousElementSibling?.innerText.toLowerCase() || 'get';
    let url = urlEl.innerText.trim();
    url = url.replace(/^\/admin/, '');
    return { method, url };
  }

  /** 生成函数名(去掉method前缀) */
  function genFuncName(url) {
    return url
      .split('/')
      .filter(Boolean)
      .map((s, i) => (i === 0 ? s.toLowerCase() : capitalize(s)))
      .join('');
  }

  /** 生成并插入 TS Interface */
  function generateInterface() {
    const apiName = parseInterfaceName();
    const { method, url } = parseApiUrl();
    const funcName = genFuncName(url);
    const prefix = 'I' + capitalize(funcName);

    const req = parseRequest(prefix);
    const res = parseResponse(prefix);

    if (!res) return;

    const paramsStr = req ? `params: ${prefix}RequestQuery` : 'params?: Record<string, any>';
    const func = `
/**
 * ${apiName}
 */
export async function ${funcName}(${paramsStr}): Promise<${prefix}Response> {
  return await ${method}(\`${url}\`, ${req ? 'params' : 'params'});
}
`;

    const result = [req, res, func].filter(Boolean).join('\n\n');

    const panelView = document.querySelector('.panel-view');
    if (panelView) {
      const pre = document.getElementById('ts-interface-generate');
      if (pre) pre.remove();
      const preEl = document.createElement('pre');
      preEl.id = 'ts-interface-generate';
      preEl.style = 'background:#f0f0f0;padding:10px;border-radius:6px;margin:10px 0;white-space:pre-wrap;position:relative;';
      preEl.textContent = result;

      const btn = document.createElement('button');
      btn.textContent = '复制';
      btn.style = 'position:absolute;top:5px;right:5px;padding:2px 6px;font-size:12px;cursor:pointer;';
      btn.onclick = () => {
        GM_setClipboard(result);
        alert('复制成功✅');
      };
      preEl.appendChild(btn);

      panelView.parentNode.insertBefore(preEl, panelView.nextSibling);
    }
  }

  // 自动展开并生成
  setTimeout(() => expandAll(generateInterface), 1500);
})();