Generate TS Interface

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

Fra og med 20.10.2025. Se den nyeste version.

// ==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);
})();