API Console and Request Rewriter

Send API requests and rewrite this site's fetch/XHR requests and responses.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         API Console and Request Rewriter
// @namespace    local.api-console
// @version      2.0.1
// @description  Send API requests and rewrite this site's fetch/XHR requests and responses.
// @match        http://*/*
// @match        https://*/*
// @run-at       document-start
// @grant        GM_addElement
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @connect      *
// ==/UserScript==

(function () {
  'use strict';

  const isTopFrame = window.top === window.self;

  const STORAGE_KEY = 'tm_api_console_state_v1';
  const RULES_KEY = 'tm_api_console_rules_v1';
  const DEBUG_KEY = 'tm_api_console_rewrite_debug_v1';
  const DEBUG_RECORDS_KEY = 'tm_api_console_debug_records_v1';
  const PANEL_SETTINGS_KEY = 'tm_api_console_panel_settings_v1';
  const BUTTON_STATE_KEY = 'tm_api_console_button_state_v5';
  const PANEL_LAYOUT_KEY = 'tm_api_console_panel_layout_v1';
  const EVENT_NAME = 'tm-api-console:rules-updated';
  const DEBUG_EVENT_NAME = 'tm-api-console:rewrite-debug';
  const DEBUG_SYNC_EVENT_NAME = 'tm-api-console:debug-sync';
  const DEBUG_BRIDGE_MESSAGE_TYPE = 'tm-api-console:frame-debug';
  const CONTENT_TYPES = [
    'application/json',
    'application/x-www-form-urlencoded;charset=UTF-8',
    'multipart/form-data',
    'text/plain;charset=UTF-8'
  ];
  const COMMON_HEADERS = [
    'Content-Type',
    'Authorization',
    'Accept',
    'Accept-Language',
    'Cache-Control',
    'X-API-Key',
    'X-Request-ID',
    'X-Requested-With'
  ];
  const DEBUG_RECORD_LIMITS = [100, 300, 500, 1000];
  const PERSISTED_DEBUG_RECORD_LIMIT = 50;
  const PERSISTED_DEBUG_TEXT_LIMIT = 6000;
  const DEFAULT_STATE = {
    method: 'POST',
    urlMode: 'manual',
    url: '',
    params: [],
    headers: [],
    contentType: 'application/json',
    customContentType: '',
    bodyType: 'json',
    jsonBody: '{\n  \n}',
    bodyFields: [],
    rawBody: '',
    batchFields: [],
    batchExecutionMode: 'all',
    batchIntervalSeconds: 0,
    batchSuccessMode: 'http',
    batchSuccessPath: '',
    batchSuccessOperator: 'equals',
    batchSuccessExpected: '0',
    responseMode: 'preview',
    downloadFileName: '',
    rules: '[]'
  };

  installRequestInterceptor();

  if (!isTopFrame) {
    return;
  }

  function installRequestInterceptor() {
    const interceptor = function (storageKey, rulesKey, eventName, debugKey, debugEventName, debugSyncEventName, debugBridgeMessageType) {
      const isNestedFrame = window.top !== window;
      const eventTarget = (() => {
        try {
          if (window.top && window.top !== window && window.top.location.origin === window.location.origin) {
            return window.top;
          }
        } catch {
        }
        return window;
      })();
      let rules = readRules();
      let debugSequence = 0;
      const debugBacklog = [];
      const MAX_DEBUG_BACKLOG = 200;
      const frameIdentifier = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;

      eventTarget.addEventListener(eventName, (event) => {
        rules = Array.isArray(event.detail) ? event.detail : readRules();
      });
      eventTarget.addEventListener(debugSyncEventName, () => {
        debugBacklog.forEach(dispatchDebugDetail);
      });

      function readRules() {
        try {
          const saved = localStorage.getItem(rulesKey) || localStorage.getItem(storageKey);
          const parsed = JSON.parse(saved || '[]');
          return Array.isArray(parsed) ? parsed : parsed.rules || [];
        } catch {
          return [];
        }
      }

      function isDebugEnabled() {
        try {
          return JSON.parse(localStorage.getItem(debugKey) || '{}').enabled === true;
        } catch {
          return false;
        }
      }

      function cloneDebugRequestBody(value) {
        if (typeof Request !== 'undefined' && value instanceof Request) {
          try {
            return value.clone();
          } catch {
          }
        }
        return value;
      }

      function dispatchDebugDetail(detail) {
        if (eventTarget === window && isNestedFrame) {
          try {
            window.top.postMessage({
              type: debugBridgeMessageType,
              detail
            }, '*');
            return;
          } catch {
          }
        }

        eventTarget.dispatchEvent(new CustomEvent(debugEventName, { detail }));
      }

      function emitDebug(entry) {
        if (!isDebugEnabled()) {
          return;
        }

        const detail = {
          ...entry,
          timestamp: Date.now(),
          frameUrl: location.href
        };
        debugBacklog.push(detail);
        while (debugBacklog.length > MAX_DEBUG_BACKLOG) {
          debugBacklog.shift();
        }
        dispatchDebugDetail(detail);
      }

      function nextDebugId(transport) {
        debugSequence += 1;
        return `${frameIdentifier}-${transport}-${debugSequence}`;
      }

      function findRule(url, method) {
        for (let index = 0; index < rules.length; index += 1) {
          const rule = rules[index];
          if (!rule || rule.enabled === false || typeof rule.match !== 'string' || !rule.match) {
            continue;
          }

          const allowedMethods = Array.isArray(rule.methods)
            ? rule.methods.map((item) => String(item).toUpperCase())
            : [];

          if (allowedMethods.length && !allowedMethods.includes(method)) {
            continue;
          }

          if (rule.matchType === 'regex') {
            try {
              if (new RegExp(rule.match).test(url)) {
                return { rule, index };
              }
            } catch {
              continue;
            }
            continue;
          }

          if (url.includes(rule.match)) {
            return { rule, index };
          }
        }

        return null;
      }

      function rewriteUrl(url, rule) {
        if (!rule.replaceUrl) {
          return url;
        }

        const replacement = String(rule.replaceUrl).trim();
        if (!replacement) {
          return url;
        }

        try {
          const absoluteReplacement = new URL(replacement);
          if (absoluteReplacement.protocol === 'http:' || absoluteReplacement.protocol === 'https:') {
            return absoluteReplacement.href;
          }
        } catch {
        }

        if (rule.matchType === 'regex') {
          try {
            return url.replace(new RegExp(rule.match), replacement);
          } catch {
            return url;
          }
        }

        return url.replace(rule.match, replacement);
      }

      function applyHeaders(originalHeaders, ruleHeaders) {
        const headers = new Headers(originalHeaders || undefined);

        if (ruleHeaders && typeof ruleHeaders === 'object' && !Array.isArray(ruleHeaders)) {
          Object.entries(ruleHeaders).forEach(([name, value]) => {
            headers.set(name, String(value));
          });
        }

        return headers;
      }

      const customScriptCache = new Map();

      function headersToObject(headers) {
        const result = {};
        new Headers(headers || undefined).forEach((value, name) => {
          result[name] = value;
        });
        return result;
      }

      const DEBUG_TEXT_LIMIT = 60000;

      function limitDebugText(value) {
        const text = String(value ?? '');
        return {
          text: text.slice(0, DEBUG_TEXT_LIMIT),
          truncated: text.length > DEBUG_TEXT_LIMIT
        };
      }

      function addDebugObjectValue(target, key, value) {
        if (!Object.prototype.hasOwnProperty.call(target, key)) {
          target[key] = value;
          return;
        }

        target[key] = Array.isArray(target[key])
          ? [...target[key], value]
          : [target[key], value];
      }

      function queryParamsToObject(url) {
        const result = {};
        try {
          new URL(String(url), location.href).searchParams.forEach((value, key) => {
            addDebugObjectValue(result, key, value);
          });
        } catch {
        }
        return result;
      }

      function bodyToDebugValue(body, headers) {
        if (body == null || body === '') {
          return null;
        }

        const contentType = String(new Headers(headers || undefined).get('content-type') || '').toLowerCase();
        if (typeof body === 'string') {
          const limited = limitDebugText(body);
          const looksLikeJson = contentType.includes('json') || /^\s*[\[{]/.test(body);
          if (looksLikeJson && !limited.truncated) {
            try {
              return { type: 'json', value: JSON.parse(body), raw: limited.text, truncated: false };
            } catch {
            }
          }
          return { type: 'text', value: limited.text, truncated: limited.truncated };
        }

        if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
          const value = {};
          body.forEach((entryValue, key) => addDebugObjectValue(value, key, entryValue));
          return { type: 'form', value, truncated: false };
        }

        if (typeof FormData !== 'undefined' && body instanceof FormData) {
          const value = {};
          body.forEach((entryValue, key) => {
            if (typeof File !== 'undefined' && entryValue instanceof File) {
              addDebugObjectValue(value, key, {
                type: 'file',
                name: entryValue.name,
                mimeType: entryValue.type || 'application/octet-stream',
                size: entryValue.size
              });
            } else {
              addDebugObjectValue(value, key, String(entryValue));
            }
          });
          return { type: 'multipart', value, truncated: false };
        }

        if (typeof Blob !== 'undefined' && body instanceof Blob) {
          return {
            type: 'binary',
            value: {
              name: body.name || '',
              mimeType: body.type || 'application/octet-stream',
              size: body.size
            },
            truncated: false
          };
        }

        if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
          return {
            type: 'binary',
            value: { size: body.byteLength, mimeType: 'application/octet-stream' },
            truncated: false
          };
        }

        try {
          const serialized = JSON.stringify(body);
          const limited = limitDebugText(serialized);
          if (!limited.truncated) {
            return { type: 'json', value: JSON.parse(serialized), raw: limited.text, truncated: false };
          }
          return { type: 'text', value: limited.text, truncated: true };
        } catch {
          const limited = limitDebugText(body);
          return { type: 'text', value: limited.text, truncated: limited.truncated };
        }
      }

      async function readDebugBody(body, headers) {
        if (typeof Request !== 'undefined' && body instanceof Request) {
          try {
            return bodyToDebugValue(await body.clone().text(), body.headers);
          } catch {
            return { type: 'unavailable', value: '请求体无法读取(可能已被消费或为不可读流)。', truncated: false };
          }
        }
        return bodyToDebugValue(body, headers);
      }

      function responseHeadersToObject(rawHeaders) {
        const result = {};
        String(rawHeaders || '').split(/\r?\n/).forEach((line) => {
          const separatorIndex = line.indexOf(':');
          if (separatorIndex > 0) {
            result[line.slice(0, separatorIndex).trim().toLowerCase()] = line.slice(separatorIndex + 1).trim();
          }
        });
        return result;
      }

      function isBinaryContentType(contentType) {
        return /application\/(?:octet-stream|pdf|zip|gzip)|(?:image|audio|video)\//.test(String(contentType || '').toLowerCase());
      }

      async function captureFetchResponse(response) {
        const responseHeaders = headersToObject(response.headers);
        const contentType = responseHeaders['content-type'] || '';
        if (isBinaryContentType(contentType)) {
          return {
            headers: responseHeaders,
            contentType,
            body: { type: 'binary', value: { mimeType: contentType, size: null }, truncated: false }
          };
        }

        try {
          return {
            headers: responseHeaders,
            contentType,
            body: bodyToDebugValue(await response.clone().text(), responseHeaders)
          };
        } catch {
          return { headers: responseHeaders, contentType, body: { type: 'unavailable', value: '响应内容无法读取。', truncated: false } };
        }
      }

      function captureXhrResponse(xhr) {
        const responseHeaders = responseHeadersToObject(xhr.getAllResponseHeaders());
        const contentType = responseHeaders['content-type'] || '';
        if (isBinaryContentType(contentType) || !['', 'text', 'json'].includes(xhr.responseType || '')) {
          return {
            headers: responseHeaders,
            contentType,
            body: { type: 'binary', value: { mimeType: contentType || 'application/octet-stream', size: null }, truncated: false }
          };
        }

        const responseValue = xhr.responseType === 'json' ? xhr.response : xhr.responseText;
        const responseText = xhr.responseType === 'json' ? JSON.stringify(responseValue) : responseValue;
        return { headers: responseHeaders, contentType, body: bodyToDebugValue(responseText, responseHeaders) };
      }

      function getCustomScriptExecutor(source) {
        const code = String(source || '').trim();
        if (customScriptCache.has(code)) {
          return customScriptCache.get(code);
        }

        let executor;
        try {
          const functionExecutor = new Function('request', 'context', `'use strict'; return (${code})(request, context);`);
          executor = (request, context) => functionExecutor(request, context);
        } catch {
          const bodyExecutor = new Function('request', 'context', `'use strict';\n${code}\nreturn request;`);
          executor = (request, context) => bodyExecutor(request, context);
        }
        customScriptCache.set(code, executor);
        return executor;
      }

      function runCustomRequestScript(rule, requestState, transport) {
        const script = rule && rule.script;
        if (!script || script.enabled !== true || !String(script.code || '').trim()) {
          return { request: requestState, error: '' };
        }

        try {
          const request = {
            url: requestState.url,
            method: requestState.method,
            headers: headersToObject(requestState.headers),
            body: requestState.body
          };
          const context = {
            phase: 'request',
            transport,
            ruleIndex: requestState.ruleIndex
          };
          const result = getCustomScriptExecutor(script.code)(request, context);
          if (result && typeof result.then === 'function') {
            throw new Error('暂不支持异步 JS 函数,请使用同步函数。');
          }

          const returnedRequest = result && typeof result === 'object' && result.request && typeof result.request === 'object'
            ? result.request
            : result && typeof result === 'object' ? result : request;
          const finalUrl = returnedRequest.url == null ? request.url : new URL(String(returnedRequest.url), location.href).href;
          const finalMethod = String(returnedRequest.method || request.method).toUpperCase();
          const finalHeaders = new Headers(returnedRequest.headers || request.headers);
          return {
            request: {
              url: finalUrl,
              method: finalMethod,
              headers: finalHeaders,
              body: Object.prototype.hasOwnProperty.call(returnedRequest, 'body')
                ? returnedRequest.body
                : request.body,
              ruleIndex: requestState.ruleIndex
            },
            error: ''
          };
        } catch (error) {
          return { request: requestState, error: error?.message || '自定义 JS 执行失败' };
        }
      }

      function supportsResponseRewrite(rule) {
        return rule && ['replace', 'merge-json'].includes(rule.responseMode);
      }

      function describeChanges(rule, originalUrl, rewrittenUrl, method) {
        if (!rule) {
          return [];
        }

        const changes = [];
        if (originalUrl !== rewrittenUrl) {
          changes.push('URL');
        }
        if (rule.headers && typeof rule.headers === 'object' && !Array.isArray(rule.headers)
          && Object.keys(rule.headers).length) {
          changes.push(`请求头 ${Object.keys(rule.headers).length} 项`);
        }
        if (rule.bodyMode === 'replace' && !['GET', 'HEAD'].includes(method)) {
          changes.push('请求体');
        }
        if (supportsResponseRewrite(rule)) {
          changes.push('响应内容');
        }
        if (rule.script && rule.script.enabled === true && String(rule.script.code || '').trim()) {
          changes.push('自定义 JS');
        }
        return changes;
      }

      function mergeJsonValue(originalValue, overrideValue) {
        if (!originalValue || !overrideValue || Array.isArray(originalValue) || Array.isArray(overrideValue)
          || typeof originalValue !== 'object' || typeof overrideValue !== 'object') {
          return overrideValue;
        }

        const mergedValue = { ...originalValue };
        Object.entries(overrideValue).forEach(([key, value]) => {
          mergedValue[key] = mergeJsonValue(mergedValue[key], value);
        });
        return mergedValue;
      }

      function rewriteResponseText(responseText, rule) {
        if (rule.responseMode === 'replace') {
          return rule.responseBody == null ? '' : String(rule.responseBody);
        }

        if (rule.responseMode === 'merge-json') {
          const originalValue = JSON.parse(responseText);
          const overrideValue = JSON.parse(rule.responseBody || '{}');
          return JSON.stringify(mergeJsonValue(originalValue, overrideValue));
        }

        return responseText;
      }

      async function rewriteFetchResponse(response, rule) {
        if (!supportsResponseRewrite(rule) || [204, 205, 304].includes(response.status)) {
          return response;
        }

        try {
          const responseText = await response.clone().text();
          const rewrittenText = rewriteResponseText(responseText, rule);
          const headers = new Headers(response.headers);
          headers.delete('content-length');
          headers.delete('content-encoding');
          return new Response(rewrittenText, {
            status: response.status,
            statusText: response.statusText,
            headers
          });
        } catch {
          return response;
        }
      }

      function rewriteXhrResponse(xhr, rule) {
        if (!supportsResponseRewrite(rule)) {
          return;
        }

        try {
          const responseType = xhr.responseType || 'text';
          if (!['text', 'json'].includes(responseType)) {
            return;
          }

          const originalText = responseType === 'json'
            ? JSON.stringify(xhr.response)
            : xhr.responseText;
          const rewrittenText = rewriteResponseText(originalText, rule);

          if (responseType === 'json') {
            const rewrittenValue = JSON.parse(rewrittenText);
            Object.defineProperty(xhr, 'response', {
              configurable: true,
              get: () => rewrittenValue
            });
            return;
          }

          Object.defineProperties(xhr, {
            responseText: {
              configurable: true,
              get: () => rewrittenText
            },
            response: {
              configurable: true,
              get: () => rewrittenText
            }
          });
        } catch {
        }
      }

      const nativeFetch = window.fetch;
      window.fetch = function (input, init) {
        const request = input instanceof Request ? input : null;
        const requestInit = init || {};
        const suppliedUrl = request ? request.url : String(input);
        let url = suppliedUrl;
        try {
          url = new URL(suppliedUrl, location.href).href;
        } catch {
          url = suppliedUrl;
        }
        const method = String(requestInit.method || (request && request.method) || 'GET').toUpperCase();
        const matchedRule = findRule(url, method);
        const rule = matchedRule?.rule;
        const debugId = nextDebugId('fetch');
        const debugEnabled = isDebugEnabled();
        const originalRequestHeaders = headersToObject(requestInit.headers || (request && request.headers));
        const originalRequestBody = Object.prototype.hasOwnProperty.call(requestInit, 'body')
          ? requestInit.body
          : request;
        const debugOriginalRequestBody = debugEnabled
          ? cloneDebugRequestBody(originalRequestBody)
          : originalRequestBody;

        let fetchRequest;
        let finalRequest;
        let scriptError = '';
        if (!rule) {
          fetchRequest = nativeFetch.apply(this, arguments);
        } else {
          const initialBody = Object.prototype.hasOwnProperty.call(requestInit, 'body')
            ? requestInit.body
            : undefined;
          const baseRequest = {
            url: rewriteUrl(url, rule),
            method,
            headers: applyHeaders(requestInit.headers || (request && request.headers), rule.headers),
            body: rule.bodyMode === 'replace' && method !== 'GET' && method !== 'HEAD'
              ? (rule.body == null ? '' : String(rule.body))
              : initialBody,
            ruleIndex: matchedRule.index + 1
          };
          const scriptedRequest = runCustomRequestScript(rule, baseRequest, 'fetch');
          finalRequest = scriptedRequest.request;
          scriptError = scriptedRequest.error;
          const nextInit = { ...requestInit, method: finalRequest.method, headers: finalRequest.headers };

          if (finalRequest.body !== undefined) {
            nextInit.body = finalRequest.body;
          }

          fetchRequest = request && finalRequest.url !== url
            ? nativeFetch.call(this, new Request(finalRequest.url, request), nextInit)
            : nativeFetch.call(this, request || finalRequest.url, nextInit);
        }

        const finalUrl = finalRequest ? finalRequest.url : url;
        const finalMethod = finalRequest ? finalRequest.method : method;
        const finalRequestHeaders = finalRequest
          ? headersToObject(finalRequest.headers)
          : originalRequestHeaders;
        const finalRequestBody = finalRequest && finalRequest.body !== undefined
          ? finalRequest.body
          : originalRequestBody;
        const debugFinalRequestBody = debugEnabled
          ? (finalRequest && finalRequest.body !== undefined
            ? cloneDebugRequestBody(finalRequestBody)
            : debugOriginalRequestBody)
          : finalRequestBody;
        if (debugEnabled) {
          emitDebug({
            phase: 'request',
            id: debugId,
            transport: 'fetch',
            method: finalMethod,
            originalMethod: method,
            originalUrl: url,
            finalUrl,
            matched: Boolean(rule),
            ruleIndex: rule ? matchedRule.index + 1 : null,
            changes: describeChanges(rule, url, finalUrl, finalMethod),
            scriptError,
            queryParams: queryParamsToObject(url),
            requestHeaders: originalRequestHeaders,
            finalQueryParams: queryParamsToObject(finalUrl),
            finalRequestHeaders
          });
          Promise.all([
            readDebugBody(debugOriginalRequestBody, originalRequestHeaders),
            readDebugBody(debugFinalRequestBody, finalRequestHeaders)
          ]).then(([requestBody, finalRequestBodyValue]) => {
            emitDebug({
              id: debugId,
              requestBody,
              finalRequestBody: finalRequestBodyValue
            });
          }).catch(() => {
          });
        }

        return fetchRequest
          .then(async (response) => {
            const originalResponse = debugEnabled ? await captureFetchResponse(response) : null;
            const finalResponse = await rewriteFetchResponse(response, rule);
            const responseDetails = supportsResponseRewrite(rule) && debugEnabled
              ? await captureFetchResponse(finalResponse)
              : originalResponse;
            emitDebug({
              phase: 'response',
              id: debugId,
              status: finalResponse.status,
              statusText: finalResponse.statusText,
              responseUrl: finalResponse.url || response.url || finalUrl,
              responseRewritten: supportsResponseRewrite(rule),
              responseHeaders: responseDetails?.headers,
              responseContentType: responseDetails?.contentType,
              responseBody: responseDetails?.body,
              originalResponseBody: supportsResponseRewrite(rule) ? originalResponse?.body : undefined
            });
            return finalResponse;
          })
          .catch((error) => {
            emitDebug({ phase: 'error', id: debugId, message: error?.message || 'fetch 请求失败' });
            throw error;
          });
      };

      const nativeOpen = XMLHttpRequest.prototype.open;
      const nativeSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
      const nativeSend = XMLHttpRequest.prototype.send;
      const requestData = new WeakMap();
      const responseRewriteListeners = new WeakSet();

      XMLHttpRequest.prototype.open = function (method, url) {
        const fullUrl = new URL(String(url), location.href).href;
        const normalizedMethod = String(method || 'GET').toUpperCase();
        const matchedRule = findRule(fullUrl, normalizedMethod);
        const rule = matchedRule?.rule;
        const baseRequest = rule
          ? {
            url: rewriteUrl(fullUrl, rule),
            method: normalizedMethod,
            headers: applyHeaders(undefined, rule.headers),
            body: rule.bodyMode === 'replace' && normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD'
              ? (rule.body == null ? '' : String(rule.body))
              : undefined,
            ruleIndex: matchedRule.index + 1
          }
          : null;
        const scriptedRequest = baseRequest ? runCustomRequestScript(rule, baseRequest, 'xhr') : null;
        const finalRequest = scriptedRequest?.request;

        requestData.set(this, {
          method: finalRequest?.method || normalizedMethod,
          originalMethod: normalizedMethod,
          rule,
          ruleIndex: rule ? matchedRule.index + 1 : null,
          originalUrl: fullUrl,
          finalUrl: finalRequest?.url || fullUrl,
          configuredHeaders: finalRequest ? headersToObject(finalRequest.headers) : {},
          originalRequestHeaders: {},
          requestHeaders: {},
          scriptBody: finalRequest?.body,
          scriptError: scriptedRequest?.error || '',
          suppliedHeaders: new Set(),
          responseRewritten: false,
          debugId: nextDebugId('xhr')
        });

        if (!responseRewriteListeners.has(this)) {
          this.addEventListener('readystatechange', () => {
            const currentRequest = requestData.get(this);
            if (this.readyState === 4 && currentRequest && !currentRequest.responseRewritten) {
              currentRequest.responseRewritten = true;
              const shouldCaptureDebug = isDebugEnabled();
              const originalResponse = shouldCaptureDebug ? captureXhrResponse(this) : null;
              rewriteXhrResponse(this, currentRequest.rule);
              const responseDetails = shouldCaptureDebug && supportsResponseRewrite(currentRequest.rule)
                ? captureXhrResponse(this)
                : originalResponse;
              emitDebug({
                phase: 'response',
                id: currentRequest.debugId,
                status: this.status,
                statusText: this.statusText,
                responseUrl: this.responseURL || currentRequest.finalUrl,
                responseRewritten: supportsResponseRewrite(currentRequest.rule),
                responseHeaders: responseDetails?.headers,
                responseContentType: responseDetails?.contentType,
                responseBody: responseDetails?.body,
                originalResponseBody: supportsResponseRewrite(currentRequest.rule) ? originalResponse?.body : undefined
              });
            }
          });
          responseRewriteListeners.add(this);
        }

        const openArguments = Array.from(arguments);
        openArguments[0] = finalRequest?.method || normalizedMethod;
        openArguments[1] = finalRequest?.url || url;
        return nativeOpen.apply(this, openArguments);
      };

      XMLHttpRequest.prototype.setRequestHeader = function (name, value) {
        const data = requestData.get(this);
        if (!data) {
          return nativeSetRequestHeader.call(this, name, value);
        }

        const matchedHeaderName = data.rule && Object.keys(data.configuredHeaders || {}).find(
          (configuredName) => configuredName.toLowerCase() === String(name).toLowerCase()
        );
        const finalName = matchedHeaderName || name;
        const finalValue = matchedHeaderName ? String(data.configuredHeaders[matchedHeaderName]) : value;
        data.originalRequestHeaders[String(name).toLowerCase()] = String(value);
        data.requestHeaders[String(finalName).toLowerCase()] = String(finalValue);
        data.suppliedHeaders.add(String(finalName).toLowerCase());
        return nativeSetRequestHeader.call(this, finalName, finalValue);
      };

      XMLHttpRequest.prototype.send = function (body) {
        const data = requestData.get(this);

        if (!data || !data.rule) {
          if (data) {
            emitDebug({
              phase: 'request',
              id: data.debugId,
              transport: 'xhr',
              method: data.method,
              originalMethod: data.originalMethod,
              originalUrl: data.originalUrl,
              finalUrl: data.finalUrl,
              matched: false,
              ruleIndex: null,
              changes: [],
              queryParams: queryParamsToObject(data.originalUrl),
              requestHeaders: data.originalRequestHeaders,
              requestBody: bodyToDebugValue(body, data.originalRequestHeaders),
              finalQueryParams: queryParamsToObject(data.finalUrl),
              finalRequestHeaders: data.requestHeaders,
              finalRequestBody: bodyToDebugValue(body, data.requestHeaders)
            });
          }
          return nativeSend.apply(this, arguments);
        }

        const ruleHeaders = data.configuredHeaders;
        if (ruleHeaders && typeof ruleHeaders === 'object' && !Array.isArray(ruleHeaders)) {
          Object.entries(ruleHeaders).forEach(([name, value]) => {
            if (!data.suppliedHeaders.has(name.toLowerCase())) {
              nativeSetRequestHeader.call(this, name, String(value));
              data.requestHeaders[String(name).toLowerCase()] = String(value);
            }
          });
        }

        const finalBody = data.scriptBody !== undefined
          ? data.scriptBody
          : data.rule.bodyMode === 'replace' && data.method !== 'GET' && data.method !== 'HEAD'
            ? (data.rule.body == null ? '' : String(data.rule.body))
            : body;
        emitDebug({
          phase: 'request',
          id: data.debugId,
          transport: 'xhr',
          method: data.method,
          originalMethod: data.originalMethod,
          originalUrl: data.originalUrl,
          finalUrl: data.finalUrl,
          matched: true,
          ruleIndex: data.ruleIndex,
          changes: describeChanges(data.rule, data.originalUrl, data.finalUrl, data.method),
          scriptError: data.scriptError,
          queryParams: queryParamsToObject(data.originalUrl),
          requestHeaders: data.originalRequestHeaders,
          requestBody: bodyToDebugValue(body, data.originalRequestHeaders),
          finalQueryParams: queryParamsToObject(data.finalUrl),
          finalRequestHeaders: data.requestHeaders,
          finalRequestBody: bodyToDebugValue(finalBody, data.requestHeaders)
        });
        return nativeSend.call(this, finalBody);
      };
    };

    const scriptText = `(${interceptor.toString()})(${JSON.stringify(STORAGE_KEY)}, ${JSON.stringify(RULES_KEY)}, ${JSON.stringify(EVENT_NAME)}, ${JSON.stringify(DEBUG_KEY)}, ${JSON.stringify(DEBUG_EVENT_NAME)}, ${JSON.stringify(DEBUG_SYNC_EVENT_NAME)}, ${JSON.stringify(DEBUG_BRIDGE_MESSAGE_TYPE)});`;
    let injectedScript = null;
    if (typeof GM_addElement === 'function') {
      injectedScript = GM_addElement(document.documentElement || document.head, 'script', {
        type: 'text/javascript',
        textContent: scriptText
      });
    }
    if (!injectedScript) {
      injectedScript = document.createElement('script');
      injectedScript.type = 'text/javascript';
      injectedScript.textContent = scriptText;
      (document.documentElement || document.head).appendChild(injectedScript);
    }
    injectedScript.remove();
  }

  function loadState() {
    try {
      const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
      const legacyHeaders = typeof saved.headers === 'string'
        ? parseStoredObject(saved.headers)
        : saved.headers;
      const legacyBody = typeof saved.body === 'string' ? parseStoredObject(saved.body) : null;
      const hasSavedHeaders = Object.prototype.hasOwnProperty.call(saved, 'headers');
      const hasSavedBodyType = Object.prototype.hasOwnProperty.call(saved, 'bodyType');
      const headerState = splitContentType(
        hasSavedHeaders ? normalizeRows(legacyHeaders) : DEFAULT_STATE.headers
      );
      const bodyType = hasSavedBodyType ? normalizeBodyType(saved.bodyType) : (legacyBody ? 'json' : 'raw');
      const previousBodyParams = normalizeRows(saved.bodyParams);
      const savedContentType = typeof saved.contentType === 'string' ? saved.contentType : headerState.contentType;
      const legacyTextFields = normalizeRows(
        saved.formParams || (['form', 'multipart'].includes(bodyType) ? previousBodyParams : [])
      ).map((field) => ({ key: field.key, type: 'text', value: field.value }));
      const legacyFileFields = normalizeRows(saved.multipartFileFields)
        .map((field) => ({ key: field.key, type: 'file', value: '' }));

      return {
        ...DEFAULT_STATE,
        ...saved,
        params: normalizeRows(saved.params),
        headers: headerState.headers,
        contentType: CONTENT_TYPES.includes(savedContentType) || savedContentType === 'none' || savedContentType === 'custom'
          ? savedContentType
          : 'custom',
        customContentType: saved.customContentType ?? (CONTENT_TYPES.includes(savedContentType) ? '' : headerState.customContentType),
        bodyType,
        jsonBody: saved.jsonBody ?? (bodyType === 'json'
          ? JSON.stringify(rowsToObject(previousBodyParams.length ? previousBodyParams : normalizeRows(legacyBody)), null, 2)
          : DEFAULT_STATE.jsonBody),
        bodyFields: normalizeBodyFields(saved.bodyFields || [...legacyTextFields, ...legacyFileFields]),
        rawBody: saved.rawBody ?? (legacyBody ? '' : saved.body || ''),
        batchFields: normalizeBatchFields(saved.batchFields, saved.batchVariable, saved.batchValues),
        batchExecutionMode: saved.batchExecutionMode === 'stop-on-success' ? 'stop-on-success' : 'all',
        batchIntervalSeconds: normalizeBatchInterval(saved.batchIntervalSeconds),
        batchSuccessMode: ['http', 'json', 'http-and-json'].includes(saved.batchSuccessMode) ? saved.batchSuccessMode : 'http',
        batchSuccessPath: String(saved.batchSuccessPath || ''),
        batchSuccessOperator: ['equals', 'not-equals', 'exists', 'truthy', 'falsy', 'includes'].includes(saved.batchSuccessOperator)
          ? saved.batchSuccessOperator
          : 'equals',
        batchSuccessExpected: String(saved.batchSuccessExpected ?? '0'),
        urlMode: saved.urlMode === 'current-page' ? 'current-page' : 'manual'
      };
    } catch {
      return { ...DEFAULT_STATE };
    }
  }

  function saveState(state) {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
    localStorage.setItem(RULES_KEY, state.rules);
  }

  function normalizeBatchInterval(value) {
    const numericValue = Number(value);
    return Number.isFinite(numericValue)
      ? Math.min(600, Math.max(0, numericValue))
      : 0;
  }

  function loadRewriteDebugEnabled() {
    try {
      return JSON.parse(localStorage.getItem(DEBUG_KEY) || '{}').enabled === true;
    } catch {
      return false;
    }
  }

  function saveRewriteDebugEnabled(enabled) {
    localStorage.setItem(DEBUG_KEY, JSON.stringify({ enabled: enabled === true }));
  }

  function normalizeDebugRecordLimit(value) {
    const numericValue = Number(value);
    return DEBUG_RECORD_LIMITS.includes(numericValue) ? numericValue : DEBUG_RECORD_LIMITS[0];
  }

  function loadPanelSettings() {
    try {
      const saved = JSON.parse(localStorage.getItem(PANEL_SETTINGS_KEY) || '{}');
      const opacity = Number(saved.opacity);
      const timeoutSeconds = Number(saved.timeoutSeconds);
      const platform = String(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || '');
      const defaultCurlTarget = /win/i.test(platform) ? 'powershell' : 'posix';
      const savedCurlTarget = saved.curlTarget === 'cmd' ? 'powershell' : saved.curlTarget;
      return {
        opacity: Number.isFinite(opacity) ? Math.min(100, Math.max(0, opacity)) : 96,
        timeoutSeconds: Number.isFinite(timeoutSeconds) ? Math.min(600, Math.max(0, Math.round(timeoutSeconds))) : 30,
        curlTarget: ['posix', 'powershell'].includes(savedCurlTarget) ? savedCurlTarget : defaultCurlTarget,
        debugRecordLimit: normalizeDebugRecordLimit(saved.debugRecordLimit),
        persistDebugRecords: saved.persistDebugRecords === true
      };
    } catch {
      const platform = String(navigator.userAgentData?.platform || navigator.platform || navigator.userAgent || '');
      return {
        opacity: 96,
        timeoutSeconds: 30,
        curlTarget: /win/i.test(platform) ? 'powershell' : 'posix',
        debugRecordLimit: DEBUG_RECORD_LIMITS[0],
        persistDebugRecords: false
      };
    }
  }

  function savePanelSettings(settings) {
    localStorage.setItem(PANEL_SETTINGS_KEY, JSON.stringify(settings));
  }

  function parseStoredObject(text) {
    try {
      const parsed = JSON.parse(text || '{}');
      return parsed && !Array.isArray(parsed) && typeof parsed === 'object' ? parsed : null;
    } catch {
      return null;
    }
  }

  function normalizeRows(value) {
    if (Array.isArray(value)) {
      return value
        .filter((item) => item && typeof item === 'object')
        .map((item) => ({ key: String(item.key ?? ''), value: String(item.value ?? '') }));
    }

    if (value && typeof value === 'object') {
      return Object.entries(value).map(([key, rowValue]) => ({ key, value: String(rowValue) }));
    }

    return [];
  }

  function normalizeBodyType(value) {
    return ['none', 'json', 'form', 'multipart', 'raw'].includes(value) ? value : 'json';
  }

  function normalizeBodyFields(value) {
    if (!Array.isArray(value)) {
      return [];
    }

    return value
      .filter((field) => field && typeof field === 'object')
      .map((field) => ({
        key: String(field.key ?? ''),
        type: ['text', 'number', 'file'].includes(field.type) ? field.type : 'text',
        value: String(field.value ?? '')
      }));
  }

  function normalizeBatchFields(value, legacyName = '', legacyValues = '') {
    const fields = Array.isArray(value)
      ? value
        .filter((field) => field && typeof field === 'object')
        .map((field) => ({
          name: String(field.name ?? '').trim().replace(/[{}]/g, ''),
          values: String(field.values ?? '')
        }))
        .filter((field) => field.name || field.values.trim())
      : [];

    const isLegacyPlaceholder = fields.length === 1
      && fields[0].name === 'batch'
      && !fields[0].values.trim();
    if (fields.length && !isLegacyPlaceholder) {
      return fields;
    }

    const name = String(legacyName || '').trim().replace(/[{}]/g, '');
    const values = String(legacyValues || '');
    if (name === 'batch' && !values.trim()) {
      return [];
    }
    return name || values.trim() ? [{ name, values }] : [];
  }

  function splitContentType(headers) {
    let contentType = 'application/json';
    let customContentType = '';
    const remainingHeaders = [];

    headers.forEach((header) => {
      if (header.key.trim().toLowerCase() !== 'content-type') {
        remainingHeaders.push(header);
        return;
      }

      if (CONTENT_TYPES.includes(header.value)) {
        contentType = header.value;
      } else if (!header.value.trim()) {
        contentType = 'none';
      } else {
        contentType = 'custom';
        customContentType = header.value;
      }
    });

    return { headers: remainingHeaders, contentType, customContentType };
  }

  function rowsToObject(rows) {
    return Object.fromEntries(
      rows
        .map((row) => ({ key: row.key.trim(), value: row.value }))
        .filter((row) => row.key)
    );
  }

  function appendParams(urlText, params) {
    const targetUrl = new URL(urlText);
    params.forEach((param) => {
      const key = param.key.trim();
      if (key) {
        targetUrl.searchParams.set(key, param.value);
      }
    });
    return targetUrl.href;
  }

  function buildRequestHeaders(state) {
    const headers = rowsToObject(state.headers);
    const selectedContentType = state.contentType === 'custom'
      ? state.customContentType.trim()
      : state.contentType;

    Object.keys(headers).forEach((headerName) => {
      if (headerName.toLowerCase() === 'content-type') {
        delete headers[headerName];
      }
    });

    if (selectedContentType && selectedContentType !== 'none' && !selectedContentType.startsWith('multipart/form-data')) {
      headers['Content-Type'] = selectedContentType;
    }

    return headers;
  }

  function bodyTypeForContentType(contentType) {
    const normalizedContentType = String(contentType || '').toLowerCase();

    if (normalizedContentType.includes('multipart/form-data')) {
      return 'multipart';
    }
    if (normalizedContentType.includes('application/x-www-form-urlencoded')) {
      return 'form';
    }
    if (normalizedContentType.includes('application/json') || normalizedContentType.includes('+json')) {
      return 'json';
    }
    return 'raw';
  }

  function buildRequestBody(state) {
    if (['GET', 'HEAD'].includes(state.method) || state.bodyType === 'none') {
      return undefined;
    }

    if (state.bodyType === 'raw') {
      return state.rawBody;
    }

    if (state.bodyType === 'form') {
      return new URLSearchParams(
        (state.bodyFields || [])
          .filter((field) => field.key && field.type !== 'file')
          .map((field) => [field.key, field.value])
      ).toString();
    }

    if (state.bodyType === 'multipart') {
      const formData = new FormData();
      (state.bodyFields || []).filter((field) => field.key && field.type !== 'file').forEach((field) => {
        formData.append(field.key, field.value);
      });
      (state.bodyFiles || []).forEach((field) => {
        field.files.forEach((file) => {
          formData.append(field.key, file, file.name);
        });
      });
      return formData;
    }

    if (!state.jsonBody.trim()) {
      return '';
    }

    try {
      JSON.parse(state.jsonBody);
    } catch {
      throw new Error('请求体 JSON 格式无效,请检查逗号、引号和括号。');
    }

    return state.jsonBody;
  }

  function readResponseHeader(responseHeaders, headerName) {
    const match = String(responseHeaders || '').match(
      new RegExp(`^${headerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*(.+)$`, 'im')
    );
    return match ? match[1].trim() : '';
  }

  function resolveDownloadFileName(response, requestedName) {
    if (requestedName.trim()) {
      return requestedName.trim().replace(/[\\/:*?"<>|]/g, '_');
    }

    const disposition = readResponseHeader(response.responseHeaders, 'content-disposition');
    const encodedMatch = disposition.match(/filename\*\s*=\s*([^;]+)/i);
    const plainMatch = disposition.match(/filename\s*=\s*("[^"]+"|[^;\s]+)/i);
    let fileName = '';

    if (encodedMatch) {
      const encodedValue = encodedMatch[1].trim().replace(/^UTF-8''/i, '').replace(/^"|"$/g, '');
      try {
        fileName = decodeURIComponent(encodedValue);
      } catch {
        fileName = encodedValue;
      }
    } else if (plainMatch) {
      fileName = plainMatch[1].replace(/^"|"$/g, '');
    }

    return (fileName || `download-${Date.now()}`).replace(/[\\/:*?"<>|]/g, '_');
  }

  function downloadResponse(response, requestedName) {
    const contentType = readResponseHeader(response.responseHeaders, 'content-type') || 'application/octet-stream';
    const blob = response.response instanceof Blob
      ? response.response
      : new Blob([response.response], { type: contentType });
    const objectUrl = URL.createObjectURL(blob);
    const link = document.createElement('a');

    link.href = objectUrl;
    link.download = resolveDownloadFileName(response, requestedName);
    link.style.display = 'none';
    document.body.appendChild(link);
    link.click();
    link.remove();
    window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
    return link.download;
  }

  function parseRules(text) {
    let parsed;

    try {
      parsed = JSON.parse(text || '[]');
    } catch {
      throw new Error('拦截规则必须是有效 JSON。');
    }

    if (!Array.isArray(parsed)) {
      throw new Error('拦截规则必须是 JSON 数组。');
    }

    parsed.forEach((rule, index) => {
      if (!rule || typeof rule !== 'object' || Array.isArray(rule) || !rule.match) {
        throw new Error(`第 ${index + 1} 条规则缺少 match。`);
      }

      if (rule.headers != null && (typeof rule.headers !== 'object' || Array.isArray(rule.headers))) {
        throw new Error(`第 ${index + 1} 条规则的 headers 必须是 JSON 对象。`);
      }

      if (rule.methods != null && !Array.isArray(rule.methods)) {
        throw new Error(`第 ${index + 1} 条规则的 methods 必须是数组。`);
      }
    });

    return parsed;
  }

  function validateCustomRuleScript(source) {
    const code = String(source || '').trim();
    if (!code) {
      throw new Error('自定义 JS 不能为空。');
    }

    try {
      new Function('request', 'context', `'use strict'; return (${code})(request, context);`);
    } catch {
      try {
        new Function('request', 'context', `'use strict';\n${code}\nreturn request;`);
      } catch (error) {
        throw new Error(`自定义 JS 语法无效:${error.message}`);
      }
    }
  }

  function createUi() {
    if (document.getElementById('tm-api-console-host') || document.getElementById('tm-api-console-root')) {
      return;
    }

    const state = loadState();
    const host = document.createElement('div');
    host.id = 'tm-api-console-host';
    host.style.setProperty('all', 'initial', 'important');
    const root = host.attachShadow({ mode: 'open' });
    root.innerHTML = `
      <style id="tm-api-console-style">
        #tm-api-console-root, #tm-api-console-root * { box-sizing: border-box; }
        #tm-api-console-control, #tm-show-tool-control { --tm-floating-size: 36px; --tm-floating-expanded-width: 96px; position: fixed; z-index: 2147483647; width: var(--tm-floating-size); height: var(--tm-floating-size); overflow: visible; touch-action: none; user-select: none; }
        #tm-api-console-control { right: 0; bottom: 20px; }
        #tm-show-tool-control { --tm-floating-expanded-width: 96px; right: 0; bottom: 20px; }
        #tm-api-console-button, #tm-show-tool-button { position: absolute; top: 0; right: 0; display: block; width: var(--tm-floating-size); height: var(--tm-floating-size); min-width: 0; padding: 0; overflow: hidden; border: 0; border-radius: 999px 0 0 999px; background: linear-gradient(135deg, #14b8a6, #0f766e); color: #fff; box-shadow: 0 9px 24px rgb(15 23 42 / 38%); cursor: pointer; transition: width .2s ease, background .2s ease, box-shadow .2s ease; }
        #tm-show-tool-button { background: linear-gradient(135deg, #0f766e, #115e59); }
        #tm-api-console-control:hover #tm-api-console-button, #tm-api-console-button:focus-visible, #tm-show-tool-control:hover #tm-show-tool-button, #tm-show-tool-button:focus-visible { width: var(--tm-floating-expanded-width); box-shadow: 0 11px 27px rgb(15 23 42 / 48%); }
        .tm-floating-tool-label { position: absolute; top: 5px; left: 40px; display: block; width: 52px; height: 26px; overflow: hidden; color: #fff; opacity: 0; pointer-events: none; transform: translateX(-4px); transition: opacity .14s ease, transform .2s ease; }
        #tm-api-console-control:hover .tm-floating-tool-label, #tm-api-console-button:focus-visible .tm-floating-tool-label, #tm-show-tool-control:hover .tm-floating-tool-label, #tm-show-tool-button:focus-visible .tm-floating-tool-label { opacity: 1; transform: translateX(0); }
        .tm-floating-console-mark { display: flex; flex-direction: column; width: 52px; align-items: center; gap: 3px; text-align: center; white-space: nowrap; }
        .tm-floating-console-api { color: #f0fdfa; font: 700 13px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: .04em; text-shadow: 0 1px 1px rgb(15 23 42 / 18%); }
        .tm-floating-console-name { color: rgb(204 251 241 / 90%); font: 600 8px/1 ui-sans-serif, system-ui, sans-serif; letter-spacing: .12em; text-transform: uppercase; }
        .tm-floating-tool-icon { position: absolute; top: 5px; left: 6px; display: grid; width: 26px; height: 26px; place-items: center; border: 1px solid rgb(255 255 255 / 26%); border-radius: 999px; background: rgb(15 23 42 / 16%); color: #fff; box-shadow: inset 0 1px rgb(255 255 255 / 12%); }
        .tm-floating-tool-icon svg { width: 18px; height: 18px; filter: drop-shadow(0 1px 1px rgb(15 23 42 / 28%)); }
        .tm-floating-hide-button { position: absolute; z-index: 2; top: -8px; left: calc(var(--tm-floating-size) - var(--tm-floating-expanded-width) + 5px); display: grid; width: 13px; min-width: 13px; height: 13px; min-height: 13px; padding: 0; place-items: center; border: 1px solid rgb(255 255 255 / 34%); border-radius: 999px; background: rgb(15 23 42 / 18%); color: rgb(255 255 255 / 82%); box-shadow: none; cursor: pointer; opacity: 0; pointer-events: none; transform: scale(.8); transition: opacity .16s ease, transform .16s ease, background .16s ease; }
        .tm-floating-hide-button svg { width: 8px; height: 8px; }
        #tm-api-console-control:hover .tm-floating-hide-button, .tm-floating-hide-button:focus-visible, #tm-show-tool-control:hover .tm-floating-hide-button { opacity: .84; pointer-events: auto; transform: scale(1); }
        .tm-floating-hide-button:hover, .tm-floating-hide-button:focus-visible { background: rgb(15 23 42 / 48%); opacity: 1 !important; }
        #tm-api-console-panel { --tm-panel-alpha: .96; --tm-surface-alpha: 0; --tm-deep-alpha: 0; --tm-control-alpha: 0; --tm-edge-alpha: .43; --tm-panel-blur: 3.84px; --tm-shadow-alpha: .58; --tm-muted-color: #94a3b8; --tm-secondary-text: #cbd5e1; --tm-control-border: #475569; --tm-control-rgb: 51 65 85; --tm-control-hover-rgb: 71 85 105; --tm-action-rgb: 15 118 110; --tm-surface-border: #2d405c; position: fixed; z-index: 2147483646; display: none; right: 20px; bottom: 72px; width: min(820px, calc(100vw - 40px)); height: min(760px, calc(100vh - 100px)); min-width: min(360px, calc(100vw - 20px)); min-height: 280px; max-width: calc(100vw - 20px); max-height: calc(100vh - 20px); overflow: auto; padding: 18px; border: 1px solid rgb(45 64 92 / var(--tm-panel-alpha)); border-radius: 16px; background: linear-gradient(160deg, rgb(16 25 44 / var(--tm-panel-alpha)), rgb(12 20 36 / var(--tm-panel-alpha))); color: #e2e8f0; box-shadow: 0 24px 70px rgb(15 23 42 / var(--tm-shadow-alpha)); backdrop-filter: blur(var(--tm-panel-blur)) saturate(110%); font: 14px/1.4 system-ui, sans-serif; scrollbar-width: none; -ms-overflow-style: none; container-type: inline-size; transition: background .12s ease, border-color .12s ease, color .12s ease, box-shadow .12s ease, backdrop-filter .12s ease; }
        #tm-api-console-panel.tm-panel-low-opacity { --tm-muted-color: #cbd5e1; --tm-secondary-text: #e2e8f0; --tm-control-border: #64748b; --tm-control-rgb: 59 80 108; --tm-control-hover-rgb: 82 107 138; --tm-action-rgb: 13 148 136; --tm-surface-border: #4d6685; }
        #tm-api-console-panel.tm-panel-zero-opacity { --tm-muted-color: #e2e8f0; --tm-secondary-text: #f8fafc; --tm-control-border: #7890ad; --tm-control-rgb: 71 95 125; --tm-control-hover-rgb: 93 120 154; --tm-action-rgb: 20 184 166; --tm-surface-border: #6b87a6; }
        #tm-api-console-panel.tm-panel-low-opacity, #tm-api-console-panel.tm-panel-zero-opacity { color: #334155; }
        #tm-api-console-panel.tm-panel-low-opacity h2, #tm-api-console-panel.tm-panel-low-opacity h3, #tm-api-console-panel.tm-panel-zero-opacity h2, #tm-api-console-panel.tm-panel-zero-opacity h3 { color: #1e293b !important; }
        #tm-api-console-panel.tm-panel-low-opacity p, #tm-api-console-panel.tm-panel-low-opacity .tm-hint, #tm-api-console-panel.tm-panel-zero-opacity p, #tm-api-console-panel.tm-panel-zero-opacity .tm-hint { color: #475569; }
        #tm-api-console-panel.tm-panel-low-opacity label, #tm-api-console-panel.tm-panel-low-opacity .tm-method-list label, #tm-api-console-panel.tm-panel-zero-opacity label, #tm-api-console-panel.tm-panel-zero-opacity .tm-method-list label { color: #334155 !important; }
        #tm-api-console-panel.tm-panel-low-opacity input, #tm-api-console-panel.tm-panel-low-opacity select, #tm-api-console-panel.tm-panel-low-opacity textarea, #tm-api-console-panel.tm-panel-zero-opacity input, #tm-api-console-panel.tm-panel-zero-opacity select, #tm-api-console-panel.tm-panel-zero-opacity textarea { color: #1e293b; }
        #tm-api-console-panel.tm-panel-low-opacity button, #tm-api-console-panel.tm-panel-zero-opacity button { color: #1e293b; }
        #tm-api-console-panel.tm-panel-low-opacity #tm-send, #tm-api-console-panel.tm-panel-zero-opacity #tm-send { color: #0f172a !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-module-tab, #tm-api-console-panel.tm-panel-zero-opacity .tm-module-tab { color: #475569; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-step-label, #tm-api-console-panel.tm-panel-low-opacity .tm-debug-step-value, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-step-label, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-step-value { color: #475569; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-log-head, #tm-api-console-panel.tm-panel-low-opacity .tm-debug-log-url, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-log-head, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-log-url { color: #334155; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-log-status.is-success, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-log-status.is-success { color: #0f766e; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-log-meta, #tm-api-console-panel.tm-panel-low-opacity .tm-debug-detail-value, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-log-meta, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-detail-value { color: #475569; }
        #tm-api-console-panel.tm-panel-low-opacity #tm-panel-settings-popup, #tm-api-console-panel.tm-panel-zero-opacity #tm-panel-settings-popup { background: rgb(15 23 42 / 98%); color: #e2e8f0; }
        #tm-api-console-panel.tm-panel-low-opacity #tm-panel-settings-popup label, #tm-api-console-panel.tm-panel-zero-opacity #tm-panel-settings-popup label { color: #cbd5e1 !important; }
        #tm-api-console-panel.tm-panel-low-opacity #tm-panel-settings-popup .tm-hint, #tm-api-console-panel.tm-panel-zero-opacity #tm-panel-settings-popup .tm-hint { color: #94a3b8; }
        #tm-api-console-panel.tm-panel-low-opacity #tm-panel-settings-popup input, #tm-api-console-panel.tm-panel-zero-opacity #tm-panel-settings-popup input { color: #f8fafc; }
        #tm-api-console-panel.open { display: block; }
        #tm-api-console-panel::-webkit-scrollbar { width: 0; height: 0; }
        .tm-panel-resize-handle { position: absolute; z-index: 3; width: 20px; height: 20px; touch-action: none; opacity: 0; }
        .tm-panel-resize-handle::before { display: none; }
        .tm-panel-resize-handle[data-panel-resize="nw"] { top: 3px; left: 3px; cursor: nwse-resize; }
        .tm-panel-resize-handle[data-panel-resize="nw"]::before { top: 2px; left: 2px; border-right: 0; border-bottom: 0; }
        .tm-panel-resize-handle[data-panel-resize="ne"] { top: 3px; right: 3px; cursor: nesw-resize; }
        .tm-panel-resize-handle[data-panel-resize="ne"]::before { top: 2px; right: 2px; border-left: 0; border-bottom: 0; }
        .tm-panel-resize-handle[data-panel-resize="sw"] { bottom: 3px; left: 3px; cursor: nesw-resize; }
        .tm-panel-resize-handle[data-panel-resize="sw"]::before { bottom: 2px; left: 2px; border-right: 0; border-top: 0; }
        .tm-panel-resize-handle[data-panel-resize="se"] { right: 3px; bottom: 3px; cursor: nwse-resize; }
        .tm-panel-resize-handle[data-panel-resize="se"]::before { right: 2px; bottom: 2px; border-left: 0; border-top: 0; }
        #tm-api-console-panel h2, #tm-api-console-panel h3, #tm-api-console-panel p { margin: 0; }
        #tm-api-console-panel h2 { color: #f8fafc !important; font-size: 18px; letter-spacing: -.02em; }
        #tm-api-console-panel h3 { color: #f8fafc !important; font-size: 14px; }
        #tm-api-console-panel .tm-summary-copy h3 { color: #f8fafc !important; }
        #tm-api-console-panel p, #tm-api-console-panel .tm-hint { color: var(--tm-muted-color); font-size: 12px; }
        #tm-panel-scroll-hide-button { position: fixed; z-index: 2147483647; display: grid; width: 22px; min-width: 22px; min-height: 22px !important; height: 22px; padding: 0 !important; place-items: center; border-color: rgb(148 163 184 / 38%) !important; border-radius: 7px; background: rgb(15 23 42 / 78%) !important; color: #cbd5e1; box-shadow: 0 4px 12px rgb(2 6 23 / 24%); backdrop-filter: blur(7px); opacity: .88; }
        #tm-panel-scroll-hide-button svg { width: 14px; height: 14px; }
        #tm-panel-scroll-hide-button:hover { border-color: var(--tm-control-border) !important; background: rgb(30 41 59 / 88%) !important; color: #f8fafc; opacity: 1; }
        .tm-panel-heading { position: relative; z-index: 1; display: flex; align-items: center; justify-content: space-between; min-height: 34px; gap: 12px; margin: -18px -18px 0; padding: 18px 18px 12px; border-bottom: 1px solid rgb(38 55 80 / var(--tm-control-alpha)); border-radius: 15px 15px 0 0; background: rgb(15 23 42 / var(--tm-surface-alpha)); cursor: move; touch-action: none; user-select: none; }
        .tm-panel-controls { display: flex; align-items: center; gap: 7px; }
        .tm-panel-settings-wrap { position: relative; }
        #tm-api-console-panel #tm-panel-settings-button { display: grid; width: 28px; min-width: 28px; min-height: 28px !important; height: 28px; padding: 0 !important; place-items: center; border: 1px solid transparent !important; border-radius: 8px; background: transparent !important; color: #94a3b8; box-shadow: none; cursor: pointer; opacity: .72; touch-action: auto; transition: border-color .16s ease, background .16s ease, color .16s ease, opacity .16s ease; }
        #tm-panel-settings-button svg { width: 16px; height: 16px; }
        #tm-panel-settings-button:hover, #tm-panel-settings-button[aria-expanded="true"] { border-color: var(--tm-control-border) !important; background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)) !important; color: #e2e8f0; filter: none; opacity: 1; }
        #tm-panel-settings-button:focus-visible { outline: 2px solid #2dd4bf; outline-offset: 2px; opacity: 1; }
        #tm-panel-settings-popup { position: absolute; z-index: 8; top: calc(100% + 10px); right: 0; width: 276px; padding: 14px; border: 1px solid #3b506c; border-radius: 12px; background: rgb(15 23 42 / 98%); color: #e2e8f0; box-shadow: 0 16px 38px rgb(2 6 23 / 46%); backdrop-filter: blur(14px) saturate(125%); cursor: default; }
        .tm-panel-settings-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: #f8fafc; font-size: 12px; font-weight: 800; }
        .tm-panel-settings-value { color: #99f6e4; font: 700 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; }
        #tm-panel-settings-popup .tm-panel-setting { display: grid; gap: 7px; margin-top: 13px; color: #cbd5e1; font-size: 11px; font-weight: 700; }
        #tm-panel-settings-popup .tm-panel-setting > span:first-child { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
        #tm-panel-settings-popup .tm-panel-setting small { color: #64748b; font-size: 10px; font-weight: 600; }
        #tm-api-console-panel #tm-panel-opacity { width: 100%; min-height: auto !important; height: 5px; padding: 0 !important; accent-color: #2dd4bf; cursor: pointer; }
        .tm-settings-unit-input { display: flex; align-items: center; min-height: 38px; overflow: hidden; border: 1px solid var(--tm-control-border); border-radius: 9px; background: rgb(2 6 23 / var(--tm-deep-alpha)); transition: border-color .16s ease, box-shadow .16s ease; }
        .tm-settings-unit-input:focus-within { border-color: rgb(45 212 191 / var(--tm-panel-alpha)); box-shadow: 0 0 0 2px rgb(45 212 191 / var(--tm-panel-alpha)); }
        #tm-api-console-panel .tm-settings-unit-input input { min-width: 0; min-height: 36px; padding: 7px 10px; border: 0; border-radius: 0; background: transparent; outline: 0; }
        #tm-api-console-panel .tm-settings-unit-input input:focus { outline: 0; border-color: transparent; }
        #tm-api-console-panel .tm-settings-unit-input input[type="number"] { appearance: textfield; -moz-appearance: textfield; }
        #tm-api-console-panel .tm-settings-unit-input input[type="number"]::-webkit-inner-spin-button, #tm-api-console-panel .tm-settings-unit-input input[type="number"]::-webkit-outer-spin-button { margin: 0; appearance: none; -webkit-appearance: none; }
        .tm-settings-unit-input em { flex: 0 0 auto; padding: 0 11px; border-left: 1px solid var(--tm-control-border); color: #94a3b8; font: 700 11px/1 ui-sans-serif, system-ui, sans-serif; font-style: normal; }
        .tm-panel-settings-section { margin-top: 15px; padding-top: 12px; border-top: 1px solid rgb(71 85 105 / 44%); color: #64748b; font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
        #tm-panel-settings-popup .tm-panel-settings-toggle { display: flex !important; align-items: center; justify-content: space-between; gap: 12px; margin-top: 13px; color: #cbd5e1; cursor: pointer; }
        .tm-panel-settings-toggle > span { display: grid; gap: 3px; min-width: 0; }
        .tm-panel-settings-toggle strong { color: #dbeafe; font-size: 11px; }
        .tm-panel-settings-toggle small { color: #64748b; font-size: 10px; font-weight: 600; line-height: 1.35; }
        #tm-api-console-panel .tm-panel-settings-toggle input { position: relative; flex: 0 0 auto; width: 34px !important; min-height: 20px !important; height: 20px; margin: 0; padding: 0 !important; border: 1px solid #475569; border-radius: 999px; appearance: none; -webkit-appearance: none; background: #263750; cursor: pointer; transition: border-color .16s ease, background .16s ease; }
        #tm-api-console-panel .tm-panel-settings-toggle input::after { position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 999px; background: #cbd5e1; content: ''; box-shadow: 0 1px 2px rgb(2 6 23 / 32%); transition: transform .16s ease, background .16s ease; }
        #tm-api-console-panel .tm-panel-settings-toggle input:checked { border-color: #2dd4bf; background: #0f766e; }
        #tm-api-console-panel .tm-panel-settings-toggle input:checked::after { background: #f0fdfa; transform: translateX(14px); }
        #tm-api-console-panel .tm-panel-settings-toggle input:focus-visible { outline: 2px solid #2dd4bf; outline-offset: 2px; }
        #tm-panel-settings-popup .tm-hint { display: block; margin-top: 9px; color: #94a3b8; font-size: 10px; line-height: 1.45; }
        #tm-panel-settings-popup #tm-panel-reset-layout { width: 100%; min-height: 30px !important; margin-top: 11px; border: 1px solid #475569 !important; background: #263750 !important; color: #e2e8f0 !important; font-size: 11px; }
        #tm-panel-settings-popup #tm-panel-reset-layout:hover { border-color: #5eead4 !important; background: #334155 !important; }
        #tm-module-tabs { display: inline-flex; align-items: center; gap: 3px; margin-top: 12px; padding: 3px; border: 1px solid var(--tm-surface-border); border-radius: 10px; background: rgb(15 23 42 / var(--tm-surface-alpha)); }
        #tm-api-console-panel .tm-module-tab { min-height: 30px; padding: 0 11px; border: 1px solid transparent; border-radius: 7px; background: transparent; color: #94a3b8; font-size: 12px; }
        #tm-api-console-panel .tm-module-tab:hover { background: rgb(var(--tm-control-hover-rgb) / var(--tm-control-alpha)); color: #e2e8f0; filter: none; }
        #tm-api-console-panel .tm-module-tab.is-active { border-color: rgb(45 212 191 / var(--tm-control-alpha)); background: rgb(20 184 166 / var(--tm-control-alpha)); color: #ccfbf1; }
        .tm-module { margin-top: 12px; }
        #tm-panel-hide-button { display: grid; flex: 0 0 auto; width: 28px; min-width: 28px; min-height: 28px !important; height: 28px; padding: 0 !important; place-items: center; border: 1px solid transparent !important; border-radius: 8px; background: transparent !important; color: var(--tm-muted-color); box-shadow: none; cursor: pointer; opacity: .7; touch-action: auto; transition: border-color .16s ease, background .16s ease, color .16s ease, opacity .16s ease; }
        #tm-panel-hide-button svg { width: 16px; height: 16px; }
        #tm-panel-hide-button:hover { border-color: var(--tm-control-border) !important; background: rgb(51 65 85 / var(--tm-control-alpha)) !important; color: #e2e8f0; filter: none; opacity: 1; }
        #tm-panel-hide-button:focus-visible { outline: 2px solid #2dd4bf; outline-offset: 2px; opacity: 1; }
        #tm-api-console-panel label { display: grid; gap: 5px; color: var(--tm-secondary-text); font-size: 12px; font-weight: 700; }
        #tm-api-console-panel input, #tm-api-console-panel select, #tm-api-console-panel textarea { width: 100%; border: 1px solid var(--tm-control-border); border-radius: 8px; background: rgb(2 6 23 / var(--tm-deep-alpha)); color: #f8fafc; font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }
        #tm-api-console-panel input, #tm-api-console-panel select { min-height: 38px; padding: 8px 10px; }
        #tm-api-console-panel textarea { min-height: 120px; padding: 10px; resize: vertical; }
        #tm-api-console-panel input:focus, #tm-api-console-panel select:focus, #tm-api-console-panel textarea:focus { outline: 2px solid rgb(45 212 191 / var(--tm-panel-alpha)); outline-offset: 1px; border-color: rgb(45 212 191 / var(--tm-panel-alpha)); }
        #tm-api-console-panel .tm-intro { margin-top: 5px; }
        #tm-active-request { margin-top: 0; padding-bottom: 14px; border-bottom: 1px solid var(--tm-control-border); }
        #tm-captured-edit-banner { display: flex; align-items: center; justify-content: space-between; gap: 9px; margin: 0 0 10px; padding: 7px 9px; border: 1px solid rgb(45 212 191 / var(--tm-edge-alpha)); border-radius: 9px; color: #99f6e4; font-size: 12px; }
        #tm-api-console-panel #tm-restore-captured-draft { min-height: 27px !important; padding: 0 8px !important; border-color: rgb(45 212 191 / var(--tm-edge-alpha)) !important; border-radius: 999px !important; color: #ccfbf1; font-size: 11px; white-space: nowrap; }
        #tm-url-mode-choice { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
        .tm-url-mode-label { color: #cbd5e1; font-size: 12px; font-weight: 700; }
        .tm-url-mode-options { display: inline-flex; align-items: center; gap: 2px; padding: 3px; border: 1px solid var(--tm-control-border); border-radius: 9px; background: rgb(2 6 23 / var(--tm-control-alpha)); }
        #tm-url-mode-choice label { display: flex; align-items: center; gap: 5px; min-height: 28px; padding: 0 8px; border-radius: 6px; color: #94a3b8; font-size: 12px; font-weight: 700; cursor: pointer; transition: color .16s ease, background .16s ease; }
        #tm-url-mode-choice label:has(input:checked) { background: rgb(20 184 166 / calc(var(--tm-panel-alpha) * .17)); color: #ccfbf1; }
        #tm-url-mode-choice input { position: absolute; width: 1px !important; min-height: 1px !important; padding: 0 !important; opacity: 0; pointer-events: none; }
        #tm-url-mode-choice label:has(input:focus-visible) { outline: 2px solid rgb(45 212 191 / var(--tm-panel-alpha)); outline-offset: 2px; }
        #tm-request-grid { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 12px; margin-top: 10px; }
        #tm-response-grid { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 12px; margin-top: 0; }
        #tm-response-grid.is-preview { display: flex; align-items: center; width: fit-content; max-width: 100%; }
        #tm-response-grid.is-preview label { display: flex; align-items: center; grid-auto-flow: column; gap: 8px; }
        #tm-response-grid.is-preview select { width: 260px; }
        #tm-url[data-url-source="current-page"] { border-style: dashed; border-color: rgb(45 212 191 / var(--tm-edge-alpha)); color: #dbeafe; }
        #tm-request-meta { display: grid; gap: 10px; margin-top: 10px; }
        .tm-section { margin-top: 10px; padding: 14px; border: 1px solid var(--tm-surface-border); border-radius: 12px; background: rgb(17 28 49 / var(--tm-surface-alpha)); }
        #tm-request-meta .tm-section { margin-top: 0; }
        .tm-collapsible { padding: 0; }
        .tm-collapsible > summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; cursor: pointer; list-style: none; }
        .tm-collapsible > summary::-webkit-details-marker { display: none; }
        .tm-collapsible > summary::after { content: '⌄'; color: var(--tm-muted-color); font-size: 18px; transition: color .15s ease, transform .15s ease; }
        .tm-collapsible:not([open]) > summary::after { transform: rotate(-90deg); }
        .tm-collapsible:not([open]) > summary:hover { background: rgb(51 65 85 / var(--tm-control-alpha)); border-radius: 11px; }
        .tm-collapsible[open] { border-color: var(--tm-surface-border); background: rgb(18 30 52 / var(--tm-surface-alpha)); box-shadow: inset 0 1px rgb(148 163 184 / calc(var(--tm-panel-alpha) * .05)); }
        .tm-collapsible[open] > summary { border-bottom: 1px solid var(--tm-control-border); border-radius: 11px 11px 0 0; background: linear-gradient(90deg, rgb(30 50 78 / var(--tm-control-alpha)), transparent 72%); }
        .tm-collapsible[open] > summary::after { color: #5eead4; }
        .tm-collapsible[open] > summary .tm-hint { display: none; }
        .tm-summary-copy { display: grid; min-width: 0; gap: 2px; }
        .tm-collapsible:not([open]) > summary .tm-summary-copy { display: flex; flex: 1 1 auto; align-items: baseline; min-width: 0; gap: 9px; }
        .tm-collapsible:not([open]) > summary .tm-summary-copy h3 { flex: 0 0 auto; white-space: nowrap; }
        .tm-collapsible:not([open]) > summary .tm-hint { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        .tm-summary-actions { display: flex; align-items: center; gap: 6px; margin-left: auto; }
        #tm-api-console-panel .tm-summary-actions .tm-summary-add { display: inline-flex; align-items: center; gap: 5px; min-height: 30px !important; padding: 0 10px !important; border: 1px solid rgb(var(--tm-control-rgb) / var(--tm-control-alpha)) !important; border-radius: 999px !important; background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)) !important; color: var(--tm-secondary-text) !important; box-shadow: none; font-weight: 700; white-space: nowrap; transition: border-color .16s ease, background .16s ease, color .16s ease; }
        #tm-api-console-panel .tm-summary-actions .tm-summary-add svg { width: 14px; height: 14px; color: #5eead4; }
        #tm-api-console-panel .tm-summary-actions .tm-summary-add:hover { border-color: rgb(45 212 191 / var(--tm-control-alpha)) !important; background: rgb(var(--tm-control-hover-rgb) / var(--tm-control-alpha)) !important; color: #f0fdfa !important; filter: none; }
        #tm-api-console-panel .tm-summary-actions .tm-summary-add:focus-visible { outline: 2px solid #2dd4bf; outline-offset: 2px; }
        .tm-section-content { padding: 12px 14px 14px; }
        #tm-body-section.is-method-without-body .tm-section-content { padding-top: 8px; padding-bottom: 12px; }
        #tm-body-section.is-method-without-body .tm-section-heading { margin: 0; }
        .tm-section-toolbar { display: flex; justify-content: flex-end; margin-bottom: 10px; }
        .tm-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 10px; }
        .tm-section-heading > div { display: grid; gap: 2px; }
        .tm-row-list { display: grid; gap: 7px; }
        .tm-kv-row { display: grid; grid-template-columns: minmax(130px, .8fr) minmax(160px, 1.2fr) 30px; gap: 7px; align-items: center; }
        .tm-header-row { grid-template-columns: minmax(145px, .8fr) minmax(145px, .8fr) minmax(160px, 1.2fr) 30px; }
        .tm-header-row:not(.has-custom-header) { grid-template-columns: minmax(145px, .8fr) minmax(160px, 1.2fr) 30px; }
        .tm-header-row.is-content-type { grid-template-columns: minmax(145px, .8fr) minmax(160px, 1.2fr) minmax(160px, 1.2fr) 30px; }
        .tm-header-row.is-content-type:not(.has-custom-content-type) { grid-template-columns: minmax(145px, .8fr) minmax(160px, 1.2fr) 30px; }
        .tm-body-field-row { display: grid; grid-template-columns: minmax(130px, .75fr) minmax(110px, .45fr) minmax(190px, 1.2fr) 30px; gap: 7px; align-items: center; }
        #tm-api-console-panel button { min-height: 34px; border: 0; border-radius: 8px; padding: 8px 12px; background: rgb(var(--tm-action-rgb) / var(--tm-control-alpha)); color: #fff; cursor: pointer; font: 700 12px/1 system-ui, sans-serif; }
        #tm-api-console-panel button:hover { filter: brightness(1.1); }
        #tm-api-console-panel button.secondary, #tm-api-console-panel button.icon, #tm-api-console-panel button.danger { background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)); }
        #tm-api-console-panel button.danger { color: #fecaca; }
        #tm-api-console-panel button.icon { padding: 0; font-size: 17px; }
        #tm-api-console-panel button.icon.tm-row-remove { display: grid; width: 28px; min-width: 28px; min-height: 28px !important; height: 28px; padding: 0; place-items: center; border: 1px solid transparent; border-radius: 8px; background: transparent !important; color: var(--tm-muted-color); }
        #tm-api-console-panel button.icon.tm-row-remove svg { width: 15px; height: 15px; stroke-width: 2; }
        #tm-api-console-panel button.icon.tm-row-remove:hover { border-color: rgb(248 113 113 / var(--tm-control-alpha)); background: rgb(127 29 29 / var(--tm-control-alpha)) !important; color: #fca5a5; filter: none; }
        #tm-api-console-panel button.icon.tm-row-remove:focus-visible { outline: 2px solid #f87171; outline-offset: 2px; }
        .tm-api-console-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; margin-top: 12px; padding-top: 12px; border-top: 1px solid rgb(71 85 105 / var(--tm-control-alpha)); }
        .tm-api-console-actions::before { content: attr(data-action-label); margin-right: auto; color: var(--tm-muted-color); font-size: 11px; font-weight: 700; }
        .tm-api-console-actions button { min-height: 32px !important; padding: 0 12px !important; border-radius: 8px !important; }
        .tm-api-console-actions #tm-send, .tm-api-console-actions #tm-save-rules { flex: 0 0 auto; min-width: 118px; border-color: rgb(45 212 191 / var(--tm-edge-alpha)) !important; color: #99f6e4; }
        #tm-batch-options { display: grid; grid-template-columns: minmax(154px, .85fr) minmax(132px, .65fr) minmax(230px, 1.35fr); gap: 8px; margin-bottom: 9px; }
        .tm-batch-option { display: grid; min-width: 0; gap: 5px; }
        .tm-batch-option > span { color: var(--tm-muted-color); font-size: 11px; font-weight: 700; }
        .tm-batch-option select, .tm-batch-option input { width: 100% !important; max-width: 100%; }
        #tm-api-console-panel #tm-batch-interval { appearance: textfield; -moz-appearance: textfield; }
        #tm-api-console-panel #tm-batch-interval::-webkit-inner-spin-button, #tm-api-console-panel #tm-batch-interval::-webkit-outer-spin-button { margin: 0; appearance: none; -webkit-appearance: none; }
        #tm-batch-success-condition { display: grid; grid-template-columns: minmax(170px, .9fr) minmax(160px, .75fr) minmax(170px, 1fr); gap: 10px; margin: 0 0 10px; padding: 10px; border: 1px solid var(--tm-control-border); border-radius: 9px; }
        #tm-batch-success-condition label { display: grid; gap: 4px; }
        #tm-batch-success-condition label > span { color: var(--tm-muted-color); font-size: 11px; font-weight: 700; }
        #tm-batch-fields { display: grid; gap: 7px; }
        .tm-batch-field-row { display: grid; grid-template-columns: clamp(142px, 24%, 238px) minmax(0, 1fr) 30px; gap: 8px; align-items: stretch; padding: 8px 9px; border: 1px solid rgb(71 85 105 / calc(var(--tm-control-alpha) * .88)); border-radius: 9px; background: linear-gradient(90deg, rgb(15 23 42 / calc(var(--tm-control-alpha) * .55)), transparent); }
        .tm-batch-field-cell { display: grid; min-width: 0; gap: 4px; }
        .tm-batch-field-caption { display: flex; align-items: baseline; justify-content: flex-start; gap: 6px; color: var(--tm-muted-color); font-size: 10px; font-weight: 700; }
        .tm-batch-token-hint { flex: 0 0 auto; max-width: 100%; overflow: hidden; padding: 2px 5px; border: 1px solid rgb(45 212 191 / var(--tm-edge-alpha)); border-radius: 999px; background: rgb(20 184 166 / 10%); color: #5eead4; font: 700 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
        #tm-api-console-panel .tm-batch-field-row textarea { min-height: 54px; resize: vertical; }
        #tm-api-console-panel .tm-batch-field-row .tm-row-remove { align-self: center; justify-self: end; }
        .tm-batch-guide { display: flex; align-items: baseline; gap: 7px; margin: 0 0 8px; padding: 6px 0 6px 9px; border-left: 2px solid rgb(45 212 191 / var(--tm-edge-alpha)); color: var(--tm-muted-color); font-size: 11px; line-height: 1.45; }
        .tm-batch-guide code { flex: 0 0 auto; color: #5eead4; font: 700 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; }
        #tm-json-body, #tm-raw-body { min-height: 190px; }
        #tm-rewrite-rules { margin-top: 0; padding: 0; }
        .tm-rewrite-module-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--tm-control-border); background: rgb(23 36 58 / var(--tm-surface-alpha)); }
        .tm-rewrite-module-heading > div { display: grid; gap: 2px; }
        .tm-rewrite-module-content { padding: 0 14px 14px; }
        #tm-api-console-panel .tm-rewrite-add { display: inline-flex; align-items: center; gap: 5px; min-height: 32px !important; padding: 0 10px !important; border: 1px solid rgb(var(--tm-control-rgb) / var(--tm-control-alpha)) !important; border-radius: 8px !important; background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)) !important; color: var(--tm-secondary-text); white-space: nowrap; }
        #tm-api-console-panel .tm-rewrite-add svg { width: 15px; height: 15px; color: #5eead4; }
        #tm-api-console-panel .tm-rewrite-add:hover { border-color: rgb(45 212 191 / var(--tm-control-alpha)) !important; background: rgb(var(--tm-control-hover-rgb) / var(--tm-control-alpha)) !important; color: #f0fdfa; filter: none; }
        #tm-rewrite-status { margin-top: 12px; padding: 8px 10px; border: 1px solid var(--tm-control-border); border-radius: 8px; background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)); color: var(--tm-secondary-text); font-size: 12px; }
        #tm-rewrite-status.is-error { border-color: rgb(248 113 113 / var(--tm-control-alpha)); background: rgb(127 29 29 / var(--tm-control-alpha)); color: #fecaca; }
        #tm-rule-list { display: grid; gap: 12px; }
        .tm-rule-card { padding: 12px; border: 1px solid var(--tm-surface-border); border-radius: 10px; background: rgb(23 36 58 / var(--tm-surface-alpha)); }
        .tm-rule-card-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
        .tm-rule-card-title { font-weight: 800; }
        #tm-api-console-panel .tm-rule-remove { min-height: 28px !important; padding: 0 9px !important; border: 1px solid transparent !important; background: transparent !important; color: #fda4af; }
        #tm-api-console-panel .tm-rule-remove:hover { border-color: rgb(248 113 113 / var(--tm-control-alpha)) !important; background: rgb(127 29 29 / var(--tm-control-alpha)) !important; color: #fee2e2; filter: none; }
        .tm-switch { display: flex !important; grid-auto-flow: column; align-items: center; gap: 6px; width: fit-content; color: #99f6e4 !important; }
        .tm-switch input { width: 15px !important; min-height: 15px !important; padding: 0 !important; accent-color: #14b8a6; }
        .tm-rule-grid { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 10px; }
        .tm-rule-wide { grid-column: span 2; }
        .tm-method-list { grid-column: span 2; display: flex; flex-wrap: wrap; gap: 7px 11px; margin: 0; padding: 0; border: 0; }
        .tm-method-list legend { width: 100%; margin-bottom: 2px; color: #cbd5e1; font-size: 12px; font-weight: 700; }
        #tm-api-console-panel .tm-method-list label { display: inline-flex; align-items: center; gap: 5px; width: fit-content; color: #cbd5e1; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
        .tm-method-list input { width: 14px !important; min-height: 14px !important; padding: 0 !important; accent-color: #14b8a6; }
        .tm-rule-extra { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--tm-control-border); }
        .tm-rule-extra summary { cursor: pointer; color: #cbd5e1; font-size: 12px; font-weight: 700; }
        .tm-rule-extra-content { margin-top: 10px; }
        .tm-rule-body-toggle { display: flex !important; grid-auto-flow: column; align-items: center; justify-content: start; gap: 6px; margin-top: 10px; }
        .tm-rule-body-toggle input { width: 15px !important; min-height: 15px !important; padding: 0 !important; accent-color: #14b8a6; }
        .tm-rule-body { min-height: 100px !important; margin-top: 8px; }
        .tm-rule-response { display: grid; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--tm-control-border); }
        .tm-rule-response textarea { min-height: 120px !important; }
        .tm-rule-script { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--tm-control-border); }
        .tm-rule-script summary { cursor: pointer; color: #cbd5e1; font-size: 12px; font-weight: 700; }
        .tm-rule-script-content { display: grid; gap: 8px; margin-top: 10px; }
        .tm-rule-script textarea { min-height: 150px !important; }
        #tm-empty-rules { margin: 12px 0; padding: 18px; border: 1px dashed var(--tm-control-border); border-radius: 9px; color: #94a3b8; text-align: center; font-size: 12px; }
        .tm-debug-panel { margin-top: 12px; border: 1px solid var(--tm-surface-border); border-radius: 10px; background: rgb(2 6 23 / var(--tm-control-alpha)); overflow: hidden; }
        .tm-debug-header { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--tm-control-border); }
        .tm-debug-title { display: flex; align-items: baseline; min-width: 0; gap: 9px; }
        .tm-debug-title h3 { font-size: 13px !important; }
        .tm-debug-title .tm-hint { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        .tm-debug-state { margin-left: auto; padding: 3px 7px; border: 1px solid var(--tm-control-border); border-radius: 999px; color: #94a3b8; font-size: 11px; font-weight: 700; white-space: nowrap; }
        .tm-debug-state.is-active { border-color: rgb(45 212 191 / var(--tm-control-alpha)); background: rgb(20 184 166 / var(--tm-control-alpha)); color: #99f6e4; }
        .tm-debug-content { padding: 0 12px 12px; }
        .tm-debug-toolbar { display: grid; grid-template-columns: auto minmax(170px, 1fr) auto; align-items: center; gap: 8px; padding: 10px 0 0; }
        .tm-debug-toolbar .tm-switch { color: #cbd5e1 !important; font-size: 11px; }
        .tm-debug-filter { display: flex !important; align-items: center; min-width: 0; gap: 0; }
        .tm-debug-filter > span { display: none; }
        .tm-debug-filter input { width: 100%; min-width: 0; min-height: 30px !important; padding: 5px 58px 5px 10px !important; border-radius: 8px !important; font-size: 11px !important; }
        .tm-debug-filter { position: relative; }
        .tm-debug-filter small { position: absolute; top: 50%; right: 9px; color: var(--tm-muted-color); font: 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; pointer-events: none; transform: translateY(-50%); }
        #tm-clear-debug { min-height: 30px !important; padding: 0 10px !important; font-size: 11px; white-space: nowrap; }
        .tm-debug-steps { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; margin-top: 10px; }
        .tm-debug-step { min-width: 0; padding: 8px; border: 1px solid var(--tm-control-border); border-radius: 8px; background: rgb(15 23 42 / var(--tm-control-alpha)); }
        .tm-debug-step.is-active { border-color: rgb(45 212 191 / var(--tm-control-alpha)); background: rgb(20 184 166 / var(--tm-control-alpha)); }
        .tm-debug-step.is-success { border-color: rgb(45 212 191 / var(--tm-control-alpha)); }
        .tm-debug-step.is-error { border-color: rgb(248 113 113 / var(--tm-control-alpha)); background: rgb(127 29 29 / var(--tm-control-alpha)); }
        .tm-debug-step-label { display: flex; align-items: center; gap: 5px; color: #cbd5e1; font-size: 11px; font-weight: 700; }
        .tm-debug-step-index { display: grid; width: 16px; height: 16px; place-items: center; border-radius: 999px; background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)); color: #cbd5e1; font-size: 10px; }
        .tm-debug-step.is-active .tm-debug-step-index, .tm-debug-step.is-success .tm-debug-step-index { background: rgb(var(--tm-action-rgb) / var(--tm-control-alpha)); color: #ecfeff; }
        .tm-debug-step.is-error .tm-debug-step-index { background: rgb(153 27 27 / var(--tm-control-alpha)); color: #fee2e2; }
        .tm-debug-step-value { display: block; margin-top: 5px; overflow: hidden; color: #94a3b8; font: 11px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
        #tm-debug-empty { margin: 10px 0 0; color: #94a3b8; font-size: 12px; }
        #tm-debug-log { display: grid; gap: 7px; min-width: 0; max-height: 280px; margin: 10px 0 0; padding: 0; overflow: auto; list-style: none; scrollbar-width: none; }
        #tm-debug-log::-webkit-scrollbar { width: 0; height: 0; }
        .tm-debug-log-item { position: relative; isolation: isolate; min-width: 0; max-width: 100%; padding: 9px 10px; overflow: visible; border: 1px solid var(--tm-control-border); border-left: 3px solid rgb(100 116 139 / var(--tm-control-alpha)); border-radius: 8px; background: rgb(15 23 42 / var(--tm-control-alpha)); }
        .tm-debug-log-item:has(.tm-debug-curl-export.is-open) { z-index: 3; }
        .tm-debug-log-item.is-matched { border-left-color: rgb(45 212 191 / var(--tm-control-alpha)); }
        .tm-debug-log-item.is-error { border-left-color: rgb(248 113 113 / var(--tm-control-alpha)); }
        .tm-debug-log-head { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 5px 8px; color: #cbd5e1; font: 700 11px/1.35 ui-monospace, SFMono-Regular, Menlo, monospace; }
        .tm-debug-log-actions { display: flex; justify-content: flex-end; margin-top: 6px; }
        #tm-api-console-panel .tm-debug-edit-button { display: inline-flex; align-items: center; gap: 5px; min-height: 26px !important; padding: 0 9px !important; border-color: rgb(45 212 191 / var(--tm-edge-alpha)) !important; border-radius: 999px !important; color: #99f6e4; font-size: 11px; letter-spacing: .01em; }
        #tm-api-console-panel .tm-debug-edit-button svg { width: 13px; height: 13px; stroke-width: 2.1; }
        #tm-api-console-panel .tm-debug-edit-button:hover { border-color: #2dd4bf !important; color: #f0fdfa; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-edit-button, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-edit-button { color: #0f766e !important; }
        .tm-debug-log-status { color: #94a3b8; }
        .tm-debug-log-status.is-success { color: #99f6e4; }
        .tm-debug-log-status.is-error { color: #fca5a5; }
        .tm-debug-log-url { display: block; width: 100%; min-width: 0; margin-top: 5px; overflow-x: auto; overflow-y: hidden; color: #cbd5e1; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; scrollbar-width: none; -ms-overflow-style: none; overscroll-behavior-x: contain; }
        .tm-debug-log-url::-webkit-scrollbar { display: none; }
        .tm-debug-log-url.is-rewritten { color: #99f6e4; }
        .tm-debug-log-meta { margin-top: 4px; color: #94a3b8; font-size: 11px; }
        .tm-debug-details-wrap { position: relative; width: 100%; min-width: 0; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgb(51 65 85 / var(--tm-control-alpha)); }
        .tm-debug-details { width: 100%; min-width: 0; }
        .tm-debug-details > summary { display: block; width: 100%; min-width: 0; min-height: 30px; padding: 5px 194px 5px 0; overflow: hidden; color: #99f6e4; font-size: 11px; font-weight: 800; cursor: pointer; list-style: none; }
        .tm-debug-details > summary::-webkit-details-marker { display: none; }
        .tm-debug-details > summary::before { content: '›'; display: inline-block; margin-right: 5px; color: #5eead4; font-size: 16px; line-height: 10px; transition: transform .16s ease; }
        .tm-debug-details[open] > summary::before { transform: rotate(90deg); }
        .tm-debug-summary-title { display: inline-block; max-width: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; white-space: nowrap; }
        .tm-debug-request-export { position: absolute; z-index: 1; top: 11px; right: 0; display: inline-flex; align-items: center; gap: 4px; min-width: 0; margin: 0; padding: 0; border: 0; background: transparent; }
        .tm-debug-request-export-label { margin-right: 1px; color: var(--tm-muted-color); font-size: 10px; font-weight: 700; letter-spacing: .01em; white-space: nowrap; }
        #tm-api-console-panel .tm-debug-export-button { min-height: 23px !important; padding: 0 7px !important; border: 1px solid var(--tm-control-border) !important; border-radius: 999px !important; background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)) !important; color: var(--tm-secondary-text); font: 700 10px/1 ui-sans-serif, system-ui, sans-serif; }
        #tm-api-console-panel .tm-debug-export-button:hover { border-color: rgb(45 212 191 / var(--tm-control-alpha)) !important; background: rgb(var(--tm-control-hover-rgb) / var(--tm-control-alpha)) !important; color: #f0fdfa; filter: none; }
        .tm-debug-curl-export { position: relative; display: inline-flex; align-items: center; gap: 2px; }
        #tm-api-console-panel .tm-debug-curl-menu-button { display: grid; width: 20px; min-width: 20px; min-height: 23px !important; padding: 0 !important; place-items: center; border-radius: 999px !important; }
        #tm-api-console-panel .tm-debug-curl-menu-button svg { width: 11px; height: 11px; transition: transform .16s ease; }
        .tm-debug-curl-export.is-open .tm-debug-curl-menu-button svg { transform: rotate(180deg); }
        .tm-debug-curl-menu, .tm-debug-curl-menu * { box-sizing: border-box; }
        .tm-debug-curl-menu { position: fixed; z-index: 2147483647; display: none; min-width: 132px; padding: 3px; border: 1px solid rgb(71 85 105 / 84%); border-radius: 8px; background: rgb(15 23 42 / 98%); box-shadow: 0 8px 20px rgb(2 6 23 / 34%); }
        .tm-debug-curl-menu.is-open { display: grid; gap: 2px; }
        #tm-api-console-panel .tm-debug-curl-menu-button { border-color: var(--tm-control-border) !important; background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)) !important; color: var(--tm-secondary-text) !important; }
        .tm-debug-curl-menu-item { display: block; width: 100%; min-height: 25px !important; padding: 0 8px !important; border: 0 !important; border-radius: 5px !important; appearance: none; background: transparent !important; color: #cbd5e1 !important; cursor: pointer; text-align: left; white-space: nowrap; font: 600 10px/1 ui-sans-serif, system-ui, sans-serif; }
        .tm-debug-curl-menu-item.is-selected { background: rgb(20 184 166 / 13%) !important; color: #99f6e4 !important; }
        #tm-api-console-panel .tm-debug-curl-menu-button:hover, .tm-debug-curl-menu-item:hover { background: rgb(71 85 105 / 58%) !important; color: #f0fdfa !important; }
        .tm-debug-detail-grid { display: grid; width: 100%; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 9px; }
        .tm-debug-detail { min-width: 0; padding: 8px; border: 1px solid var(--tm-control-border); border-radius: 7px; background: rgb(2 6 23 / var(--tm-control-alpha)); }
        .tm-debug-detail.is-wide { grid-column: 1 / -1; }
        .tm-debug-detail-label { display: block; margin-bottom: 5px; color: #94a3b8; font-size: 10px; font-weight: 800; letter-spacing: .02em; }
        .tm-debug-detail-value { max-height: 210px; margin: 0; overflow: auto; color: #dbeafe; white-space: pre-wrap; word-break: break-word; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; scrollbar-width: thin; scrollbar-color: #475569 transparent; }
        .tm-debug-detail-value::-webkit-scrollbar { width: 5px; height: 5px; }
        .tm-debug-detail-value::-webkit-scrollbar-thumb { border-radius: 999px; background: rgb(71 85 105 / var(--tm-control-alpha)); }
        .tm-debug-payload-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin: -1px 0 6px; }
        #tm-api-console-panel .tm-debug-payload-button { min-height: 24px !important; padding: 0 8px !important; border: 1px solid var(--tm-control-border) !important; border-radius: 6px !important; background: rgb(var(--tm-control-rgb) / var(--tm-control-alpha)) !important; color: var(--tm-secondary-text); font-size: 11px; }
        #tm-api-console-panel .tm-debug-payload-button:hover, #tm-api-console-panel .tm-debug-payload-button.is-active { border-color: rgb(45 212 191 / var(--tm-control-alpha)) !important; background: rgb(var(--tm-control-hover-rgb) / var(--tm-control-alpha)) !important; color: #f0fdfa; filter: none; }
        .tm-debug-json-raw { max-height: 210px; margin: 0; overflow: auto; white-space: pre-wrap; word-break: break-word; }
        .tm-debug-json-tree { max-height: 210px; overflow: auto; color: #dbeafe; font: 11px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; scrollbar-width: thin; scrollbar-color: #475569 transparent; }
        .tm-debug-json-tree::-webkit-scrollbar { width: 5px; height: 5px; }
        .tm-debug-json-tree::-webkit-scrollbar-thumb { border-radius: 999px; background: rgb(71 85 105 / var(--tm-control-alpha)); }
        .tm-json-branch { margin: 2px 0; }
        .tm-json-branch > summary { display: flex; align-items: baseline; gap: 5px; cursor: pointer; list-style: none; }
        .tm-json-branch > summary::-webkit-details-marker { display: none; }
        .tm-json-branch > summary::before { content: '›'; width: 8px; color: #5eead4; font-size: 16px; line-height: 10px; transition: transform .14s ease; }
        .tm-json-branch[open] > summary::before { transform: rotate(90deg); }
        .tm-json-key { color: #93c5fd; }
        .tm-json-type { color: #64748b; font-size: 10px; }
        .tm-json-children { margin-left: 12px; padding-left: 8px; border-left: 1px solid rgb(71 85 105 / var(--tm-control-alpha)); }
        .tm-json-leaf { padding: 2px 0 2px 13px; word-break: break-word; }
        .tm-json-value-string { color: #86efac; }
        .tm-json-value-number { color: #fcd34d; }
        .tm-json-value-boolean { color: #f0abfc; }
        .tm-json-value-null { color: #94a3b8; }
        #tm-result-section { margin-top: 16px; }
        #tm-api-console-output { min-height: 76px; margin: 0; padding: 12px; overflow: auto; border: 1px solid var(--tm-control-border); border-radius: 10px; background: rgb(2 6 23 / var(--tm-control-alpha)); white-space: pre-wrap; word-break: break-word; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
        #tm-api-console-panel button { border: 1px solid var(--tm-control-border); background: transparent; color: var(--tm-secondary-text); }
        #tm-api-console-panel button.secondary, #tm-api-console-panel button.icon, #tm-api-console-panel button.danger, #tm-api-console-panel .tm-summary-actions .tm-summary-add, #tm-api-console-panel .tm-rewrite-add, #tm-api-console-panel .tm-debug-payload-button, #tm-api-console-panel #tm-send { border-color: var(--tm-control-border) !important; background: transparent !important; }
        #tm-api-console-panel .tm-module-tab.is-active, #tm-api-console-panel .tm-debug-step.is-active, #tm-api-console-panel .tm-debug-state.is-active, #tm-api-console-panel #tm-send { border-color: rgb(45 212 191 / var(--tm-edge-alpha)) !important; background: transparent !important; }
        #tm-url-mode-choice label:has(input:checked) { background: transparent; color: #5eead4; }
        #tm-api-console-panel.tm-panel-low-opacity button, #tm-api-console-panel.tm-panel-zero-opacity button, #tm-api-console-panel.tm-panel-low-opacity .tm-debug-step-index, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-step-index { color: #1e293b !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-step.is-active, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-step.is-active, #tm-api-console-panel.tm-panel-low-opacity .tm-debug-step.is-success, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-step.is-success, #tm-api-console-panel.tm-panel-low-opacity .tm-debug-state.is-active, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-state.is-active { color: #0f766e !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-step.is-error, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-step.is-error, #tm-api-console-panel.tm-panel-low-opacity .tm-debug-log-status.is-error, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-log-status.is-error { color: #b91c1c !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-details > summary, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-details > summary, #tm-api-console-panel.tm-panel-low-opacity .tm-json-branch > summary::before, #tm-api-console-panel.tm-panel-zero-opacity .tm-json-branch > summary::before { color: #0f766e !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-debug-detail-value, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-detail-value, #tm-api-console-panel.tm-panel-low-opacity .tm-debug-json-tree, #tm-api-console-panel.tm-panel-zero-opacity .tm-debug-json-tree { color: #334155 !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-json-key, #tm-api-console-panel.tm-panel-zero-opacity .tm-json-key { color: #1d4ed8 !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-json-type, #tm-api-console-panel.tm-panel-zero-opacity .tm-json-type, #tm-api-console-panel.tm-panel-low-opacity .tm-json-value-null, #tm-api-console-panel.tm-panel-zero-opacity .tm-json-value-null { color: #475569 !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-json-value-string, #tm-api-console-panel.tm-panel-zero-opacity .tm-json-value-string { color: #15803d !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-json-value-number, #tm-api-console-panel.tm-panel-zero-opacity .tm-json-value-number { color: #b45309 !important; }
        #tm-api-console-panel.tm-panel-low-opacity .tm-json-value-boolean, #tm-api-console-panel.tm-panel-zero-opacity .tm-json-value-boolean { color: #a21caf !important; }
        #tm-api-console-panel.tm-panel-low-opacity #tm-panel-settings-popup button, #tm-api-console-panel.tm-panel-zero-opacity #tm-panel-settings-popup button { color: #e2e8f0 !important; }
        #tm-api-console-root [hidden] { display: none !important; }
        @container (min-width: 1050px) { #tm-request-meta { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
        @container (max-width: 720px) { #tm-batch-options { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } #tm-batch-options .tm-batch-option:last-child { grid-column: 1 / -1; } }
        @container (max-width: 520px) { .tm-batch-field-row { grid-template-columns: minmax(0, 1fr) 28px; } .tm-batch-field-row .tm-batch-field-cell:nth-child(2) { grid-column: 1 / -1; } .tm-batch-field-row .tm-row-remove { grid-column: 2; grid-row: 1; } }
        @media (max-width: 620px) { #tm-request-grid, #tm-response-grid, .tm-rule-grid, #tm-batch-options { grid-template-columns: 1fr; } #tm-batch-options .tm-batch-option:last-child { grid-column: auto; } #tm-batch-success-condition { grid-template-columns: 1fr; } #tm-response-grid.is-preview { display: grid; width: 100%; } #tm-response-grid.is-preview label { display: grid; grid-auto-flow: row; } #tm-response-grid.is-preview select { width: 100%; } .tm-debug-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); } .tm-rule-wide, .tm-method-list { grid-column: auto; } .tm-kv-row, .tm-body-field-row, .tm-batch-field-row { grid-template-columns: 1fr; } .tm-batch-field-row .tm-row-remove { margin-top: -2px; } .tm-batch-guide { align-items: flex-start; flex-wrap: wrap; } #tm-api-console-panel button.icon { min-height: 30px; } }
        @container (max-width: 300px) { .tm-debug-details > summary { padding-right: 0; } .tm-debug-request-export { position: static; margin: 4px 0 0 21px; } }
      </style>
      <div id="tm-api-console-root">
      <div id="tm-api-console-control"><button id="tm-api-console-button" type="button" title="单击打开或关闭;拖动调整位置"><span class="tm-floating-tool-label" aria-hidden="true"><span class="tm-floating-console-mark"><span class="tm-floating-console-api">API</span><span class="tm-floating-console-name">Console</span></span></span><span class="tm-floating-tool-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"></circle><circle cx="18" cy="8" r="2"></circle><circle cx="12" cy="18" r="2"></circle><path d="m7.8 6.6 8.3 1.1M7.2 7.7l3.8 8.5M17.1 9.8l-4 6.5"></path></svg></span></button><button id="tm-main-hide-button" class="tm-floating-hide-button" type="button" title="隐藏接口工具" aria-label="隐藏接口工具"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="m7 7 10 10M17 7 7 17"></path></svg></button></div>
      <div id="tm-show-tool-control" hidden><button id="tm-show-tool-button" type="button" title="单击显示工具;拖动调整位置"><span class="tm-floating-tool-label" aria-hidden="true"><span class="tm-floating-console-mark"><span class="tm-floating-console-api">API</span><span class="tm-floating-console-name">Console</span></span></span><span class="tm-floating-tool-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2"></circle><circle cx="18" cy="8" r="2"></circle><circle cx="12" cy="18" r="2"></circle><path d="m7.8 6.6 8.3 1.1M7.2 7.7l3.8 8.5M17.1 9.8l-4 6.5"></path></svg></span></button><button id="tm-show-hide-button" class="tm-floating-hide-button" type="button" title="隐藏显示接口工具按钮" aria-label="隐藏显示接口工具按钮"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true"><path d="m7 7 10 10M17 7 7 17"></path></svg></button></div>
      <button id="tm-panel-scroll-hide-button" class="secondary" type="button" title="隐藏悬浮按钮" aria-label="隐藏悬浮按钮" hidden><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 3 18 18"></path><path d="M10.6 10.6a2 2 0 0 0 2.8 2.8"></path><path d="M9.9 4.2A10.8 10.8 0 0 1 12 4c5.3 0 9.3 4.2 10 8-.2 1.2-.8 2.5-1.7 3.6"></path><path d="M6.2 6.2C4.2 7.6 2.7 7.6 2 12c.8 4 4.8 8 10 8 1.5 0 2.9-.3 4.1-.9"></path></svg></button>
      <section id="tm-api-console-panel" aria-label="API Console">
        <div class="tm-panel-heading"><h2>接口工具</h2><div class="tm-panel-controls"><div class="tm-panel-settings-wrap"><button id="tm-panel-settings-button" class="icon" type="button" title="打开面板设置" aria-label="打开面板设置" aria-expanded="false" aria-controls="tm-panel-settings-popup"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" aria-hidden="true"><path d="M4 6h6M14 6h6M4 12h2M10 12h10M4 18h10"></path><circle cx="12" cy="6" r="2"></circle><circle cx="8" cy="12" r="2"></circle><circle cx="16" cy="18" r="2"></circle></svg></button><div id="tm-panel-settings-popup" class="tm-panel-settings" hidden role="dialog" aria-label="面板设置"><div class="tm-panel-settings-title"><span>显示与请求</span><output id="tm-panel-opacity-value" class="tm-panel-settings-value">96%</output></div><label class="tm-panel-setting"><span>面板背景不透明度</span><input id="tm-panel-opacity" type="range" min="0" max="100" step="1" value="96"></label><label class="tm-panel-setting"><span>主动请求超时 <small>0 为不限制</small></span><span class="tm-settings-unit-input"><input id="tm-request-timeout" type="number" min="0" max="600" step="1" inputmode="numeric" value="30"><em>秒</em></span></label><label class="tm-panel-setting"><span>cURL 默认命令格式</span><select id="tm-curl-target"><option value="posix">macOS / Linux</option><option value="powershell">PowerShell</option></select></label><div class="tm-panel-settings-section">监听记录</div><label class="tm-panel-setting"><span>面板记录上限 <small>超出后自动移除最早记录</small></span><select id="tm-debug-record-limit"><option value="100">100 条</option><option value="300">300 条</option><option value="500">500 条</option><option value="1000">1000 条</option></select></label><label class="tm-panel-settings-toggle"><span><strong>刷新后保留监听记录</strong><small>同一网站最多 50 条,可能包含敏感数据</small></span><input id="tm-debug-persist" type="checkbox" aria-label="刷新后保留监听记录"></label><span class="tm-hint">不透明度只影响外层背景;关闭保留后会立即删除该网站已保存的监听记录。</span><button id="tm-panel-reset-layout" class="secondary" type="button">恢复默认位置和大小</button></div></div><button id="tm-panel-hide-button" class="secondary" type="button" title="隐藏悬浮按钮" aria-label="隐藏悬浮按钮"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m3 3 18 18"></path><path d="M10.6 10.6a2 2 0 0 0 2.8 2.8"></path><path d="M9.9 4.2A10.8 10.8 0 0 1 12 4c5.3 0 9.3 4.2 10 8-.2 1.2-.8 2.5-1.7 3.6"></path><path d="M6.2 6.2C4.2 7.6 2.7 7.6 2 12c.8 4 4.8 8 10 8 1.5 0 2.9-.3 4.1-.9"></path></svg></button></div></div>
        <div id="tm-module-tabs" role="tablist" aria-label="接口工具模块"><button class="tm-module-tab is-active" data-module-tab="request" type="button" role="tab" aria-selected="true">接口请求</button><button class="tm-module-tab" data-module-tab="rewrite" type="button" role="tab" aria-selected="false">网页请求改写</button></div>
        <div id="tm-request-module" class="tm-module">
        <div id="tm-active-request">
        <div id="tm-captured-edit-banner" hidden><span>正在编辑捕获请求,原接口请求配置已保留。</span><button id="tm-restore-captured-draft" class="secondary" type="button">恢复原请求</button></div>
        <div id="tm-url-mode-choice" role="radiogroup" aria-label="URL 来源"><span class="tm-url-mode-label">URL 来源</span><div class="tm-url-mode-options"><label><input name="tm-url-mode" value="manual" type="radio">手动输入</label><label title="载入当前浏览器地址;载入后仍可修改请求 URL 和参数"><input name="tm-url-mode" value="current-page" type="radio">浏览器地址</label></div></div>
        <div id="tm-request-grid">
          <label>请求方法<select id="tm-method"><option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option><option>HEAD</option></select></label>
          <label>请求 URL<input id="tm-url" type="url" placeholder="https://api.example.com/v1/resource"></label>
        </div>
        <div id="tm-request-meta">
          <details class="tm-section tm-collapsible">
            <summary><div class="tm-summary-copy"><h3>查询参数</h3><span class="tm-hint">覆盖或附加 URL 参数</span></div><div class="tm-summary-actions"><button id="tm-add-param" class="secondary tm-summary-add" type="button" title="添加参数" aria-label="添加参数"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg><span>添加</span></button></div></summary>
            <div class="tm-section-content"><div id="tm-params" class="tm-row-list"></div></div>
          </details>
          <details class="tm-section tm-collapsible">
            <summary><div class="tm-summary-copy"><h3>请求头</h3><span class="tm-hint">配置 Content-Type、Authorization 等常用项</span></div><div class="tm-summary-actions"><button id="tm-add-header" class="secondary tm-summary-add" type="button" title="添加 Header" aria-label="添加 Header"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg><span>添加</span></button></div></summary>
            <div class="tm-section-content"><div id="tm-headers" class="tm-row-list"></div></div>
          </details>
        </div>
        <details id="tm-body-section" class="tm-section tm-collapsible">
          <summary><div class="tm-summary-copy"><h3>请求体</h3><span class="tm-hint">根据 Content-Type 自动切换请求格式</span></div><div class="tm-summary-actions"><button id="tm-add-body-field" class="secondary tm-summary-add" type="button" title="添加字段" aria-label="添加字段"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg><span>添加</span></button></div></summary>
          <div class="tm-section-content"><div class="tm-section-heading"><span id="tm-body-type-hint" class="tm-hint"></span></div>
            <div id="tm-json-body-wrap"><textarea id="tm-json-body" spellcheck="false" placeholder="{\n  \"name\": \"demo\"\n}"></textarea></div>
            <div id="tm-body-fields-wrap" hidden><h3>请求字段</h3><p class="tm-hint">每行选择文本、数值或附件;附件类型仅在 multipart/form-data 下可用。</p><div id="tm-body-fields" class="tm-row-list" style="margin-top:8px"></div></div>
            <textarea id="tm-raw-body" spellcheck="false" placeholder="原样发送的请求内容" hidden></textarea></div>
        </details>
        <details class="tm-section tm-collapsible">
          <summary><div class="tm-summary-copy"><h3>响应处理</h3><span class="tm-hint">预览 JSON 响应或下载文件</span></div></summary>
          <div class="tm-section-content"><div id="tm-response-grid">
            <label>处理方式<select id="tm-response-mode"><option value="preview">在面板中查看响应</option><option value="download">下载响应文件</option></select></label>
            <label id="tm-download-name-wrap">保存文件名(可选)<input id="tm-download-name" placeholder="留空则读取服务端文件名"></label>
          </div></div>
        </details>
        <details id="tm-batch-section" class="tm-section tm-collapsible">
          <summary><div class="tm-summary-copy"><h3>批量请求</h3><span class="tm-hint">多变量按相同行号组合发送</span></div><div class="tm-summary-actions"><button id="tm-add-batch-field" class="secondary tm-summary-add" type="button" title="添加批量变量" aria-label="添加批量变量"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg><span>添加</span></button></div></summary>
          <div class="tm-section-content"><div id="tm-batch-options"><label class="tm-batch-option"><span>执行方式</span><select id="tm-batch-execution"><option value="all">全部执行</option><option value="stop-on-success">任意一条成功后停止</option></select></label><label class="tm-batch-option"><span>请求间隔(秒)</span><input id="tm-batch-interval" type="text" inputmode="decimal" value="0" placeholder="0 表示连续发送"></label><label class="tm-batch-option"><span>成功判定</span><select id="tm-batch-success-mode"><option value="http">仅 HTTP 状态成功(2xx–3xx)</option><option value="http-and-json">HTTP 成功且满足 JSON 条件</option><option value="json">仅满足 JSON 条件</option></select></label></div><div id="tm-batch-success-condition" hidden><label><span>JSON 路径</span><input id="tm-batch-success-path" placeholder="例如 code 或 data.success"></label><label><span>判断方式</span><select id="tm-batch-success-operator"><option value="equals">等于</option><option value="not-equals">不等于</option><option value="exists">字段存在</option><option value="truthy">值为真</option><option value="falsy">值为假</option><option value="includes">包含文本</option></select></label><label id="tm-batch-success-expected-wrap"><span>期望值</span><input id="tm-batch-success-expected" placeholder="例如 0、true 或 success"></label></div><div class="tm-batch-guide"><code>{{变量名}}</code><span>可填入 URL、参数、Header 或请求体;每行变量值会组合为一次请求。</span></div><div id="tm-batch-fields"></div><p class="tm-hint">变量值可用换行、逗号或分号分隔;多个变量的有效值数量需一致。请求间隔从前一条请求完成后开始计算。</p></div>
        </details>
        <div class="tm-api-console-actions" data-action-label="请求操作"><button id="tm-send" type="button">发送请求</button><button id="tm-batch-send" class="secondary" type="button">批量发送</button><button id="tm-save" class="secondary" type="button">保存配置</button></div>
        </div>
        <details id="tm-result-section" class="tm-section tm-collapsible" open>
          <summary><div class="tm-summary-copy"><h3>操作状态</h3><span class="tm-hint">显示规则保存和配置提示</span></div></summary>
          <div class="tm-section-content"><pre id="tm-api-console-output">等待操作。</pre></div>
        </details>
        </div>
        <div id="tm-rewrite-module" class="tm-module" hidden>
          <section id="tm-rewrite-rules" class="tm-section">
            <div class="tm-rewrite-module-heading"><div><h3>网页请求改写</h3><span class="tm-hint">拦截并改写当前网页的 fetch / XMLHttpRequest</span></div><button id="tm-add-rule" class="secondary tm-rewrite-add" type="button" title="新建规则" aria-label="新建规则"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg><span>添加规则</span></button></div>
            <div class="tm-rewrite-module-content">
              <div id="tm-rewrite-status" hidden role="status"></div>
              <div id="tm-empty-rules">尚未创建规则。点击右上角“添加”,填写要匹配的接口地址。</div>
              <div id="tm-rule-list"></div>
              <div class="tm-api-console-actions" data-action-label="改写规则"><button id="tm-save-rules" type="button">保存改写规则</button><button id="tm-example" class="secondary" type="button">添加示例规则</button></div>
              <section id="tm-rewrite-debug" class="tm-debug-panel">
                <div class="tm-debug-header"><div class="tm-debug-title"><h3>请求调试</h3><span class="tm-hint">按步骤查看当前页面请求的捕获、匹配、改写和响应</span></div><span id="tm-debug-state" class="tm-debug-state">已关闭</span></div>
                <div class="tm-debug-content"><div class="tm-debug-toolbar"><label class="tm-switch"><input id="tm-debug-enabled" type="checkbox">监听</label><label class="tm-debug-filter"><span>接口过滤</span><input id="tm-debug-filter" type="search" aria-label="接口过滤" placeholder="筛选 URL、方法或状态码" autocomplete="off" spellcheck="false"><small id="tm-debug-filter-count">0/0</small></label><button id="tm-clear-debug" class="secondary" type="button">清空</button></div><div id="tm-debug-steps" class="tm-debug-steps"><div id="tm-debug-step-capture" class="tm-debug-step"><span class="tm-debug-step-label"><span class="tm-debug-step-index">1</span>捕获请求</span><span class="tm-debug-step-value">等待开启监听</span></div><div id="tm-debug-step-match" class="tm-debug-step"><span class="tm-debug-step-label"><span class="tm-debug-step-index">2</span>匹配规则</span><span class="tm-debug-step-value">等待请求</span></div><div id="tm-debug-step-rewrite" class="tm-debug-step"><span class="tm-debug-step-label"><span class="tm-debug-step-index">3</span>执行改写</span><span class="tm-debug-step-value">等待匹配</span></div><div id="tm-debug-step-response" class="tm-debug-step"><span class="tm-debug-step-label"><span class="tm-debug-step-index">4</span>接收响应</span><span class="tm-debug-step-value">等待响应</span></div></div><p id="tm-debug-empty">开启监听后,在网页中触发接口;每条记录均可展开查看请求参数、请求头、请求体和响应内容。</p><ol id="tm-debug-log"></ol></div>
              </section>
            </div>
          </section>
        </div>
        <div class="tm-panel-resize-handle" data-panel-resize="nw" title="拖动调整窗口大小" aria-label="拖动调整窗口大小"></div>
        <div class="tm-panel-resize-handle" data-panel-resize="ne" title="拖动调整窗口大小" aria-label="拖动调整窗口大小"></div>
        <div class="tm-panel-resize-handle" data-panel-resize="sw" title="拖动调整窗口大小" aria-label="拖动调整窗口大小"></div>
        <div class="tm-panel-resize-handle" data-panel-resize="se" title="拖动调整窗口大小" aria-label="拖动调整窗口大小"></div>
      </section>
      </div>
    `;

    const embeddedStyle = root.querySelector('#tm-api-console-style');
    if (embeddedStyle
      && 'adoptedStyleSheets' in root
      && typeof CSSStyleSheet !== 'undefined'
      && typeof CSSStyleSheet.prototype.replaceSync === 'function') {
      try {
        const styleSheet = new CSSStyleSheet();
        styleSheet.replaceSync(embeddedStyle.textContent || '');
        root.adoptedStyleSheets = [...root.adoptedStyleSheets, styleSheet];
        embeddedStyle.remove();
      } catch {
      }
    }
    document.body.appendChild(host);

    const get = (id) => root.querySelector(id);
    const floatingControl = get('#tm-api-console-control');
    const button = get('#tm-api-console-button');
    const mainHideButton = get('#tm-main-hide-button');
    const showToolControl = get('#tm-show-tool-control');
    const showToolButton = get('#tm-show-tool-button');
    const showHideButton = get('#tm-show-hide-button');
    const panelHideToolButton = get('#tm-panel-hide-button');
    const panelScrollHideButton = get('#tm-panel-scroll-hide-button');
    const panel = get('#tm-api-console-panel');
    const panelHeading = root.querySelector('.tm-panel-heading');
    const panelResizeHandles = Array.from(root.querySelectorAll('[data-panel-resize]'));
    const panelSettingsButton = get('#tm-panel-settings-button');
    const panelSettingsPopup = get('#tm-panel-settings-popup');
    const panelSettingsWrap = panelSettingsButton.closest('.tm-panel-settings-wrap');
    const panelSettingsHint = panelSettingsPopup.querySelector('.tm-hint');
    const panelOpacityInput = get('#tm-panel-opacity');
    const panelOpacityValue = get('#tm-panel-opacity-value');
    const requestTimeoutInput = get('#tm-request-timeout');
    const curlTargetInput = get('#tm-curl-target');
    const debugRecordLimitInput = get('#tm-debug-record-limit');
    const debugPersistenceInput = get('#tm-debug-persist');
    curlTargetInput.replaceChildren(
      new Option('macOS / Linux', 'posix'),
      new Option('PowerShell', 'powershell')
    );
    const panelResetLayoutButton = get('#tm-panel-reset-layout');
    const moduleTabs = Array.from(root.querySelectorAll('[data-module-tab]'));
    const requestModule = get('#tm-request-module');
    const rewriteModule = get('#tm-rewrite-module');
    const method = get('#tm-method');
    const urlModeInputs = Array.from(root.querySelectorAll('input[name="tm-url-mode"]'));
    const capturedEditBanner = get('#tm-captured-edit-banner');
    const restoreCapturedDraftButton = get('#tm-restore-captured-draft');
    const url = get('#tm-url');
    const params = get('#tm-params');
    const headers = get('#tm-headers');
    const bodySection = get('#tm-body-section');
    const bodyTypeHint = get('#tm-body-type-hint');
    const jsonBodyWrap = get('#tm-json-body-wrap');
    const jsonBody = get('#tm-json-body');
    const bodyFieldsWrap = get('#tm-body-fields-wrap');
    const bodyFields = get('#tm-body-fields');
    const addBodyFieldButton = get('#tm-add-body-field');
    const rawBody = get('#tm-raw-body');
    const batchFields = get('#tm-batch-fields');
    const addBatchFieldButton = get('#tm-add-batch-field');
    const batchExecutionMode = get('#tm-batch-execution');
    const batchIntervalInput = get('#tm-batch-interval');
    const batchSuccessMode = get('#tm-batch-success-mode');
    const batchSuccessCondition = get('#tm-batch-success-condition');
    const batchSuccessPath = get('#tm-batch-success-path');
    const batchSuccessOperator = get('#tm-batch-success-operator');
    const batchSuccessExpectedWrap = get('#tm-batch-success-expected-wrap');
    const batchSuccessExpected = get('#tm-batch-success-expected');
    const batchSendButton = get('#tm-batch-send');
    const responseGrid = get('#tm-response-grid');
    const responseMode = get('#tm-response-mode');
    const downloadNameWrap = get('#tm-download-name-wrap');
    const downloadFileName = get('#tm-download-name');
    const ruleList = get('#tm-rule-list');
    const emptyRules = get('#tm-empty-rules');
    const rewriteStatus = get('#tm-rewrite-status');
    const debugEnabledInput = get('#tm-debug-enabled');
    const debugState = get('#tm-debug-state');
    const clearDebugButton = get('#tm-clear-debug');
    const debugFilterInput = get('#tm-debug-filter');
    const debugFilterCount = get('#tm-debug-filter-count');
    const debugEmpty = get('#tm-debug-empty');
    const debugEmptyText = debugEmpty.textContent;
    const debugLog = get('#tm-debug-log');
    const debugStepCapture = get('#tm-debug-step-capture');
    const debugStepMatch = get('#tm-debug-step-match');
    const debugStepRewrite = get('#tm-debug-step-rewrite');
    const debugStepResponse = get('#tm-debug-step-response');
    const resultSection = get('#tm-result-section');
    const output = get('#tm-api-console-output');
    const debugRecords = [];
    const debugRecordById = new Map();
    let activeCurlMenu = null;
    let curlMenuSequence = 0;
    let lastRecordedPageAddress = '';

    const loadButtonState = () => {
      try {
        const saved = JSON.parse(localStorage.getItem(BUTTON_STATE_KEY) || '{}');
        const left = Number(saved.left);
        const top = Number(saved.top);
        return {
          left: Number.isFinite(left) ? left : null,
          top: Number.isFinite(top) ? top : null,
          mainPositioned: saved.mainPositioned === true,
          hidden: saved.hidden === true,
          showLeft: Number.isFinite(Number(saved.showLeft)) ? Number(saved.showLeft) : null,
          showTop: Number.isFinite(Number(saved.showTop)) ? Number(saved.showTop) : null,
          showPositioned: saved.showPositioned === true,
          showHidden: saved.showHidden === true
        };
      } catch {
        return {
          left: null,
          top: null,
          mainPositioned: false,
          hidden: false,
          showLeft: null,
          showTop: null,
          showPositioned: false,
          showHidden: false
        };
      }
    };

    const loadPanelLayout = () => {
      try {
        const saved = JSON.parse(localStorage.getItem(PANEL_LAYOUT_KEY) || '{}');
        const left = Number(saved.left);
        const top = Number(saved.top);
        const width = Number(saved.width);
        const height = Number(saved.height);
        return {
          left: Number.isFinite(left) ? left : null,
          top: Number.isFinite(top) ? top : null,
          width: Number.isFinite(width) ? width : null,
          height: Number.isFinite(height) ? height : null,
          positioned: saved.positioned === true
        };
      } catch {
        return { left: null, top: null, width: null, height: null, positioned: false };
      }
    };

    const buttonState = loadButtonState();
    const panelLayout = loadPanelLayout();
    let dragState = null;
    let draggedButton = null;
    let panelDragState = null;
    let panelResizeState = null;

    const PANEL_EDGE_GAP = 10;
    const clamp = (value, minimum, maximum) => Math.min(Math.max(value, minimum), maximum);
    const panelSettings = loadPanelSettings();

    const savePanelLayout = () => {
      localStorage.setItem(PANEL_LAYOUT_KEY, JSON.stringify(panelLayout));
    };

    const applyPanelLayout = () => {
      const minimumWidth = Math.min(360, Math.max(220, window.innerWidth - 20));
      const minimumHeight = Math.min(280, Math.max(180, window.innerHeight - 20));
      const maximumWidth = Math.max(minimumWidth, window.innerWidth - PANEL_EDGE_GAP * 2);
      const maximumHeight = Math.max(minimumHeight, window.innerHeight - PANEL_EDGE_GAP * 2);
      const width = Number.isFinite(panelLayout.width)
        ? clamp(panelLayout.width, minimumWidth, maximumWidth)
        : null;
      const height = Number.isFinite(panelLayout.height)
        ? clamp(panelLayout.height, minimumHeight, maximumHeight)
        : null;

      if (width !== null) {
        panelLayout.width = Math.round(width);
        panel.style.width = `${panelLayout.width}px`;
      }
      if (height !== null) {
        panelLayout.height = Math.round(height);
        panel.style.height = `${panelLayout.height}px`;
      }

      if (panelLayout.positioned && Number.isFinite(panelLayout.left) && Number.isFinite(panelLayout.top)) {
        const effectiveWidth = width || panel.offsetWidth || Math.min(820, maximumWidth);
        const effectiveHeight = height || panel.offsetHeight || Math.min(760, maximumHeight);
        const maximumLeft = Math.max(PANEL_EDGE_GAP, window.innerWidth - effectiveWidth - PANEL_EDGE_GAP);
        const maximumTop = Math.max(PANEL_EDGE_GAP, window.innerHeight - effectiveHeight - PANEL_EDGE_GAP);
        panelLayout.left = Math.round(clamp(panelLayout.left, PANEL_EDGE_GAP, maximumLeft));
        panelLayout.top = Math.round(clamp(panelLayout.top, PANEL_EDGE_GAP, maximumTop));
        panel.style.left = `${panelLayout.left}px`;
        panel.style.top = `${panelLayout.top}px`;
        panel.style.right = 'auto';
        panel.style.bottom = 'auto';
      }
    };

    const updatePanelScrollHideButton = () => {
      if (!panel.classList.contains('open')) {
        panelScrollHideButton.hidden = true;
        return;
      }

      const panelRect = panel.getBoundingClientRect();
      const standardButtonBottom = panelHideToolButton.offsetTop + panelHideToolButton.offsetHeight;
      const standardButtonFullyHidden = panel.scrollTop >= standardButtonBottom;

      panelScrollHideButton.hidden = !standardButtonFullyHidden;
      if (standardButtonFullyHidden) {
        panelScrollHideButton.style.top = `${Math.round(panelRect.top + 5)}px`;
        panelScrollHideButton.style.left = `${Math.round(panelRect.right - 27)}px`;
      }
    };

    const resetPanelLayout = () => {
      Object.assign(panelLayout, { left: null, top: null, width: null, height: null, positioned: false });
      panel.style.removeProperty('left');
      panel.style.removeProperty('top');
      panel.style.removeProperty('width');
      panel.style.removeProperty('height');
      panel.style.removeProperty('right');
      panel.style.removeProperty('bottom');
      savePanelLayout();
    };

    panelSettingsHint.textContent = '不透明度只影响外层背景;开启保留后会在当前网站保存最近 50 条记录,URL 参数和常见授权请求头会脱敏。';

    const applyPanelOpacity = (value) => {
      const numericOpacity = Number(value);
      const opacity = Math.round(clamp(Number.isFinite(numericOpacity) ? numericOpacity : 96, 0, 100));
      const panelAlpha = opacity / 100;
      panelSettings.opacity = opacity;
      panelOpacityInput.value = String(opacity);
      panelOpacityValue.textContent = `${opacity}%`;
      panel.style.setProperty('--tm-panel-alpha', panelAlpha.toFixed(2));
      const edgeAlpha = panelAlpha * 0.45;
      panel.style.setProperty('--tm-surface-alpha', '0');
      panel.style.setProperty('--tm-deep-alpha', '0');
      panel.style.setProperty('--tm-control-alpha', '0');
      panel.style.setProperty('--tm-edge-alpha', edgeAlpha.toFixed(2));
      panel.style.setProperty('--tm-panel-blur', `${(panelAlpha * 2).toFixed(2)}px`);
      panel.style.setProperty('--tm-shadow-alpha', (panelAlpha * 0.35).toFixed(2));
      panel.style.setProperty('--tm-control-border', `rgb(71 85 105 / ${edgeAlpha.toFixed(2)})`);
      panel.style.setProperty('--tm-surface-border', `rgb(45 64 92 / ${edgeAlpha.toFixed(2)})`);
      panel.classList.toggle('tm-panel-low-opacity', opacity < 35);
      panel.classList.toggle('tm-panel-zero-opacity', opacity <= 20);
    };

    const applyRequestTimeout = (value) => {
      const numericTimeout = Number(value);
      const timeoutSeconds = Number.isFinite(numericTimeout)
        ? Math.round(clamp(numericTimeout, 0, 600))
        : 30;
      panelSettings.timeoutSeconds = timeoutSeconds;
      requestTimeoutInput.value = String(timeoutSeconds);
    };

    const applyCurlTarget = (value) => {
      const target = value === 'powershell' ? 'powershell' : 'posix';
      panelSettings.curlTarget = target;
      curlTargetInput.value = target;
    };

    const applyDebugRecordLimit = (value) => {
      const limit = normalizeDebugRecordLimit(value);
      panelSettings.debugRecordLimit = limit;
      debugRecordLimitInput.value = String(limit);
    };

    const applyDebugRecordPersistence = (enabled) => {
      const persistsRecords = enabled === true;
      panelSettings.persistDebugRecords = persistsRecords;
      debugPersistenceInput.checked = persistsRecords;
      if (!persistsRecords) {
        localStorage.removeItem(DEBUG_RECORDS_KEY);
      }
    };

    const saveButtonState = () => {
      localStorage.setItem(BUTTON_STATE_KEY, JSON.stringify(buttonState));
    };

    applyPanelOpacity(panelSettings.opacity);
    applyRequestTimeout(panelSettings.timeoutSeconds);
    applyCurlTarget(panelSettings.curlTarget);
    applyDebugRecordLimit(panelSettings.debugRecordLimit);
    applyDebugRecordPersistence(panelSettings.persistDebugRecords);
    applyPanelLayout();

    const setControlPosition = (control, left, top, leftKey, topKey, positionedKey) => {
      const maximumLeft = Math.max(0, window.innerWidth - control.offsetWidth);
      const maximumTop = Math.max(0, window.innerHeight - control.offsetHeight);
      buttonState[leftKey] = Math.round(clamp(left, 0, maximumLeft));
      buttonState[topKey] = Math.round(clamp(top, 0, maximumTop));
      buttonState[positionedKey] = true;
      control.style.left = `${buttonState[leftKey]}px`;
      control.style.top = `${buttonState[topKey]}px`;
      control.style.right = 'auto';
      control.style.bottom = 'auto';
    };

    const setButtonPosition = (left, top) => setControlPosition(
      floatingControl, left, top, 'left', 'top', 'mainPositioned'
    );
    const setShowToolPosition = (left, top) => setControlPosition(
      showToolControl, left, top, 'showLeft', 'showTop', 'showPositioned'
    );

    const resetControlPosition = (control, right, bottom) => {
      control.style.left = 'auto';
      control.style.top = 'auto';
      control.style.right = `${right}px`;
      control.style.bottom = `${bottom}px`;
    };

    const applyButtonState = () => {
      floatingControl.hidden = buttonState.hidden;
      showToolControl.hidden = !buttonState.hidden || buttonState.showHidden;

      if (!buttonState.hidden && buttonState.mainPositioned
        && buttonState.left !== null && buttonState.top !== null) {
        setButtonPosition(buttonState.left, buttonState.top);
      } else {
        resetControlPosition(floatingControl, 0, 20);
      }

      if (buttonState.hidden && !buttonState.showHidden && buttonState.showPositioned
        && buttonState.showLeft !== null && buttonState.showTop !== null) {
        setShowToolPosition(buttonState.showLeft, buttonState.showTop);
      } else {
        resetControlPosition(showToolControl, 0, 20);
      }
    };

    applyButtonState();

    const readRows = (container) => Array.from(container.querySelectorAll('.tm-kv-row'))
      .map((row) => {
        const keyElement = row.querySelector('[data-key]');
        const customKeyElement = row.querySelector('[data-custom-key]');
        const key = customKeyElement && keyElement.value === 'custom'
          ? customKeyElement.value
          : keyElement.value;
        const valueElements = Array.from(row.querySelectorAll('[data-value]'));
        const valueElement = valueElements.find((element) => !element.hidden) || valueElements[0];
        const customValueElement = row.querySelector('[data-custom-value]');
        const value = customValueElement && !customValueElement.hidden && valueElement.value === 'custom'
          ? customValueElement.value
          : valueElement.value;

        return {
          key,
          value
        };
      })
      .filter((row) => row.key.trim() || row.value.trim());

    const configureRowRemoveButton = (button, label) => {
      button.className = 'icon tm-row-remove';
      button.type = 'button';
      button.title = label;
      button.setAttribute('aria-label', label);
      button.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"></path></svg>';
    };

    const addRow = (container, initialRow, placeholders) => {
      const row = document.createElement('div');
      const keyInput = document.createElement('input');
      const valueInput = document.createElement('input');
      const removeButton = document.createElement('button');

      row.className = 'tm-kv-row';
      keyInput.dataset.key = '';
      keyInput.placeholder = placeholders.key;
      keyInput.value = initialRow.key || '';
      valueInput.dataset.value = '';
      valueInput.placeholder = placeholders.value;
      valueInput.value = initialRow.value || '';
      configureRowRemoveButton(removeButton, '删除此行');
      removeButton.addEventListener('click', () => {
        row.remove();
        if (!container.children.length) {
          addRow(container, {}, placeholders);
        }
      });
      row.append(keyInput, valueInput, removeButton);
      container.appendChild(row);
    };

    const renderRows = (container, rows, placeholders) => {
      container.replaceChildren();
      (rows.length ? rows : [{}]).forEach((row) => addRow(container, row, placeholders));
    };

    const addBatchField = (initialField = {}) => {
      const row = document.createElement('div');
      const nameCell = document.createElement('label');
      const valuesCell = document.createElement('label');
      const nameCaption = document.createElement('span');
      const valuesCaption = document.createElement('span');
      const tokenHint = document.createElement('code');
      const nameInput = document.createElement('input');
      const valuesInput = document.createElement('textarea');
      const removeButton = document.createElement('button');

      row.className = 'tm-batch-field-row';
      nameCell.className = 'tm-batch-field-cell';
      valuesCell.className = 'tm-batch-field-cell';
      nameCaption.className = 'tm-batch-field-caption';
      valuesCaption.className = 'tm-batch-field-caption';
      nameCaption.textContent = '变量名称';
      valuesCaption.textContent = '变量值';
      tokenHint.className = 'tm-batch-token-hint';
      tokenHint.textContent = '{{变量名}}';
      nameInput.dataset.batchFieldName = '';
      nameInput.placeholder = '例如 userId';
      nameInput.value = String(initialField.name || '').replace(/[{}]/g, '');
      valuesInput.dataset.batchFieldValues = '';
      valuesInput.spellcheck = false;
      valuesInput.placeholder = '每行一个值,也可用逗号或分号分隔\n1001, 1002; 1003';
      valuesInput.value = initialField.values || '';
      configureRowRemoveButton(removeButton, '删除此批量变量');
      const updateTokenHint = () => {
        const name = nameInput.value.trim().replace(/[{}]/g, '');
        tokenHint.textContent = name ? `{{${name}}}` : '{{变量名}}';
      };
      removeButton.addEventListener('click', () => {
        row.remove();
        if (!batchFields.children.length) {
          addBatchField();
        }
      });
      nameInput.addEventListener('input', updateTokenHint);
      nameCaption.appendChild(tokenHint);
      nameCell.append(nameCaption, nameInput);
      valuesCell.append(valuesCaption, valuesInput);
      row.append(nameCell, valuesCell, removeButton);
      batchFields.appendChild(row);
      updateTokenHint();
    };

    const renderBatchFields = (fields) => {
      batchFields.replaceChildren();
      (fields.length ? fields : [{}]).forEach(addBatchField);
    };

    const readBatchFields = () => Array.from(batchFields.querySelectorAll('.tm-batch-field-row'))
      .map((row) => ({
        name: row.querySelector('[data-batch-field-name]').value.trim().replace(/[{}]/g, ''),
        values: row.querySelector('[data-batch-field-values]').value
      }))
      .filter((field) => field.name || field.values.trim());

    const getCurrentBodyType = () => bodyTypeForContentType(getSelectedContentType());

    const updateBodyFieldType = (row) => {
      const typeSelect = row.querySelector('[data-body-field-type]');
      const valueInput = row.querySelector('[data-body-field-value]');
      const fileInput = row.querySelector('[data-body-field-files]');
      const fileOption = typeSelect.querySelector('option[value="file"]');
      const isMultipart = getCurrentBodyType() === 'multipart';

      fileOption.disabled = !isMultipart;
      if (!isMultipart && typeSelect.value === 'file') {
        typeSelect.value = 'text';
      }

      const isFile = typeSelect.value === 'file';
      valueInput.hidden = isFile;
      fileInput.hidden = !isFile;
      fileInput.disabled = !isFile;
      valueInput.type = typeSelect.value === 'number' ? 'number' : 'text';
      valueInput.placeholder = typeSelect.value === 'number' ? '数值,例如 1' : '字段值,例如 demo';
    };

    const addBodyField = (initialField = {}) => {
      const row = document.createElement('div');
      const keyInput = document.createElement('input');
      const typeSelect = document.createElement('select');
      const valueInput = document.createElement('input');
      const fileInput = document.createElement('input');
      const removeButton = document.createElement('button');

      row.className = 'tm-body-field-row';
      keyInput.dataset.bodyFieldKey = '';
      keyInput.placeholder = '字段名,例如 amount';
      keyInput.value = initialField.key || '';
      typeSelect.dataset.bodyFieldType = '';
      typeSelect.innerHTML = '<option value="text">文本</option><option value="number">数值</option><option value="file">附件</option>';
      typeSelect.value = ['text', 'number', 'file'].includes(initialField.type) ? initialField.type : 'text';
      valueInput.dataset.bodyFieldValue = '';
      valueInput.value = initialField.value || '';
      fileInput.dataset.bodyFieldFiles = '';
      fileInput.type = 'file';
      fileInput.multiple = true;
      configureRowRemoveButton(removeButton, '删除此字段');
      removeButton.addEventListener('click', () => {
        row.remove();
        if (!bodyFields.children.length) {
          addBodyField();
        }
      });
      typeSelect.addEventListener('change', () => updateBodyFieldType(row));
      row.append(keyInput, typeSelect, valueInput, fileInput, removeButton);
      bodyFields.appendChild(row);
      updateBodyFieldType(row);
    };

    const renderBodyFields = (fields) => {
      bodyFields.replaceChildren();
      (fields.length ? fields : [{}]).forEach((field) => addBodyField(field));
    };

    const readBodyFields = (includeFiles = false) => Array.from(bodyFields.querySelectorAll('.tm-body-field-row'))
      .map((row) => ({
        key: row.querySelector('[data-body-field-key]').value.trim(),
        type: row.querySelector('[data-body-field-type]').value,
        value: row.querySelector('[data-body-field-value]').value,
        files: includeFiles
          ? Array.from(row.querySelector('[data-body-field-files]').files || [])
          : []
      }))
      .filter((field) => field.key);

    const addHeaderRow = (container, initialRow = {}) => {
      const row = document.createElement('div');
      const headerSelect = document.createElement('select');
      const customHeaderInput = document.createElement('input');
      const valueInput = document.createElement('input');
      const contentTypeSelect = document.createElement('select');
      const customContentTypeInput = document.createElement('input');
      const removeButton = document.createElement('button');
      const selectedHeader = COMMON_HEADERS.includes(initialRow.key) ? initialRow.key : 'custom';

      row.className = 'tm-kv-row tm-header-row';
      headerSelect.dataset.key = '';
      headerSelect.innerHTML = `<option value="custom">自定义 Header</option>${COMMON_HEADERS.map((name) => `<option value="${name}">${name}</option>`).join('')}`;
      headerSelect.value = selectedHeader;
      customHeaderInput.dataset.customKey = '';
      customHeaderInput.placeholder = 'Header 名,例如 X-Tenant-ID';
      customHeaderInput.value = selectedHeader === 'custom' ? (initialRow.key || '') : '';
      valueInput.dataset.value = 'plain';
      valueInput.placeholder = 'Header 值';
      valueInput.value = initialRow.value || '';
      contentTypeSelect.dataset.value = 'content-type';
      contentTypeSelect.innerHTML = `<option value="application/json">application/json</option><option value="application/x-www-form-urlencoded;charset=UTF-8">application/x-www-form-urlencoded</option><option value="multipart/form-data">multipart/form-data</option><option value="text/plain;charset=UTF-8">text/plain</option><option value="custom">自定义 Content-Type</option>`;
      customContentTypeInput.dataset.customValue = '';
      customContentTypeInput.placeholder = '例如 application/vnd.api+json';
      configureRowRemoveButton(removeButton, '删除此行');

      const getCurrentValue = () => {
        if (headerSelect.value !== 'Content-Type') {
          return valueInput.value;
        }
        return contentTypeSelect.value === 'custom'
          ? customContentTypeInput.value
          : contentTypeSelect.value;
      };

      const updateHeaderRow = (preferredValue) => {
        const isContentType = headerSelect.value === 'Content-Type';
        const isCustomHeader = headerSelect.value === 'custom';
        const contentTypeValue = preferredValue || 'application/json';
        const knownContentType = CONTENT_TYPES.includes(contentTypeValue)
          ? contentTypeValue
          : 'custom';

        row.classList.toggle('has-custom-header', isCustomHeader);
        row.classList.toggle('is-content-type', isContentType);
        row.classList.toggle('has-custom-content-type', isContentType && knownContentType === 'custom');
        customHeaderInput.hidden = !isCustomHeader;
        valueInput.hidden = isContentType;
        contentTypeSelect.hidden = !isContentType;
        customContentTypeInput.hidden = !isContentType || knownContentType !== 'custom';

        if (isContentType) {
          contentTypeSelect.value = knownContentType;
          customContentTypeInput.value = knownContentType === 'custom' ? contentTypeValue : '';
        }
      };

      updateHeaderRow(initialRow.value);
      headerSelect.addEventListener('change', () => {
        const previousValue = getCurrentValue();
        updateHeaderRow(headerSelect.value === 'Content-Type' && !CONTENT_TYPES.includes(previousValue)
          ? 'application/json'
          : previousValue);
        if (headerSelect.value === 'custom') {
          customHeaderInput.focus();
        }
      });
      contentTypeSelect.addEventListener('change', () => {
        const isCustomContentType = contentTypeSelect.value === 'custom';
        row.classList.toggle('has-custom-content-type', isCustomContentType);
        customContentTypeInput.hidden = !isCustomContentType;
        if (isCustomContentType) {
          customContentTypeInput.focus();
        }
      });
      removeButton.addEventListener('click', () => {
        row.remove();
        if (!container.children.length) {
          addHeaderRow(container);
        }
      });
      row.append(headerSelect, customHeaderInput, valueInput, contentTypeSelect, customContentTypeInput, removeButton);
      container.appendChild(row);
    };

    const renderHeaderRows = (container, rows) => {
      container.replaceChildren();
      (rows.length ? rows : [{}]).forEach((row) => addHeaderRow(container, row));
    };

    const activateModule = (moduleName) => {
      const showRequestModule = moduleName !== 'rewrite';
      requestModule.hidden = !showRequestModule;
      rewriteModule.hidden = showRequestModule;
      moduleTabs.forEach((tab) => {
        const isActive = tab.dataset.moduleTab === (showRequestModule ? 'request' : 'rewrite');
        tab.classList.toggle('is-active', isActive);
        tab.setAttribute('aria-selected', String(isActive));
      });
    };

    const displayRewriteStatus = (message, isError = false) => {
      rewriteStatus.textContent = message;
      rewriteStatus.hidden = false;
      rewriteStatus.classList.toggle('is-error', isError);
    };

    const display = (message, revealResult = false) => {
      output.textContent = typeof message === 'string' ? message : JSON.stringify(message, null, 2);
      if (revealResult) {
        activateModule('request');
        resultSection.open = true;
        resultSection.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
      }
    };

    const redactDebugUrl = (value) => {
      try {
        const parsedUrl = new URL(String(value));
        parsedUrl.searchParams.forEach((parameterValue, parameterName) => {
          if (/(token|secret|password|authorization|api[-_]?key)/i.test(parameterName)) {
            parsedUrl.searchParams.set(parameterName, '***');
          }
        });
        return parsedUrl.href;
      } catch {
        return String(value || '');
      }
    };

    const formatDebugTime = (timestamp) => {
      if (!timestamp) {
        return '';
      }
      return new Date(timestamp).toLocaleTimeString('zh-CN', {
        hour12: false,
        hour: '2-digit',
        minute: '2-digit',
        second: '2-digit'
      });
    };

    const debugValuesDiffer = (firstValue, secondValue) => {
      try {
        return JSON.stringify(firstValue) !== JSON.stringify(secondValue);
      } catch {
        return firstValue !== secondValue;
      }
    };

    const formatDebugPayloadValue = (value, emptyText = '(无)') => {
      if (value === undefined) {
        return '等待捕获…';
      }
      if (value === null || value === '') {
        return emptyText;
      }

      let payload = value;
      let payloadType = '';
      let isTruncated = false;
      if (value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'type')
        && Object.prototype.hasOwnProperty.call(value, 'value')) {
        payload = value.value;
        payloadType = value.type;
        isTruncated = value.truncated === true;
      }

      let text;
      if (typeof payload === 'string') {
        text = payload || emptyText;
      } else {
        try {
          text = JSON.stringify(payload, null, 2);
        } catch {
          text = String(payload);
        }
      }

      const typePrefix = {
        form: 'application/x-www-form-urlencoded',
        multipart: 'multipart/form-data',
        binary: '二进制内容',
        unavailable: '无法读取'
      }[payloadType];
      if (typePrefix) {
        text = `${typePrefix}\n${text}`;
      }
      return isTruncated ? `${text}\n\n[内容超过 60,000 个字符,已截断]` : text;
    };

    const copyText = async (text) => {
      try {
        await navigator.clipboard.writeText(text);
        return true;
      } catch {
        const temporaryInput = document.createElement('textarea');
        temporaryInput.value = text;
        temporaryInput.style.position = 'fixed';
        temporaryInput.style.opacity = '0';
        document.body.appendChild(temporaryInput);
        temporaryInput.select();
        const copied = document.execCommand('copy');
        temporaryInput.remove();
        return copied;
      }
    };

    const shellQuote = (value) => `'${String(value).replace(/'/g, `'"'"'`)}'`;
    const powerShellQuote = (value) => `'${String(value).replace(/'/g, "''")}'`;
    const getPreferredCurlTarget = () => panelSettings.curlTarget;

    const getCapturedRequestExportData = (record) => {
      const requestUrl = Object.prototype.hasOwnProperty.call(record, 'finalUrl') && record.finalUrl !== undefined
        ? record.finalUrl
        : record.originalUrl;
      const requestHeaders = Object.prototype.hasOwnProperty.call(record, 'finalRequestHeaders')
        && record.finalRequestHeaders !== undefined
        ? record.finalRequestHeaders
        : record.requestHeaders;
      const requestBody = Object.prototype.hasOwnProperty.call(record, 'finalRequestBody')
        && record.finalRequestBody !== undefined
        ? record.finalRequestBody
        : record.requestBody;
      const method = String(record.method || record.originalMethod || 'GET').toUpperCase();
      const headerEntries = Object.entries(requestHeaders && typeof requestHeaders === 'object' ? requestHeaders : {})
        .flatMap(([name, value]) => (Array.isArray(value) ? value : [value])
          .map((entryValue) => [String(name), String(entryValue)]));
      return {
        url: String(requestUrl || ''),
        method,
        headerEntries,
        body: ['GET', 'HEAD'].includes(method) ? null : requestBody
      };
    };

    const capturedBodyEntries = (value) => Object.entries(value && typeof value === 'object' ? value : {})
      .flatMap(([key, entryValue]) => (Array.isArray(entryValue) ? entryValue : [entryValue])
        .map((item) => [String(key), item]));

    const isCapturedFileValue = (value) => value && typeof value === 'object' && value.type === 'file';

    const getCapturedBodyExport = (body) => {
      if (body === undefined) {
        return {
          kind: 'unavailable',
          message: '请求体仍在捕获中,请稍后再复制。'
        };
      }
      if (!body || typeof body !== 'object' || !Object.prototype.hasOwnProperty.call(body, 'type')) {
        return { kind: 'none', text: '' };
      }

      if (body.truncated === true) {
        return {
          kind: 'unavailable',
          message: '请求体超过 60,000 个字符,监听记录已截断,无法完整导出。'
        };
      }

      if (body.type === 'json') {
        let text = typeof body.raw === 'string' ? body.raw : '';
        if (!text) {
          try {
            text = JSON.stringify(body.value, null, 2);
          } catch {
            text = String(body.value ?? '');
          }
        }
        return { kind: 'text', text };
      }

      if (body.type === 'text') {
        return { kind: 'text', text: String(body.value ?? '') };
      }

      if (body.type === 'form') {
        return { kind: 'form', entries: capturedBodyEntries(body.value) };
      }

      if (body.type === 'multipart') {
        return { kind: 'multipart', entries: capturedBodyEntries(body.value) };
      }

      return {
        kind: 'unavailable',
        message: body.type === 'binary'
          ? '请求体为二进制内容,无法从监听记录还原。'
          : '请求体无法读取,无法从监听记录还原。'
      };
    };

    const getExportHeaders = (headerEntries, bodyExport) => {
      if (bodyExport.kind !== 'multipart') {
        return headerEntries;
      }
      return headerEntries.filter(([name, value]) => !(
        name.toLowerCase() === 'content-type' && /multipart\/form-data/i.test(value)
      ));
    };

    const createCurlRequest = (record, target = 'posix') => {
      const request = getCapturedRequestExportData(record);
      const bodyExport = getCapturedBodyExport(request.body);
      const isPowerShell = target === 'powershell';
      const quote = isPowerShell ? powerShellQuote : shellQuote;
      const command = 'curl';
      const lineBreak = isPowerShell ? ' `\n' : ' \\\n';
      const commentPrefix = '# ';
      const lines = [];
      const comments = [];
      const parts = [`${command} --request ${request.method}`, `--url ${quote(request.url)}`];
      getExportHeaders(request.headerEntries, bodyExport).forEach(([name, value]) => {
        parts.push(`--header ${quote(`${name}: ${value}`)}`);
      });

      if (bodyExport.kind === 'text') {
        parts.push(`--data-raw ${quote(bodyExport.text)}`);
      } else if (bodyExport.kind === 'form') {
        bodyExport.entries.forEach(([key, value]) => {
          parts.push(`--data-urlencode ${quote(`${key}=${String(value)}`)}`);
        });
      } else if (bodyExport.kind === 'multipart') {
        bodyExport.entries.forEach(([key, value]) => {
          if (isCapturedFileValue(value)) {
            comments.push(`文件字段 ${JSON.stringify(key)}(${value.name || '未命名文件'})需自行替换本地路径。`);
            const filePath = isPowerShell
              ? `C:\\path\\to\\${value.name || 'file'}`
              : `/path/to/${value.name || 'file'}`;
            parts.push(`--form ${quote(`${key}=@${filePath}`)}`);
          } else {
            parts.push(`--form ${quote(`${key}=${String(value)}`)}`);
          }
        });
      } else if (bodyExport.kind === 'unavailable') {
        comments.push(bodyExport.message);
      }

      if (bodyExport.kind === 'multipart' && request.headerEntries.length !== getExportHeaders(request.headerEntries, bodyExport).length) {
        comments.push('已省略 multipart 的 Content-Type,让 cURL 自动补充 boundary。');
      }
      comments.forEach((comment) => lines.push(`${commentPrefix}${comment}`));
      lines.push(parts.map((part, index) => `${index ? '  ' : ''}${part}`).join(lineBreak));
      return lines.join('\n');
    };

    const createJavaScriptBody = (bodyExport) => {
      if (bodyExport.kind === 'none' || bodyExport.kind === 'unavailable') {
        return { prelude: bodyExport.kind === 'unavailable' ? [`// ${bodyExport.message}`] : [], expression: null };
      }
      if (bodyExport.kind === 'text') {
        return { prelude: [], expression: JSON.stringify(bodyExport.text) };
      }

      const constructorName = bodyExport.kind === 'multipart' ? 'FormData' : 'URLSearchParams';
      const variableName = 'requestBody';
      const prelude = [`const ${variableName} = new ${constructorName}();`];
      bodyExport.entries.forEach(([key, value]) => {
        if (isCapturedFileValue(value)) {
          prelude.push(`// TODO: 为文件字段 ${JSON.stringify(key)} 选择 ${JSON.stringify(value.name || 'file')}。`);
          prelude.push(`// ${variableName}.append(${JSON.stringify(key)}, selectedFile);`);
        } else {
          prelude.push(`${variableName}.append(${JSON.stringify(key)}, ${JSON.stringify(String(value))});`);
        }
      });
      return { prelude, expression: variableName };
    };

    const createFetchRequest = (record) => {
      const request = getCapturedRequestExportData(record);
      const bodyExport = getCapturedBodyExport(request.body);
      const body = createJavaScriptBody(bodyExport);
      const headerEntries = getExportHeaders(request.headerEntries, bodyExport);
      const options = [`method: ${JSON.stringify(request.method)}`];
      if (headerEntries.length) {
        options.push(`headers: ${JSON.stringify(headerEntries, null, 2).replace(/\n/g, '\n  ')}`);
      }
      if (body.expression) {
        options.push(`body: ${body.expression}`);
      }
      return [
        ...body.prelude,
        `fetch(${JSON.stringify(request.url)}, {`,
        ...options.map((option, index) => `  ${option}${index === options.length - 1 ? '' : ','}`),
        '})',
        '  .then(async (response) => {',
        '    const text = await response.text();',
        '    console.log(response.status, text);',
        '  });'
      ].join('\n');
    };

    const createXhrRequest = (record) => {
      const request = getCapturedRequestExportData(record);
      const bodyExport = getCapturedBodyExport(request.body);
      const body = createJavaScriptBody(bodyExport);
      const headerEntries = getExportHeaders(request.headerEntries, bodyExport);
      return [
        ...body.prelude,
        'const xhr = new XMLHttpRequest();',
        `xhr.open(${JSON.stringify(request.method)}, ${JSON.stringify(request.url)}, true);`,
        ...headerEntries.map(([name, value]) => `xhr.setRequestHeader(${JSON.stringify(name)}, ${JSON.stringify(value)});`),
        'xhr.addEventListener("load", () => {',
        '  console.log(xhr.status, xhr.responseText);',
        '});',
        'xhr.addEventListener("error", () => console.error("Request failed"));',
        `xhr.send(${body.expression || 'null'});`
      ].join('\n');
    };

    const createDebugRequestExport = (record) => {
      const exportBar = document.createElement('div');
      const label = document.createElement('span');
      const createCopyButton = (name, createRequest, title = '') => {
        const exportButton = document.createElement('button');
        exportButton.className = 'tm-debug-export-button';
        exportButton.type = 'button';
        exportButton.textContent = name;
        exportButton.title = title || `复制为 ${name}`;
        exportButton.addEventListener('click', async () => {
          const copied = await copyText(createRequest());
          exportButton.textContent = copied ? '已复制' : '复制失败';
          window.setTimeout(() => { exportButton.textContent = name; }, 1200);
        });
        return exportButton;
      };
      exportBar.className = 'tm-debug-request-export';
      label.className = 'tm-debug-request-export-label';
      label.textContent = '复制请求';
      exportBar.appendChild(label);
      const curlExport = document.createElement('div');
      const curlMenuButton = document.createElement('button');
      const curlMenu = document.createElement('div');
      curlExport.className = 'tm-debug-curl-export';
      curlMenuButton.className = 'tm-debug-curl-menu-button';
      curlMenuButton.type = 'button';
      curlMenuButton.title = '选择 curl 命令格式';
      curlMenuButton.setAttribute('aria-label', '选择 curl 命令格式');
      curlMenuButton.setAttribute('aria-haspopup', 'menu');
      curlMenuButton.setAttribute('aria-expanded', 'false');
      curlMenuButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m7 10 5 5 5-5"></path></svg>';
      curlMenu.className = 'tm-debug-curl-menu';
      curlMenu.setAttribute('role', 'menu');
      curlMenu.id = `tm-debug-curl-menu-${++curlMenuSequence}`;
      curlMenuButton.setAttribute('aria-controls', curlMenu.id);
      const updateCurlMenuSelection = () => {
        curlMenu.querySelectorAll('.tm-debug-curl-menu-item').forEach((menuItem) => {
          menuItem.classList.toggle('is-selected', menuItem.dataset.curlTarget === getPreferredCurlTarget());
        });
      };
      const positionCurlMenu = () => {
        const buttonRect = curlMenuButton.getBoundingClientRect();
        const menuRect = curlMenu.getBoundingClientRect();
        const viewportPadding = 8;
        const left = Math.min(
          Math.max(viewportPadding, buttonRect.right - menuRect.width),
          window.innerWidth - menuRect.width - viewportPadding
        );
        const canOpenBelow = window.innerHeight - buttonRect.bottom >= menuRect.height + viewportPadding + 5;
        const top = canOpenBelow
          ? buttonRect.bottom + 5
          : Math.max(viewportPadding, buttonRect.top - menuRect.height - 5);
        curlMenu.style.left = `${Math.round(left)}px`;
        curlMenu.style.top = `${Math.round(Math.max(viewportPadding, top))}px`;
      };
      const closeCurlMenu = () => {
        curlExport.classList.remove('is-open');
        curlMenu.classList.remove('is-open');
        curlMenuButton.setAttribute('aria-expanded', 'false');
        if (activeCurlMenu?.menu === curlMenu) {
          activeCurlMenu = null;
        }
      };
      curlMenuButton.addEventListener('click', () => {
        const willOpen = !curlMenu.classList.contains('is-open');
        if (willOpen) {
          activeCurlMenu?.close();
          updateCurlMenuSelection();
          curlExport.classList.add('is-open');
          curlMenu.classList.add('is-open');
          curlMenuButton.setAttribute('aria-expanded', 'true');
          activeCurlMenu = { menu: curlMenu, trigger: curlMenuButton, close: closeCurlMenu };
          requestAnimationFrame(positionCurlMenu);
        } else {
          closeCurlMenu();
        }
      });
      curlExport.addEventListener('focusout', () => {
        window.setTimeout(() => {
          if (!curlExport.matches(':focus-within') && !curlMenu.matches(':focus-within')) {
            closeCurlMenu();
          }
        }, 0);
      });
      curlMenu.addEventListener('focusout', () => {
        window.setTimeout(() => {
          if (!curlExport.matches(':focus-within') && !curlMenu.matches(':focus-within')) {
            closeCurlMenu();
          }
        }, 0);
      });
      curlExport.append(
        createCopyButton('cURL', () => createCurlRequest(record, getPreferredCurlTarget()), '按设置的默认格式复制 cURL 命令'),
        curlMenuButton
      );
      [
        ['macOS / Linux', 'posix'],
        ['PowerShell', 'powershell']
      ].forEach(([name, target]) => {
        const menuItem = createCopyButton(name, () => createCurlRequest(record, target), `复制 ${name} 格式`);
        menuItem.className = 'tm-debug-curl-menu-item';
        menuItem.dataset.curlTarget = target;
        menuItem.classList.toggle('is-selected', target === getPreferredCurlTarget());
        menuItem.setAttribute('role', 'menuitem');
        menuItem.addEventListener('click', closeCurlMenu);
        curlMenu.appendChild(menuItem);
      });
      root.appendChild(curlMenu);
      exportBar.append(
        curlExport,
        createCopyButton('fetch', () => createFetchRequest(record)),
        createCopyButton('XHR', () => createXhrRequest(record))
      );
      return exportBar;
    };

    const appendJsonTreeNode = (container, key, value, depth, isRoot = false) => {
      const isContainer = value !== null && typeof value === 'object';
      if (!isContainer) {
        const row = document.createElement('div');
        const keyElement = document.createElement('span');
        const valueElement = document.createElement('span');
        row.className = 'tm-json-leaf';
        if (key !== null) {
          keyElement.className = 'tm-json-key';
          keyElement.textContent = `${key}: `;
          row.appendChild(keyElement);
        }
        let valueText = 'null';
        let valueClass = 'tm-json-value-null';
        if (typeof value === 'string') {
          valueText = JSON.stringify(value);
          valueClass = 'tm-json-value-string';
        } else if (typeof value === 'number' || typeof value === 'bigint') {
          valueText = String(value);
          valueClass = 'tm-json-value-number';
        } else if (typeof value === 'boolean') {
          valueText = String(value);
          valueClass = 'tm-json-value-boolean';
        }
        valueElement.className = valueClass;
        valueElement.textContent = valueText;
        row.appendChild(valueElement);
        container.appendChild(row);
        return;
      }

      const branch = document.createElement('details');
      const summary = document.createElement('summary');
      const label = document.createElement('span');
      const type = document.createElement('span');
      const children = document.createElement('div');
      const entries = Array.isArray(value)
        ? value.map((entry, index) => [index, entry])
        : Object.entries(value);
      branch.className = 'tm-json-branch';
      branch.open = isRoot;
      children.className = 'tm-json-children';
      label.className = 'tm-json-key';
      label.textContent = key === null ? '$' : `${key}:`;
      type.className = 'tm-json-type';
      type.textContent = Array.isArray(value) ? `[${value.length}]` : `{${entries.length}}`;
      summary.append(label, type);
      if (!entries.length) {
        const empty = document.createElement('span');
        empty.className = 'tm-json-type';
        empty.textContent = ' 空';
        summary.appendChild(empty);
      } else {
        entries.forEach(([entryKey, entryValue]) => {
          appendJsonTreeNode(children, entryKey, entryValue, depth + 1);
        });
      }
      branch.append(summary, children);
      container.appendChild(branch);
    };

    const createJsonTree = (value) => {
      const tree = document.createElement('div');
      tree.className = 'tm-debug-json-tree';
      appendJsonTreeNode(tree, null, value, 0, true);
      return tree;
    };

    const appendDebugDetail = (container, label, value, options = {}) => {
      const detail = document.createElement('section');
      const title = document.createElement('span');
      const content = document.createElement('pre');
      detail.className = `tm-debug-detail${options.wide ? ' is-wide' : ''}`;
      title.className = 'tm-debug-detail-label';
      content.className = 'tm-debug-detail-value';
      title.textContent = label;
      const isWrappedPayload = value && typeof value === 'object'
        && Object.prototype.hasOwnProperty.call(value, 'type')
        && Object.prototype.hasOwnProperty.call(value, 'value');
      const isJsonPayload = isWrappedPayload && value.type === 'json' && value.truncated !== true;
      const isStructuredValue = !isWrappedPayload && value !== null && typeof value === 'object';
      if (isJsonPayload || isStructuredValue) {
        const payload = document.createElement('div');
        const toolbar = document.createElement('div');
        const treeButton = document.createElement('button');
        const rawButton = document.createElement('button');
        const copyButton = document.createElement('button');
        const structuredValue = isJsonPayload ? value.value : value;
        const tree = createJsonTree(structuredValue);
        const raw = document.createElement('pre');
        let rawText;
        try {
          rawText = isJsonPayload && typeof value.raw === 'string'
            ? value.raw
            : JSON.stringify(structuredValue, null, 2);
        } catch {
          rawText = formatDebugPayloadValue(value, options.emptyText);
        }
        payload.className = 'tm-debug-payload';
        toolbar.className = 'tm-debug-payload-toolbar';
        treeButton.className = 'tm-debug-payload-button is-active';
        treeButton.type = 'button';
        treeButton.textContent = '树形';
        rawButton.className = 'tm-debug-payload-button';
        rawButton.type = 'button';
        rawButton.textContent = '原文';
        copyButton.className = 'tm-debug-payload-button';
        copyButton.type = 'button';
        copyButton.textContent = '复制原文';
        raw.className = 'tm-debug-detail-value tm-debug-json-raw';
        raw.textContent = rawText;
        raw.hidden = true;
        const showJsonView = (view) => {
          const showRaw = view === 'raw';
          tree.hidden = showRaw;
          raw.hidden = !showRaw;
          treeButton.classList.toggle('is-active', !showRaw);
          rawButton.classList.toggle('is-active', showRaw);
        };
        treeButton.addEventListener('click', () => showJsonView('tree'));
        rawButton.addEventListener('click', () => showJsonView('raw'));
        copyButton.addEventListener('click', async () => {
          const copied = await copyText(rawText);
          copyButton.textContent = copied ? '已复制' : '复制失败';
          window.setTimeout(() => { copyButton.textContent = '复制原文'; }, 1200);
        });
        toolbar.append(treeButton, rawButton, copyButton);
        payload.append(toolbar, tree, raw);
        detail.append(title, payload);
      } else {
        content.textContent = formatDebugPayloadValue(value, options.emptyText);
        detail.append(title, content);
      }
      container.appendChild(detail);
    };

    const setDebugStep = (step, state, value) => {
      step.classList.remove('is-active', 'is-success', 'is-error');
      if (state) {
        step.classList.add(`is-${state}`);
      }
      step.querySelector('.tm-debug-step-value').textContent = value;
    };

    const resetDebugSteps = (isEnabled) => {
      setDebugStep(debugStepCapture, isEnabled ? 'active' : '', isEnabled ? '等待网页请求' : '等待开启监听');
      setDebugStep(debugStepMatch, '', '等待请求');
      setDebugStep(debugStepRewrite, '', '等待匹配');
      setDebugStep(debugStepResponse, '', '等待响应');
    };

    const updateDebugSteps = (record) => {
      if (!record) {
        resetDebugSteps(debugEnabledInput.checked);
        return;
      }

      if (record.transport === 'navigation') {
        setDebugStep(debugStepCapture, 'success', '页面导航地址');
        setDebugStep(debugStepMatch, '', '导航请求不参与规则匹配');
        setDebugStep(debugStepRewrite, '', '未拦截导航请求');
        setDebugStep(debugStepResponse, 'success', '当前页面地址已记录');
        return;
      }

      setDebugStep(
        debugStepCapture,
        'success',
        `${String(record.transport || 'request').toUpperCase()} ${record.method || 'GET'}`
      );
      setDebugStep(
        debugStepMatch,
        record.matched ? 'success' : 'active',
        record.matched ? `命中规则 #${record.ruleIndex}` : '未命中规则'
      );

      const rewriteSummary = Array.isArray(record.changes) && record.changes.length
        ? `改写:${record.changes.join('、')}`
        : record.matched ? '规则未配置改写项' : '未执行改写';
      setDebugStep(
        debugStepRewrite,
        record.scriptError ? 'error' : record.matched && record.changes?.length ? 'success' : '',
        record.scriptError ? `JS 执行失败:${record.scriptError}` : rewriteSummary
      );

      if (record.phase === 'error' || record.status === 0) {
        setDebugStep(debugStepResponse, 'error', record.message || '请求失败');
      } else if (typeof record.status === 'number') {
        setDebugStep(
          debugStepResponse,
          record.status >= 200 && record.status < 400 ? 'success' : 'error',
          `HTTP ${record.status}`
        );
      } else {
        setDebugStep(debugStepResponse, 'active', '等待响应');
      }
    };

    const debugRecordMatchesFilter = (record) => {
      const filter = debugFilterInput.value.trim().toLowerCase();
      if (!filter) {
        return true;
      }

      const values = [
        record.transport,
        record.method,
        record.status,
        record.statusText,
        record.originalUrl,
        record.finalUrl,
        record.responseUrl,
        record.message,
        record.ruleIndex
      ];
      return values.some((value) => String(value ?? '').toLowerCase().includes(filter));
    };

    const renderDebugRecords = () => {
      const visibleRecords = debugRecords.filter(debugRecordMatchesFilter);
      activeCurlMenu = null;
      root.querySelectorAll('.tm-debug-curl-menu').forEach((menu) => menu.remove());
      debugLog.replaceChildren();
      debugFilterCount.textContent = `${visibleRecords.length}/${debugRecords.length}`;
      debugEmpty.hidden = visibleRecords.length > 0;
      debugEmpty.textContent = debugRecords.length && !visibleRecords.length
        ? '没有匹配的接口记录,请调整过滤条件。'
        : debugEmptyText;
      updateDebugSteps(visibleRecords[0] || (debugFilterInput.value.trim() ? null : debugRecords[0]));

      visibleRecords.forEach((record) => {
        const item = document.createElement('li');
        const heading = document.createElement('div');
        const timestamp = document.createElement('span');
        const requestType = document.createElement('span');
        const requestMethod = document.createElement('span');
        const status = document.createElement('span');
        const originalUrl = document.createElement('div');
        const finalUrl = document.createElement('div');
        const metadata = document.createElement('div');
        const isError = record.phase === 'error' || record.status === 0 || Boolean(record.scriptError);
        const hasStatus = typeof record.status === 'number';

        item.className = `tm-debug-log-item${record.matched ? ' is-matched' : ''}${isError ? ' is-error' : ''}`;
        heading.className = 'tm-debug-log-head';
        timestamp.textContent = formatDebugTime(record.timestamp);
        requestType.textContent = String(record.transport || 'request').toUpperCase();
        requestMethod.textContent = record.method || 'GET';
        status.className = `tm-debug-log-status${isError ? ' is-error' : hasStatus && record.status >= 200 && record.status < 400 ? ' is-success' : ''}`;

        if (record.transport === 'navigation') {
          status.textContent = '当前页面地址';
        } else if (record.scriptError) {
          status.textContent = 'JS 执行失败';
        } else if (isError) {
          status.textContent = record.message || '请求失败';
        } else if (hasStatus) {
          status.textContent = `HTTP ${record.status}`;
        } else if (record.matched) {
          status.textContent = `命中规则 #${record.ruleIndex}`;
        } else {
          status.textContent = '未命中规则';
        }

        let actions;
        heading.append(timestamp, requestType, requestMethod, status);
        if (record.finalUrl || record.originalUrl) {
          actions = document.createElement('div');
          const editButton = document.createElement('button');
          actions.className = 'tm-debug-log-actions';
          editButton.className = 'tm-debug-edit-button';
          editButton.type = 'button';
          editButton.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 20h9"></path><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"></path></svg><span>编辑请求</span>';
          editButton.title = '将实际发送的请求回填到“接口请求”中';
          editButton.addEventListener('click', () => editCapturedRequest(record));
          actions.appendChild(editButton);
          item.appendChild(actions);
        }
        originalUrl.className = 'tm-debug-log-url';
        originalUrl.textContent = record.finalUrl !== record.originalUrl
          ? `原始:${redactDebugUrl(record.originalUrl)}`
          : redactDebugUrl(record.originalUrl);
        item.append(heading, originalUrl);
        if (actions) {
          item.insertBefore(actions, originalUrl);
        }

        if (record.finalUrl && record.finalUrl !== record.originalUrl) {
          finalUrl.className = 'tm-debug-log-url is-rewritten';
          finalUrl.textContent = `改写后:${redactDebugUrl(record.finalUrl)}`;
          item.appendChild(finalUrl);
        }

        const metadataParts = [];
        if (record.matched && record.ruleIndex) {
          metadataParts.push(`规则 #${record.ruleIndex}`);
        }
        if (Array.isArray(record.changes) && record.changes.length) {
          metadataParts.push(`改写:${record.changes.join('、')}`);
        }
        if (record.responseRewritten) {
          metadataParts.push('已尝试改写响应');
        }
        if (record.scriptError) {
          metadataParts.push(`JS 错误:${record.scriptError}`);
        }
        if (metadataParts.length) {
          metadata.className = 'tm-debug-log-meta';
          metadata.textContent = metadataParts.join(' · ');
          item.appendChild(metadata);
        }

        const hasRequestDetails = ['queryParams', 'requestHeaders', 'requestBody', 'finalQueryParams', 'finalRequestHeaders', 'finalRequestBody']
          .some((key) => Object.prototype.hasOwnProperty.call(record, key));
        const hasResponseDetails = ['responseHeaders', 'responseBody', 'originalResponseBody']
          .some((key) => Object.prototype.hasOwnProperty.call(record, key));
        if (hasRequestDetails || hasResponseDetails) {
          const detailsWrap = document.createElement('div');
          const details = document.createElement('details');
          const summary = document.createElement('summary');
          const summaryTitle = document.createElement('span');
          const detailGrid = document.createElement('div');
          detailsWrap.className = 'tm-debug-details-wrap';
          details.className = 'tm-debug-details';
          summaryTitle.className = 'tm-debug-summary-title';
          summaryTitle.textContent = '查看请求 / 响应详情';
          summary.appendChild(summaryTitle);
          detailGrid.className = 'tm-debug-detail-grid';

          if (hasRequestDetails) {
            detailsWrap.appendChild(createDebugRequestExport(record));
            appendDebugDetail(detailGrid, '查询参数', record.queryParams, { emptyText: '(无查询参数)' });
            appendDebugDetail(detailGrid, '请求头', record.requestHeaders, { emptyText: '(浏览器未设置自定义请求头)' });
            if (Object.prototype.hasOwnProperty.call(record, 'requestBody')) {
              appendDebugDetail(detailGrid, '请求体', record.requestBody, { wide: true, emptyText: '(无请求体)' });
            }

            const requestWasRewritten = record.finalUrl !== record.originalUrl
              || record.originalMethod !== record.method
              || debugValuesDiffer(record.requestHeaders, record.finalRequestHeaders)
              || debugValuesDiffer(record.requestBody, record.finalRequestBody);
            if (requestWasRewritten) {
              appendDebugDetail(detailGrid, '改写后查询参数', record.finalQueryParams, { emptyText: '(无查询参数)' });
              appendDebugDetail(detailGrid, '改写后请求头', record.finalRequestHeaders, { emptyText: '(浏览器未设置自定义请求头)' });
              if (Object.prototype.hasOwnProperty.call(record, 'finalRequestBody')) {
                appendDebugDetail(detailGrid, '改写后请求体', record.finalRequestBody, { wide: true, emptyText: '(无请求体)' });
              }
            }
          }

          if (hasResponseDetails) {
            appendDebugDetail(detailGrid, '响应头', record.responseHeaders, { emptyText: '(浏览器未暴露响应头)' });
            if (record.responseRewritten && record.originalResponseBody !== undefined) {
              appendDebugDetail(detailGrid, '原始响应内容', record.originalResponseBody, { wide: true, emptyText: '(无响应内容)' });
            }
            appendDebugDetail(detailGrid, record.responseRewritten ? '返回内容(改写后)' : '返回内容', record.responseBody, { wide: true, emptyText: '(无响应内容)' });
          }

          details.prepend(summary);
          details.appendChild(detailGrid);
          detailsWrap.prepend(details);
          item.appendChild(detailsWrap);
        }

        debugLog.appendChild(item);
      });
    };

    let debugRenderFrame = 0;
    let debugPersistenceFrame = 0;
    const scheduleDebugRender = () => {
      if (debugRenderFrame) {
        return;
      }
      debugRenderFrame = requestAnimationFrame(() => {
        debugRenderFrame = 0;
        renderDebugRecords();
      });
    };

    const trimDebugRecords = () => {
      while (debugRecords.length > panelSettings.debugRecordLimit) {
        const removedRecord = debugRecords.pop();
        debugRecordById.delete(removedRecord.id);
      }
    };

    const redactPersistedUrl = (value) => {
      try {
        const parsedUrl = new URL(String(value));
        parsedUrl.searchParams.forEach((parameterValue, parameterName) => {
          if (/(token|secret|password|authorization|api[-_]?key)/i.test(parameterName)) {
            parsedUrl.searchParams.set(parameterName, '***');
          }
        });
        return parsedUrl.href;
      } catch {
        return String(value || '');
      }
    };

    const sanitizePersistedDebugValue = (value, depth = 0, seen = new WeakSet()) => {
      if (value == null || typeof value === 'boolean' || typeof value === 'number') {
        return value;
      }
      if (typeof value === 'bigint') {
        return String(value);
      }
      if (typeof value === 'string') {
        return value.length > PERSISTED_DEBUG_TEXT_LIMIT
          ? `${value.slice(0, PERSISTED_DEBUG_TEXT_LIMIT)}\n…(本地保存时已截断)`
          : value;
      }
      if (typeof value !== 'object') {
        return String(value);
      }
      if (depth >= 5) {
        return '…(层级过深,未继续保存)';
      }
      if (seen.has(value)) {
        return '…(循环引用,未保存)';
      }
      seen.add(value);
      if (typeof Blob !== 'undefined' && value instanceof Blob) {
        return { type: 'binary', name: value.name || '', size: value.size, mimeType: value.type || '' };
      }
      if (Array.isArray(value)) {
        const entries = value.slice(0, 80).map((entry) => sanitizePersistedDebugValue(entry, depth + 1, seen));
        if (value.length > entries.length) {
          entries.push(`…(其余 ${value.length - entries.length} 项未保存)`);
        }
        return entries;
      }
      const entries = Object.entries(value).slice(0, 100);
      const result = {};
      entries.forEach(([key, entryValue]) => {
        result[key] = sanitizePersistedDebugValue(entryValue, depth + 1, seen);
      });
      if (Object.keys(value).length > entries.length) {
        result.__truncated__ = `其余 ${Object.keys(value).length - entries.length} 个字段未保存`;
      }
      return result;
    };

    const redactPersistedHeaders = (headers) => {
      if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
        return sanitizePersistedDebugValue(headers);
      }
      return Object.fromEntries(Object.entries(headers).map(([name, value]) => [
        name,
        /^(authorization|cookie|set-cookie|proxy-authorization|x-api-key)$/i.test(name)
          ? '***'
          : sanitizePersistedDebugValue(value)
      ]));
    };

    const createPersistedDebugRecord = (record) => {
      const persistedRecord = sanitizePersistedDebugValue(record);
      ['originalUrl', 'finalUrl', 'responseUrl', 'frameUrl'].forEach((key) => {
        if (Object.prototype.hasOwnProperty.call(record, key)) {
          persistedRecord[key] = redactPersistedUrl(record[key]);
        }
      });
      ['requestHeaders', 'finalRequestHeaders', 'responseHeaders'].forEach((key) => {
        if (Object.prototype.hasOwnProperty.call(record, key)) {
          persistedRecord[key] = redactPersistedHeaders(record[key]);
        }
      });
      return persistedRecord;
    };

    const persistDebugRecords = () => {
      debugPersistenceFrame = 0;
      if (!panelSettings.persistDebugRecords) {
        return;
      }
      try {
        const records = debugRecords
          .slice(0, PERSISTED_DEBUG_RECORD_LIMIT)
          .map(createPersistedDebugRecord);
        localStorage.setItem(DEBUG_RECORDS_KEY, JSON.stringify({ version: 1, records }));
      } catch {
        localStorage.removeItem(DEBUG_RECORDS_KEY);
        applyDebugRecordPersistence(false);
        savePanelSettings(panelSettings);
        displayRewriteStatus('监听记录本地保存空间不足,已自动关闭“刷新后保留记录”。', true);
      }
    };

    const scheduleDebugPersistence = () => {
      if (!panelSettings.persistDebugRecords || debugPersistenceFrame) {
        return;
      }
      debugPersistenceFrame = requestAnimationFrame(persistDebugRecords);
    };

    const restorePersistedDebugRecords = () => {
      if (!panelSettings.persistDebugRecords) {
        return;
      }
      try {
        const saved = JSON.parse(localStorage.getItem(DEBUG_RECORDS_KEY) || '{}');
        const records = Array.isArray(saved) ? saved : saved.records;
        if (!Array.isArray(records)) {
          return;
        }
        records.slice(0, PERSISTED_DEBUG_RECORD_LIMIT).forEach((record) => {
          if (!record || typeof record !== 'object' || !record.id || debugRecordById.has(record.id)) {
            return;
          }
          const restoredRecord = { ...record };
          debugRecords.push(restoredRecord);
          debugRecordById.set(restoredRecord.id, restoredRecord);
        });
        trimDebugRecords();
      } catch {
        localStorage.removeItem(DEBUG_RECORDS_KEY);
      }
    };

    const clearDebugRecords = () => {
      if (debugRenderFrame) {
        cancelAnimationFrame(debugRenderFrame);
        debugRenderFrame = 0;
      }
      if (debugPersistenceFrame) {
        cancelAnimationFrame(debugPersistenceFrame);
        debugPersistenceFrame = 0;
      }
      debugRecords.length = 0;
      debugRecordById.clear();
      lastRecordedPageAddress = '';
      localStorage.removeItem(DEBUG_RECORDS_KEY);
      renderDebugRecords();
    };

    const updateDebugEnabledState = (enabled, announce = false) => {
      const isEnabled = enabled === true;
      debugEnabledInput.checked = isEnabled;
      debugState.textContent = isEnabled ? '监听中' : '已关闭';
      debugState.classList.toggle('is-active', isEnabled);
      saveRewriteDebugEnabled(isEnabled);
      if (isEnabled) {
        activateModule('rewrite');
        recordCurrentPageAddress();
      }
      if (debugRecords.length) {
        updateDebugSteps(debugRecords[0]);
      } else {
        resetDebugSteps(isEnabled);
      }
      if (announce) {
        activateModule('rewrite');
        displayRewriteStatus(isEnabled
          ? '请求改写调试已开启:现在操作网页,调试面板会记录 fetch / XHR 的参数、请求体、响应内容和规则命中情况。'
          : '请求改写调试已关闭。已有记录仍会保留到本次页面结束。');
      }
    };

    const receiveDebugEvent = (event) => {
      const detail = event.detail && typeof event.detail === 'object' ? { ...event.detail } : null;
      if (!detail || !detail.id) {
        return;
      }

      let record = debugRecordById.get(detail.id);
      if (!record) {
        record = { ...detail };
        debugRecordById.set(detail.id, record);
        debugRecords.unshift(record);
      } else {
        Object.assign(record, detail);
      }

      trimDebugRecords();
      scheduleDebugRender();
      scheduleDebugPersistence();
    };

    const receiveFrameDebugMessage = (event) => {
      const message = event.data;
      if (!message || typeof message !== 'object' || message.type !== DEBUG_BRIDGE_MESSAGE_TYPE) {
        return;
      }
      const detail = message.detail && typeof message.detail === 'object' ? message.detail : null;
      if (!detail || !detail.id) {
        return;
      }
      receiveDebugEvent({ detail });
    };

    const recordCurrentPageAddress = () => {
      if (!debugEnabledInput.checked) {
        return;
      }
      const currentUrl = window.location.href;
      if (lastRecordedPageAddress === currentUrl) {
        return;
      }
      lastRecordedPageAddress = currentUrl;
      receiveDebugEvent({
        detail: {
          id: `navigation-${Date.now()}`,
          timestamp: Date.now(),
          phase: 'navigation',
          transport: 'navigation',
          method: 'GET',
          originalUrl: currentUrl,
          finalUrl: currentUrl,
          matched: false,
          message: '仅记录当前页面地址;浏览器导航请求本身无法读取请求头和响应内容。'
        }
      });
    };

    const formatResponseBody = (responseText) => {
      const text = String(responseText || '');
      if (!text.trim()) {
        return '';
      }

      try {
        return JSON.parse(text);
      } catch {
        return text;
      }
    };

    const getSelectedContentType = () => {
      const headerState = splitContentType(readRows(headers));
      return headerState.contentType === 'custom'
        ? headerState.customContentType
        : headerState.contentType;
    };

    const updateBodyEditor = () => {
      const bodyType = bodyTypeForContentType(getSelectedContentType());
      const hasBody = !['GET', 'HEAD'].includes(method.value);
      const labels = {
        json: '当前 Content-Type:JSON,编辑完整 JSON 请求体。',
        form: '当前 Content-Type:x-www-form-urlencoded,逐行填写表单字段。',
        multipart: '当前 Content-Type:multipart/form-data,逐行填写表单字段。',
        raw: '当前 Content-Type:文本或自定义类型,原样发送请求体。'
      };

      bodyTypeHint.textContent = hasBody
        ? labels[bodyType]
        : '当前方法不会发送请求体。';
      bodySection.classList.toggle('is-method-without-body', !hasBody);
      jsonBodyWrap.hidden = !hasBody || bodyType !== 'json';
      bodyFieldsWrap.hidden = !hasBody || !['form', 'multipart'].includes(bodyType);
      rawBody.hidden = !hasBody || bodyType !== 'raw';
      addBodyFieldButton.hidden = !hasBody || !['form', 'multipart'].includes(bodyType);
      if (hasBody && ['form', 'multipart'].includes(bodyType)) {
        bodyFields.querySelectorAll('.tm-body-field-row').forEach(updateBodyFieldType);
      }
    };

    const getUrlMode = () => urlModeInputs.find((input) => input.checked)?.value || 'manual';

    let activeUrlMode = state.urlMode;
    let manualUrlDraft = null;

    const setUrlMode = (value) => {
      urlModeInputs.forEach((input) => {
        input.checked = input.value === value;
      });
    };

    const captureManualUrlDraft = () => ({
      method: method.value,
      url: url.value,
      params: readRows(params),
      headers: readRows(headers)
    });

    const restoreManualUrlDraft = (draft) => {
      if (!draft) {
        return;
      }
      method.value = Array.from(method.options).some((option) => option.value === draft.method)
        ? draft.method
        : 'GET';
      url.value = draft.url || '';
      renderRows(params, draft.params || [], { key: '参数名,例如 page', value: '参数值,例如 1' });
      renderHeaderRows(headers, draft.headers || []);
    };

    const syncCurrentPageRequest = () => {
      const currentPageUrl = new URL(window.location.href);
      const currentParams = Array.from(currentPageUrl.searchParams.entries())
        .map(([key, value]) => ({ key, value }));
      const currentHeaders = readRows(headers);
      const hasAcceptHeader = currentHeaders.some((header) => header.key.trim().toLowerCase() === 'accept');

      currentPageUrl.hash = '';
      method.value = 'GET';
      url.value = currentPageUrl.href;
      renderRows(params, currentParams, { key: '参数名,例如 page', value: '参数值,例如 1' });
      if (!hasAcceptHeader) {
        currentHeaders.push({ key: 'Accept', value: 'application/json, text/plain, */*' });
        renderHeaderRows(headers, currentHeaders);
      }
    };

    const updateUrlMode = (announce = false) => {
      const usesCurrentPageUrl = getUrlMode() === 'current-page';
      url.readOnly = false;
      url.dataset.urlSource = usesCurrentPageUrl ? 'current-page' : 'manual';

      if (usesCurrentPageUrl) {
        if (activeUrlMode !== 'current-page') {
          manualUrlDraft = captureManualUrlDraft();
        }
        syncCurrentPageRequest();
        updateBodyEditor();
        if (announce) {
          display('已从浏览器地址载入 URL、Query 参数和常用 Accept 请求头;现在可直接修改请求 URL 和参数。重新选择“浏览器地址”可再次同步。');
        }
      } else if (activeUrlMode === 'current-page') {
        restoreManualUrlDraft(manualUrlDraft);
        updateBodyEditor();
      }
      activeUrlMode = usesCurrentPageUrl ? 'current-page' : 'manual';
    };

    const updateResponseEditor = () => {
      const isDownload = responseMode.value === 'download';
      downloadNameWrap.hidden = !isDownload;
      responseGrid.classList.toggle('is-preview', !isDownload);
    };

    const updateBatchSuccessEditor = () => {
      const usesJsonCondition = batchSuccessMode.value !== 'http';
      batchSuccessCondition.hidden = !usesJsonCondition;
      batchSuccessExpectedWrap.hidden = batchSuccessOperator.value === 'exists'
        || batchSuccessOperator.value === 'truthy'
        || batchSuccessOperator.value === 'falsy';
    };

    let preservedRequestDraft = null;

    const captureRequestDraft = () => ({
      urlMode: getUrlMode(),
      method: method.value,
      url: url.value,
      params: readRows(params),
      headers: readRows(headers),
      jsonBody: jsonBody.value,
      bodyFields: readBodyFields(),
      rawBody: rawBody.value,
      batchFields: readBatchFields(),
      batchExecutionMode: batchExecutionMode.value,
      batchIntervalSeconds: batchIntervalInput.value,
      batchSuccessMode: batchSuccessMode.value,
      batchSuccessPath: batchSuccessPath.value,
      batchSuccessOperator: batchSuccessOperator.value,
      batchSuccessExpected: batchSuccessExpected.value,
      responseMode: responseMode.value,
      downloadFileName: downloadFileName.value,
      bodyOpen: bodySection.open
    });

    const restoreRequestDraft = (draft) => {
      if (!draft) {
        return;
      }
      const draftMethod = String(draft.method || 'GET').toUpperCase();
      setUrlMode(draft.urlMode === 'current-page' ? 'current-page' : 'manual');
      activeUrlMode = getUrlMode();
      method.value = Array.from(method.options).some((option) => option.value === draftMethod)
        ? draftMethod
        : 'GET';
      url.value = draft.url || '';
      url.readOnly = activeUrlMode === 'current-page';
      renderRows(params, draft.params || [], { key: '参数名,例如 page', value: '参数值,例如 1' });
      renderHeaderRows(headers, draft.headers || []);
      jsonBody.value = draft.jsonBody || '';
      rawBody.value = draft.rawBody || '';
      renderBodyFields(draft.bodyFields || []);
      renderBatchFields(draft.batchFields || []);
      batchExecutionMode.value = draft.batchExecutionMode === 'stop-on-success' ? 'stop-on-success' : 'all';
      batchIntervalInput.value = String(normalizeBatchInterval(draft.batchIntervalSeconds));
      batchSuccessMode.value = ['http', 'json', 'http-and-json'].includes(draft.batchSuccessMode)
        ? draft.batchSuccessMode
        : 'http';
      batchSuccessPath.value = draft.batchSuccessPath || '';
      batchSuccessOperator.value = ['equals', 'not-equals', 'exists', 'truthy', 'falsy', 'includes'].includes(draft.batchSuccessOperator)
        ? draft.batchSuccessOperator
        : 'equals';
      batchSuccessExpected.value = draft.batchSuccessExpected ?? '0';
      responseMode.value = draft.responseMode === 'download' ? 'download' : 'preview';
      downloadFileName.value = draft.downloadFileName || '';
      bodySection.open = draft.bodyOpen === true;
      if (activeUrlMode === 'current-page') {
        syncCurrentPageRequest();
      } else {
        manualUrlDraft = captureManualUrlDraft();
      }
      updateBodyEditor();
      updateResponseEditor();
      updateBatchSuccessEditor();
    };

    const refreshRuleLabels = () => {
      const cards = Array.from(ruleList.querySelectorAll('.tm-rule-card'));
      cards.forEach((card, index) => {
        card.querySelector('[data-rule-title]').textContent = `规则 ${index + 1}`;
      });
      emptyRules.hidden = cards.length > 0;
    };

    const createRuleCard = (initialRule = {}) => {
      const card = document.createElement('article');
      card.className = 'tm-rule-card';
      card.innerHTML = `
        <div class="tm-rule-card-header"><div><div class="tm-rule-card-title" data-rule-title></div><label class="tm-switch"><input data-rule-enabled type="checkbox">启用此规则</label></div><button data-remove-rule class="danger tm-rule-remove" type="button">删除</button></div>
        <div class="tm-rule-grid">
          <label>匹配方式<select data-rule-match-type><option value="contains">URL 包含</option><option value="regex">正则表达式</option></select></label>
          <label>匹配的接口地址<input data-rule-match placeholder="例如 /api/v1/orders"></label>
          <label class="tm-rule-wide">替换为(可选)<input data-rule-replace-url placeholder="例如 /api/v2/orders 或完整新 URL"></label>
          <fieldset class="tm-method-list"><legend>匹配方法(不选表示全部方法)</legend><label><input data-rule-method value="GET" type="checkbox">GET</label><label><input data-rule-method value="POST" type="checkbox">POST</label><label><input data-rule-method value="PUT" type="checkbox">PUT</label><label><input data-rule-method value="PATCH" type="checkbox">PATCH</label><label><input data-rule-method value="DELETE" type="checkbox">DELETE</label><label><input data-rule-method value="HEAD" type="checkbox">HEAD</label></fieldset>
        </div>
        <details class="tm-rule-extra">
          <summary>可选:改写请求头、请求体或响应内容</summary>
          <div class="tm-rule-extra-content"><div data-rule-headers class="tm-row-list"></div><button data-add-rule-header class="secondary" type="button" style="margin-top:10px">+ 添加请求头</button><label class="tm-rule-body-toggle"><input data-rule-replace-body type="checkbox">替换请求体 Body</label><textarea data-rule-body class="tm-rule-body" spellcheck="false" placeholder="替换后的原始请求体" hidden></textarea><div class="tm-rule-response"><label>响应处理<select data-rule-response-mode><option value="none">保持原响应</option><option value="replace">替换整个响应正文</option><option value="merge-json">合并 / 覆盖 JSON 字段</option></select></label><textarea data-rule-response-body spellcheck="false" placeholder="改写后的响应内容" hidden></textarea><span class="tm-hint" data-rule-response-hint hidden></span></div><details class="tm-rule-script"><summary>高级:执行自定义 JS / 函数</summary><div class="tm-rule-script-content"><label class="tm-switch"><input data-rule-script-enabled type="checkbox">启用自定义 JS 请求改写</label><textarea data-rule-script spellcheck="false" placeholder="(request, context) => ({\n  url: request.url.replace('/old', '/new'),\n  headers: { ...request.headers, 'X-Debug': '1' },\n  body: request.body\n})" hidden></textarea><span class="tm-hint" data-rule-script-hint hidden>可写函数、箭头函数或直接修改 request 后 return。request 包含 url、method、headers、body;函数返回对象可覆盖这些字段。</span></div></details></div>
        </details>
      `;

      const methods = Array.isArray(initialRule.methods)
        ? initialRule.methods.map((item) => String(item).toUpperCase())
        : [];
      card.querySelector('[data-rule-enabled]').checked = initialRule.enabled !== false;
      card.querySelector('[data-rule-match-type]').value = initialRule.matchType === 'regex' ? 'regex' : 'contains';
      card.querySelector('[data-rule-match]').value = initialRule.match || '';
      card.querySelector('[data-rule-replace-url]').value = initialRule.replaceUrl || '';
      card.querySelectorAll('[data-rule-method]').forEach((input) => {
        input.checked = methods.includes(input.value);
      });

      const ruleHeaders = card.querySelector('[data-rule-headers]');
      renderHeaderRows(ruleHeaders, normalizeRows(initialRule.headers));
      const replaceBody = card.querySelector('[data-rule-replace-body]');
      const ruleBody = card.querySelector('[data-rule-body]');
      replaceBody.checked = initialRule.bodyMode === 'replace';
      ruleBody.value = initialRule.body || '';
      ruleBody.hidden = !replaceBody.checked;
      replaceBody.addEventListener('change', () => {
        ruleBody.hidden = !replaceBody.checked;
      });
      const ruleResponseMode = card.querySelector('[data-rule-response-mode]');
      const ruleResponseBody = card.querySelector('[data-rule-response-body]');
      const ruleResponseHint = card.querySelector('[data-rule-response-hint]');
      const ruleScriptEnabled = card.querySelector('[data-rule-script-enabled]');
      const ruleScript = card.querySelector('[data-rule-script]');
      const ruleScriptHint = card.querySelector('[data-rule-script-hint]');
      ruleResponseMode.value = ['replace', 'merge-json'].includes(initialRule.responseMode)
        ? initialRule.responseMode
        : 'none';
      ruleResponseBody.value = initialRule.responseBody || '';
      ruleScriptEnabled.checked = initialRule.script?.enabled === true;
      ruleScript.value = initialRule.script?.code || '';
      const updateRuleScriptEditor = () => {
        ruleScript.hidden = !ruleScriptEnabled.checked;
        ruleScriptHint.hidden = !ruleScriptEnabled.checked;
      };
      updateRuleScriptEditor();
      ruleScriptEnabled.addEventListener('change', updateRuleScriptEditor);
      const updateRuleResponseEditor = () => {
        const isJsonMerge = ruleResponseMode.value === 'merge-json';
        ruleResponseBody.hidden = ruleResponseMode.value === 'none';
        ruleResponseHint.hidden = ruleResponseMode.value === 'none';
        ruleResponseHint.textContent = isJsonMerge
          ? '填写要覆盖的 JSON 字段,例如 {"code":0,"data":{"name":"mock"}}。'
          : '填写完整替换后的响应正文;JSON 接口请填写有效 JSON。';
        ruleResponseBody.placeholder = isJsonMerge
          ? '{\n  "code": 0\n}'
          : '{\n  "code": 0,\n  "data": {}\n}';
        if (isJsonMerge && !ruleResponseBody.value.trim()) {
          ruleResponseBody.value = '{}';
        }
      };
      updateRuleResponseEditor();
      ruleResponseMode.addEventListener('change', updateRuleResponseEditor);
      card.querySelector('[data-add-rule-header]').addEventListener('click', () => {
        addHeaderRow(ruleHeaders);
      });
      card.querySelector('[data-remove-rule]').addEventListener('click', () => {
        card.remove();
        refreshRuleLabels();
      });
      ruleList.appendChild(card);
      refreshRuleLabels();
    };

    const readRuleCards = () => Array.from(ruleList.querySelectorAll('.tm-rule-card'))
      .map((card, index) => {
        const match = card.querySelector('[data-rule-match]').value.trim();
        const replaceUrl = card.querySelector('[data-rule-replace-url]').value.trim();
        const headers = rowsToObject(readRows(card.querySelector('[data-rule-headers]')));
        const replaceBody = card.querySelector('[data-rule-replace-body]').checked;
        const body = card.querySelector('[data-rule-body]').value;
        const responseMode = card.querySelector('[data-rule-response-mode]').value;
        const responseBody = card.querySelector('[data-rule-response-body]').value;
        const scriptEnabled = card.querySelector('[data-rule-script-enabled]').checked;
        const scriptCode = card.querySelector('[data-rule-script]').value.trim();
        const hasScript = scriptEnabled || Boolean(scriptCode);
        const isBlank = !match && !replaceUrl && !Object.keys(headers).length && !replaceBody
          && responseMode === 'none' && !hasScript;

        if (isBlank) {
          return null;
        }
        if (!match) {
          throw new Error(`规则 ${index + 1} 必须填写要匹配的接口地址。`);
        }

        const methods = Array.from(card.querySelectorAll('[data-rule-method]:checked')).map((input) => input.value);
        const rule = {
          enabled: card.querySelector('[data-rule-enabled]').checked,
          match,
          matchType: card.querySelector('[data-rule-match-type]').value,
          methods
        };

        if (replaceUrl) {
          rule.replaceUrl = replaceUrl;
        }
        if (Object.keys(headers).length) {
          rule.headers = headers;
        }
        if (replaceBody) {
          rule.bodyMode = 'replace';
          rule.body = body;
        }
        if (responseMode !== 'none') {
          if (responseMode === 'merge-json') {
            try {
              JSON.parse(responseBody);
            } catch {
              throw new Error(`规则 ${index + 1} 的 JSON 响应覆盖内容格式无效。`);
            }
          }
          rule.responseMode = responseMode;
          rule.responseBody = responseBody;
        }
        if (hasScript) {
          if (scriptEnabled) {
            validateCustomRuleScript(scriptCode);
          }
          rule.script = { enabled: scriptEnabled, code: scriptCode };
        }
        return rule;
      })
      .filter(Boolean);

    method.value = state.method;
    setUrlMode(state.urlMode);
    url.value = state.url;
    jsonBody.value = state.jsonBody;
    rawBody.value = state.rawBody;
    batchExecutionMode.value = state.batchExecutionMode === 'stop-on-success' ? 'stop-on-success' : 'all';
    batchIntervalInput.value = String(normalizeBatchInterval(state.batchIntervalSeconds));
    batchSuccessMode.value = ['http', 'json', 'http-and-json'].includes(state.batchSuccessMode)
      ? state.batchSuccessMode
      : 'http';
    batchSuccessPath.value = state.batchSuccessPath || '';
    batchSuccessOperator.value = ['equals', 'not-equals', 'exists', 'truthy', 'falsy', 'includes'].includes(state.batchSuccessOperator)
      ? state.batchSuccessOperator
      : 'equals';
    batchSuccessExpected.value = state.batchSuccessExpected ?? '0';
    renderBatchFields(state.batchFields);
    responseMode.value = state.responseMode === 'download' ? 'download' : 'preview';
    downloadFileName.value = state.downloadFileName || '';
    renderRows(params, state.params, { key: '参数名,例如 page', value: '参数值,例如 1' });
    const storedContentType = state.contentType === 'custom'
      ? state.customContentType
      : state.contentType;
    const initialHeaders = storedContentType && storedContentType !== 'none'
      ? [{ key: 'Content-Type', value: storedContentType }, ...state.headers]
      : state.headers;
    renderHeaderRows(headers, initialHeaders);
    renderBodyFields(state.bodyFields);
    try {
      parseRules(state.rules).forEach(createRuleCard);
    } catch {
      display('已忽略无法读取的旧改写规则;请重新创建规则。');
    }
    updateUrlMode();
    updateBodyEditor();
    updateResponseEditor();
    updateBatchSuccessEditor();
    refreshRuleLabels();

    const getCurrentState = () => {
      const headerState = splitContentType(readRows(headers));
      return {
        method: method.value,
        urlMode: getUrlMode(),
        url: url.value.trim(),
        params: readRows(params),
        headers: headerState.headers,
        contentType: headerState.contentType,
        customContentType: headerState.customContentType,
        bodyType: bodyTypeForContentType(headerState.contentType === 'custom'
          ? headerState.customContentType
          : headerState.contentType),
        jsonBody: jsonBody.value,
        bodyFields: readBodyFields(),
        rawBody: rawBody.value,
        batchFields: readBatchFields(),
        batchExecutionMode: batchExecutionMode.value === 'stop-on-success' ? 'stop-on-success' : 'all',
        batchIntervalSeconds: normalizeBatchInterval(batchIntervalInput.value),
        batchSuccessMode: ['http', 'json', 'http-and-json'].includes(batchSuccessMode.value)
          ? batchSuccessMode.value
          : 'http',
        batchSuccessPath: batchSuccessPath.value.trim(),
        batchSuccessOperator: batchSuccessOperator.value,
        batchSuccessExpected: batchSuccessExpected.value,
        responseMode: responseMode.value,
        downloadFileName: downloadFileName.value.trim(),
        rules: state.rules
      };
    };

    const saveRequestState = (options = {}) => {
      const nextState = getCurrentState();
      if (options.persist !== false) {
        saveState(nextState);
      }
      return nextState;
    };

    const getCapturedRequestValue = (record, finalKey, originalKey) => (
      Object.prototype.hasOwnProperty.call(record, finalKey) && record[finalKey] !== undefined
        ? record[finalKey]
        : record[originalKey]
    );

    const rowsFromCapturedObject = (value) => {
      if (!value || typeof value !== 'object') {
        return [];
      }

      return Object.entries(value).flatMap(([key, entryValue]) => {
        const values = Array.isArray(entryValue) ? entryValue : [entryValue];
        return values.map((item) => ({
          key: String(key),
          value: typeof item === 'string' ? item : JSON.stringify(item)
        }));
      });
    };

    const addCapturedContentType = (headerRows, contentType) => {
      const hasContentType = headerRows.some((header) => header.key.trim().toLowerCase() === 'content-type');
      if (!hasContentType && contentType) {
        headerRows.unshift({ key: 'Content-Type', value: contentType });
      }
    };

    const bodyFieldsFromCapturedValue = (value) => {
      let fileFieldCount = 0;
      const fields = rowsFromCapturedObject(value).map((field) => {
        let parsedValue = field.value;
        try {
          parsedValue = JSON.parse(field.value);
        } catch {
        }
        if (parsedValue && typeof parsedValue === 'object' && parsedValue.type === 'file') {
          fileFieldCount += 1;
          return { key: field.key, type: 'file', value: '' };
        }
        return {
          key: field.key,
          type: typeof parsedValue === 'number' ? 'number' : 'text',
          value: typeof parsedValue === 'string' ? parsedValue : field.value
        };
      });
      return { fields, fileFieldCount };
    };

    const editCapturedRequest = (record) => {
      const capturedUrl = getCapturedRequestValue(record, 'finalUrl', 'originalUrl');
      if (!capturedUrl) {
        display('无法回填:该捕获记录没有可用的请求 URL。');
        return;
      }

      if (!preservedRequestDraft) {
        preservedRequestDraft = captureRequestDraft();
        capturedEditBanner.hidden = false;
      }

      const capturedHeaders = getCapturedRequestValue(record, 'finalRequestHeaders', 'requestHeaders') || {};
      const capturedBody = getCapturedRequestValue(record, 'finalRequestBody', 'requestBody');
      const headerRows = rowsFromCapturedObject(capturedHeaders);
      const capturedMethod = String(record.method || record.originalMethod || 'GET').toUpperCase();
      const supportsMethod = Array.from(method.options).some((option) => option.value === capturedMethod);
      let importedFileCount = 0;

      setUrlMode('manual');
      activeUrlMode = 'manual';
      url.readOnly = false;
      method.value = supportsMethod ? capturedMethod : 'GET';
      try {
        const parsedUrl = new URL(String(capturedUrl), window.location.href);
        const capturedParams = Array.from(parsedUrl.searchParams.entries())
          .map(([key, value]) => ({ key, value }));
        parsedUrl.search = '';
        parsedUrl.hash = '';
        url.value = parsedUrl.href;
        renderRows(params, capturedParams, { key: '参数名,例如 page', value: '参数值,例如 1' });
      } catch {
        url.value = String(capturedUrl);
        renderRows(params, [], { key: '参数名,例如 page', value: '参数值,例如 1' });
      }

      jsonBody.value = '';
      rawBody.value = '';
      renderBodyFields([]);
      if (capturedBody && typeof capturedBody === 'object') {
        if (capturedBody.type === 'json') {
          addCapturedContentType(headerRows, 'application/json');
          jsonBody.value = typeof capturedBody.raw === 'string'
            ? capturedBody.raw
            : JSON.stringify(capturedBody.value, null, 2);
        } else if (capturedBody.type === 'form' || capturedBody.type === 'multipart') {
          addCapturedContentType(
            headerRows,
            capturedBody.type === 'multipart' ? 'multipart/form-data' : 'application/x-www-form-urlencoded'
          );
          const importedFields = bodyFieldsFromCapturedValue(capturedBody.value);
          importedFileCount = importedFields.fileFieldCount;
          renderBodyFields(importedFields.fields);
        } else if (capturedBody.type === 'text') {
          rawBody.value = String(capturedBody.value || '');
        }
      }
      renderHeaderRows(headers, headerRows);
      responseMode.value = 'preview';
      updateBodyEditor();
      updateResponseEditor();
      bodySection.open = !['GET', 'HEAD'].includes(method.value) && Boolean(capturedBody);
      manualUrlDraft = captureManualUrlDraft();
      display(
        `已回填实际发送的 ${method.value} 请求,可继续编辑后发送;原接口请求配置已保留。${importedFileCount
          ? `捕获到 ${importedFileCount} 个附件字段,出于浏览器安全限制,请手动重新选择附件。`
          : ''}`,
        true
      );
    };

    const readJsonPath = (value, path) => {
      const segments = String(path || '')
        .replace(/\[(\d+)\]/g, '.$1')
        .split('.')
        .map((segment) => segment.trim())
        .filter(Boolean);
      let currentValue = value;
      for (const segment of segments) {
        if (currentValue === null || currentValue === undefined
          || !Object.prototype.hasOwnProperty.call(Object(currentValue), segment)) {
          return { found: false, value: undefined };
        }
        currentValue = currentValue[segment];
      }
      return { found: segments.length > 0, value: currentValue };
    };

    const parseBatchExpectedValue = (value) => {
      const text = String(value ?? '').trim();
      if (!text) {
        return '';
      }
      try {
        return JSON.parse(text);
      } catch {
        return text;
      }
    };

    const valuesAreEqual = (firstValue, secondValue) => {
      if (firstValue === secondValue) {
        return true;
      }
      try {
        return JSON.stringify(firstValue) === JSON.stringify(secondValue);
      } catch {
        return false;
      }
    };

    const evaluateBatchSuccess = (httpSuccess, responseBody, criteria) => {
      if (criteria.mode === 'http') {
        return { ok: httpSuccess, httpSuccess, conditionMatched: null };
      }

      const pathResult = readJsonPath(responseBody, criteria.path);
      const expectedValue = parseBatchExpectedValue(criteria.expected);
      let conditionMatched = false;
      switch (criteria.operator) {
        case 'not-equals':
          conditionMatched = pathResult.found && !valuesAreEqual(pathResult.value, expectedValue);
          break;
        case 'exists':
          conditionMatched = pathResult.found;
          break;
        case 'truthy':
          conditionMatched = pathResult.found && Boolean(pathResult.value);
          break;
        case 'falsy':
          conditionMatched = pathResult.found && !pathResult.value;
          break;
        case 'includes':
          conditionMatched = pathResult.found && String(pathResult.value ?? '').includes(String(expectedValue));
          break;
        case 'equals':
        default:
          conditionMatched = pathResult.found && valuesAreEqual(pathResult.value, expectedValue);
          break;
      }

      const ok = criteria.mode === 'json'
        ? conditionMatched
        : httpSuccess && conditionMatched;
      return {
        ok,
        httpSuccess,
        conditionMatched,
        responseCondition: {
          path: criteria.path,
          operator: criteria.operator,
          expected: expectedValue,
          found: pathResult.found,
          actual: pathResult.value
        }
      };
    };

    const replaceBatchPlaceholder = (text, token, replacement) => String(text ?? '').split(token).join(replacement);

    const requestStateUsesBatchToken = (requestState, token) => {
      const values = [
        requestState.url,
        requestState.jsonBody,
        requestState.rawBody,
        ...(requestState.params || []).flatMap((row) => [row.key, row.value]),
        ...(requestState.headers || []).flatMap((row) => [row.key, row.value]),
        ...(requestState.bodyFields || []).flatMap((field) => [field.key, field.value])
      ];
      return values.some((value) => String(value ?? '').includes(token));
    };

    const createBatchValueSets = (fields) => {
      if (!fields.length) {
        throw new Error('请至少添加一个批量变量。');
      }

      const seenNames = new Set();
      const normalizedFields = fields.map((field) => {
        const name = String(field.name || '').trim().replace(/[{}]/g, '');
        if (!name) {
          throw new Error('每个批量变量都需要填写名称,例如 userId。');
        }
        if (seenNames.has(name)) {
          throw new Error(`批量变量 ${name} 重复,请修改变量名称。`);
        }
        seenNames.add(name);
        const values = String(field.values || '').split(/[\r\n,;,;]+/)
          .map((value) => value.trim())
          .filter(Boolean);
        if (!values.length) {
          throw new Error(`批量变量 ${name} 至少需要填写一个参数值。`);
        }
        return { name, values };
      });

      const requestCount = normalizedFields[0].values.length;
      const unevenField = normalizedFields.find((field) => field.values.length !== requestCount);
      if (unevenField) {
        throw new Error(`批量变量 ${unevenField.name} 有 ${unevenField.values.length} 行有效值,应与其他变量的 ${requestCount} 行保持一致。`);
      }

      return Array.from({ length: requestCount }, (_, index) => Object.fromEntries(
        normalizedFields.map((field) => [field.name, field.values[index]])
      ));
    };

    const applyBatchValues = (requestState, replacements) => {
      const replaceAll = (value) => Object.entries(replacements).reduce(
        (currentValue, [name, replacement]) => replaceBatchPlaceholder(currentValue, `{{${name}}}`, replacement),
        String(value ?? '')
      );
      return {
        ...requestState,
        url: replaceAll(requestState.url),
        params: (requestState.params || []).map((row) => ({
          key: replaceAll(row.key),
          value: replaceAll(row.value)
        })),
        headers: (requestState.headers || []).map((row) => ({
          key: replaceAll(row.key),
          value: replaceAll(row.value)
        })),
        jsonBody: replaceAll(requestState.jsonBody),
        rawBody: replaceAll(requestState.rawBody),
        bodyFields: (requestState.bodyFields || []).map((field) => ({
          ...field,
          key: replaceAll(field.key),
          value: replaceAll(field.value)
        }))
      };
    };

    const prepareRequestForSending = (requestState) => {
      if (!requestState.url) {
        throw new Error('请输入请求 URL。');
      }
      return {
        requestState,
        requestUrl: appendParams(requestState.url, requestState.params),
        requestHeaders: buildRequestHeaders(requestState),
        requestBody: buildRequestBody(requestState)
      };
    };

    const sendPreparedRequest = (preparedRequest, options = {}) => new Promise((resolve) => {
      const { requestState, requestUrl, requestHeaders, requestBody } = preparedRequest;
      const requestTimeoutSeconds = panelSettings.timeoutSeconds;
      const isBatchRequest = options.batch === true;
      const resultBase = {
        index: options.index,
        values: options.values,
        method: requestState.method,
        url: requestUrl
      };
      const requestOptions = {
        method: requestState.method,
        url: requestUrl,
        headers: requestHeaders,
        onload(response) {
          const successful = response.status >= 200 && response.status < 400;
          if (requestState.responseMode === 'download') {
            if (!successful) {
              const message = `下载失败:HTTP ${response.status} ${response.statusText || ''}`.trim();
              if (!isBatchRequest) {
                display(message, true);
              }
              resolve({ ...resultBase, ok: false, status: response.status, statusText: response.statusText || '', message });
              return;
            }
            const fileName = downloadResponse(response, requestState.downloadFileName);
            if (!isBatchRequest) {
              display(`已开始下载:${fileName}`, true);
            }
            resolve({ ...resultBase, ok: true, status: response.status, fileName });
            return;
          }

          const responseBody = formatResponseBody(response.responseText);
          const successEvaluation = isBatchRequest && options.successCriteria
            ? evaluateBatchSuccess(successful, responseBody, options.successCriteria)
            : { ok: successful, httpSuccess: successful, conditionMatched: null };
          const result = {
            ...resultBase,
            ...successEvaluation,
            status: response.status,
            statusText: response.statusText || '',
            finalUrl: response.finalUrl,
            body: responseBody
          };
          if (!isBatchRequest) {
            display({
              status: response.status,
              statusText: response.statusText,
              finalUrl: response.finalUrl,
              responseHeaders: response.responseHeaders,
              body: result.body
            }, true);
          }
          resolve(result);
        },
        onerror(response) {
          const message = `网络错误:${response.status || 'unknown'} ${response.statusText || ''}`.trim();
          if (!isBatchRequest) {
            display(message, true);
          }
          resolve({ ...resultBase, ok: false, status: response.status || 0, statusText: response.statusText || '', message });
        },
        ontimeout() {
          const message = requestTimeoutSeconds > 0
            ? `请求超时(${requestTimeoutSeconds} 秒)。`
            : '请求超时。';
          if (!isBatchRequest) {
            display(message, true);
          }
          resolve({ ...resultBase, ok: false, status: 0, message });
        },
        onabort() {
          const message = '请求已取消。';
          if (!isBatchRequest) {
            display(message, true);
          }
          resolve({ ...resultBase, ok: false, status: 0, message });
        }
      };

      if (requestTimeoutSeconds > 0) {
        requestOptions.timeout = requestTimeoutSeconds * 1000;
      }
      if (requestState.responseMode === 'download') {
        requestOptions.responseType = 'blob';
      }
      if (requestBody !== undefined) {
        requestOptions.data = requestBody;
      }

      try {
        GM_xmlhttpRequest(requestOptions);
      } catch (error) {
        const message = `无法发送:${error.message}`;
        if (!isBatchRequest) {
          display(message, true);
        }
        resolve({ ...resultBase, ok: false, status: 0, message });
      }
    });

    const readRequestStateForSending = () => {
      const requestState = saveRequestState({ persist: !preservedRequestDraft });
      requestState.bodyFiles = readBodyFields(true)
        .filter((field) => field.type === 'file' && field.files.length);
      return requestState;
    };

    const sendSingleRequest = async () => {
      try {
        const preparedRequest = prepareRequestForSending(readRequestStateForSending());
        display(`正在发送 ${preparedRequest.requestState.method} ${preparedRequest.requestUrl}`);
        await sendPreparedRequest(preparedRequest);
      } catch (error) {
        display(`无法发送:${error.message}`);
      }
    };

    const sendBatchRequests = async () => {
      try {
        const requestState = readRequestStateForSending();
        if (requestState.responseMode === 'download') {
          throw new Error('批量请求暂不支持“下载响应文件”;请切换为在面板中查看响应。');
        }

        const successCriteria = {
          mode: ['http', 'json', 'http-and-json'].includes(requestState.batchSuccessMode)
            ? requestState.batchSuccessMode
            : 'http',
          path: String(requestState.batchSuccessPath || '').trim(),
          operator: ['equals', 'not-equals', 'exists', 'truthy', 'falsy', 'includes'].includes(requestState.batchSuccessOperator)
            ? requestState.batchSuccessOperator
            : 'equals',
          expected: requestState.batchSuccessExpected ?? '0'
        };
        if (successCriteria.mode !== 'http' && !successCriteria.path) {
          throw new Error('启用 JSON 成功判定时,请填写 JSON 路径,例如 code 或 data.success。');
        }

        const batchValueSets = createBatchValueSets(requestState.batchFields || []);
        const unusedTokens = (requestState.batchFields || [])
          .map((field) => String(field.name || '').trim().replace(/[{}]/g, ''))
          .filter((name) => name && !requestStateUsesBatchToken(requestState, `{{${name}}}`));
        if (unusedTokens.length) {
          throw new Error(`未找到占位符 ${unusedTokens.map((name) => `{{${name}}}`).join('、')}。请在 URL、参数、Header 或请求体中填写对应占位符。`);
        }

        batchSendButton.disabled = true;
        const results = [];
        const batchIntervalSeconds = normalizeBatchInterval(requestState.batchIntervalSeconds);
        let stoppedAfterSuccess = false;
        for (const [index, replacements] of batchValueSets.entries()) {
          const preparedRequest = prepareRequestForSending(applyBatchValues(requestState, replacements));
          display(`正在批量发送 ${index + 1}/${batchValueSets.length}:${preparedRequest.requestState.method} ${preparedRequest.requestUrl}`);
          const result = await sendPreparedRequest(preparedRequest, {
            batch: true,
            index: index + 1,
            values: replacements,
            successCriteria
          });
          results.push(result);
          if (requestState.batchExecutionMode === 'stop-on-success' && result.ok) {
            stoppedAfterSuccess = true;
            break;
          }
          if (batchIntervalSeconds > 0 && index < batchValueSets.length - 1) {
            display(`批量请求间隔:等待 ${batchIntervalSeconds} 秒后继续。`);
            await new Promise((resolve) => window.setTimeout(resolve, batchIntervalSeconds * 1000));
          }
        }
        const successCount = results.filter((result) => result.ok).length;
        display({
          mode: 'batch',
          execution: requestState.batchExecutionMode === 'stop-on-success'
            ? '任意一条成功后停止'
            : '全部执行',
          successCriteria,
          intervalSeconds: batchIntervalSeconds,
          variables: (requestState.batchFields || []).map((field) => `{{${field.name}}}`),
          requested: batchValueSets.length,
          executed: results.length,
          skipped: batchValueSets.length - results.length,
          stoppedAfterSuccess,
          successful: successCount,
          failed: results.length - successCount,
          results
        }, true);
      } catch (error) {
        display(`无法批量发送:${error.message}`);
      } finally {
        batchSendButton.disabled = false;
      }
    };

    const closePanelSettings = () => {
      panelSettingsPopup.hidden = true;
      panelSettingsButton.setAttribute('aria-expanded', 'false');
    };

    button.addEventListener('click', () => {
      if (draggedButton === button) {
        draggedButton = null;
        return;
      }
      const willOpen = !panel.classList.contains('open');
      panel.classList.toggle('open', willOpen);
      button.blur();
      requestAnimationFrame(updatePanelScrollHideButton);
      if (!willOpen) {
        closePanelSettings();
      }
    });
    showToolButton.addEventListener('click', () => {
      if (draggedButton === showToolButton) {
        draggedButton = null;
        return;
      }
      showToolButton.blur();
      restoreFloatingTool();
    });

    const collapseFloatingControl = (control, trigger, hideButton) => {
      control.addEventListener('pointerleave', () => {
        trigger.blur();
        hideButton.blur();
      });
    };

    collapseFloatingControl(floatingControl, button, mainHideButton);
    collapseFloatingControl(showToolControl, showToolButton, showHideButton);

    const bindDraggable = (trigger, control, setPosition) => {
      trigger.addEventListener('pointerdown', (event) => {
        if (event.button !== 0) {
          return;
        }
        const rect = control.getBoundingClientRect();
        dragState = {
          trigger,
          pointerId: event.pointerId,
          startX: event.clientX,
          startY: event.clientY,
          startLeft: rect.left,
          startTop: rect.top,
          didDrag: false
        };
        trigger.setPointerCapture(event.pointerId);
      });
      trigger.addEventListener('pointermove', (event) => {
        if (!dragState || dragState.trigger !== trigger || event.pointerId !== dragState.pointerId) {
          return;
        }

        const deltaX = event.clientX - dragState.startX;
        const deltaY = event.clientY - dragState.startY;
        if (!dragState.didDrag && Math.hypot(deltaX, deltaY) < 5) {
          return;
        }

        dragState.didDrag = true;
        setPosition(dragState.startLeft + deltaX, dragState.startTop + deltaY);
        event.preventDefault();
      });
      const finishDrag = (event) => {
        if (!dragState || dragState.trigger !== trigger || event.pointerId !== dragState.pointerId) {
          return;
        }
        if (trigger.hasPointerCapture(event.pointerId)) {
          trigger.releasePointerCapture(event.pointerId);
        }
        if (dragState.didDrag) {
          draggedButton = trigger;
          saveButtonState();
        }
        dragState = null;
      };
      trigger.addEventListener('pointerup', finishDrag);
      trigger.addEventListener('pointercancel', finishDrag);
    };

    bindDraggable(button, floatingControl, setButtonPosition);
    bindDraggable(showToolButton, showToolControl, setShowToolPosition);

    panelHeading.addEventListener('pointerdown', (event) => {
      if (event.button !== 0 || event.target.closest('button, input, label, output, .tm-panel-settings')) {
        return;
      }
      const rect = panel.getBoundingClientRect();
      const maximumLeft = Math.max(PANEL_EDGE_GAP, window.innerWidth - rect.width - PANEL_EDGE_GAP);
      const maximumTop = Math.max(PANEL_EDGE_GAP, window.innerHeight - rect.height - PANEL_EDGE_GAP);
      const startLeft = clamp(rect.left, PANEL_EDGE_GAP, maximumLeft);
      const startTop = clamp(rect.top, PANEL_EDGE_GAP, maximumTop);
      panelDragState = {
        pointerId: event.pointerId,
        startX: event.clientX,
        startY: event.clientY,
        startLeft,
        startTop,
        width: rect.width,
        height: rect.height
      };
      panel.style.left = `${Math.round(startLeft)}px`;
      panel.style.top = `${Math.round(startTop)}px`;
      panel.style.right = 'auto';
      panel.style.bottom = 'auto';
      panelHeading.setPointerCapture(event.pointerId);
      event.preventDefault();
    });
    panelHeading.addEventListener('pointermove', (event) => {
      if (!panelDragState || event.pointerId !== panelDragState.pointerId) {
        return;
      }
      const maximumLeft = Math.max(PANEL_EDGE_GAP, window.innerWidth - panelDragState.width - PANEL_EDGE_GAP);
      const maximumTop = Math.max(PANEL_EDGE_GAP, window.innerHeight - panelDragState.height - PANEL_EDGE_GAP);
      const left = clamp(panelDragState.startLeft + event.clientX - panelDragState.startX, PANEL_EDGE_GAP, maximumLeft);
      const top = clamp(panelDragState.startTop + event.clientY - panelDragState.startY, PANEL_EDGE_GAP, maximumTop);

      panel.style.left = `${Math.round(left)}px`;
      panel.style.top = `${Math.round(top)}px`;
      updatePanelScrollHideButton();
      event.preventDefault();
    });
    const finishPanelDrag = (event) => {
      if (!panelDragState || event.pointerId !== panelDragState.pointerId) {
        return;
      }
      if (panelHeading.hasPointerCapture(event.pointerId)) {
        panelHeading.releasePointerCapture(event.pointerId);
      }
      const rect = panel.getBoundingClientRect();
      panelLayout.left = Math.round(rect.left);
      panelLayout.top = Math.round(rect.top);
      panelLayout.width = Math.round(rect.width);
      panelLayout.height = Math.round(rect.height);
      panelLayout.positioned = true;
      savePanelLayout();
      panelDragState = null;
    };
    panelHeading.addEventListener('pointerup', finishPanelDrag);
    panelHeading.addEventListener('pointercancel', finishPanelDrag);

    const bindPanelResize = (resizeHandle) => {
      resizeHandle.addEventListener('pointerdown', (event) => {
        if (event.button !== 0) {
          return;
        }
        const rect = panel.getBoundingClientRect();
        const maximumLeft = Math.max(PANEL_EDGE_GAP, window.innerWidth - rect.width - PANEL_EDGE_GAP);
        const maximumTop = Math.max(PANEL_EDGE_GAP, window.innerHeight - rect.height - PANEL_EDGE_GAP);
        const startLeft = clamp(rect.left, PANEL_EDGE_GAP, maximumLeft);
        const startTop = clamp(rect.top, PANEL_EDGE_GAP, maximumTop);
        panelResizeState = {
          pointerId: event.pointerId,
          resizeHandle,
          direction: resizeHandle.dataset.panelResize,
          startX: event.clientX,
          startY: event.clientY,
          startWidth: rect.width,
          startHeight: rect.height,
          startLeft,
          startTop,
          startRight: startLeft + rect.width,
          startBottom: startTop + rect.height
        };
        panel.style.left = `${Math.round(startLeft)}px`;
        panel.style.top = `${Math.round(startTop)}px`;
        panel.style.right = 'auto';
        panel.style.bottom = 'auto';
        resizeHandle.setPointerCapture(event.pointerId);
        event.preventDefault();
      });
      resizeHandle.addEventListener('pointermove', (event) => {
        if (!panelResizeState || panelResizeState.resizeHandle !== resizeHandle
          || event.pointerId !== panelResizeState.pointerId) {
          return;
        }
        const minimumWidth = Math.min(360, Math.max(220, window.innerWidth - 20));
        const minimumHeight = Math.min(280, Math.max(180, window.innerHeight - 20));
        const deltaX = event.clientX - panelResizeState.startX;
        const deltaY = event.clientY - panelResizeState.startY;
        const direction = panelResizeState.direction;
        let left = panelResizeState.startLeft;
        let top = panelResizeState.startTop;
        let width = panelResizeState.startWidth;
        let height = panelResizeState.startHeight;

        if (direction.includes('e')) {
          const maximumWidth = Math.max(minimumWidth, window.innerWidth - panelResizeState.startLeft - PANEL_EDGE_GAP);
          width = clamp(panelResizeState.startWidth + deltaX, minimumWidth, maximumWidth);
        } else if (direction.includes('w')) {
          const maximumWidth = Math.max(minimumWidth, panelResizeState.startRight - PANEL_EDGE_GAP);
          width = clamp(panelResizeState.startWidth - deltaX, minimumWidth, maximumWidth);
          left = panelResizeState.startRight - width;
        }

        if (direction.includes('s')) {
          const maximumHeight = Math.max(minimumHeight, window.innerHeight - panelResizeState.startTop - PANEL_EDGE_GAP);
          height = clamp(panelResizeState.startHeight + deltaY, minimumHeight, maximumHeight);
        } else if (direction.includes('n')) {
          const maximumHeight = Math.max(minimumHeight, panelResizeState.startBottom - PANEL_EDGE_GAP);
          height = clamp(panelResizeState.startHeight - deltaY, minimumHeight, maximumHeight);
          top = panelResizeState.startBottom - height;
        }

        panel.style.left = `${Math.round(left)}px`;
        panel.style.top = `${Math.round(top)}px`;
        panel.style.width = `${Math.round(width)}px`;
        panel.style.height = `${Math.round(height)}px`;
        updatePanelScrollHideButton();
        event.preventDefault();
      });
      const finishPanelResize = (event) => {
        if (!panelResizeState || panelResizeState.resizeHandle !== resizeHandle
          || event.pointerId !== panelResizeState.pointerId) {
          return;
        }
        if (resizeHandle.hasPointerCapture(event.pointerId)) {
          resizeHandle.releasePointerCapture(event.pointerId);
        }
        const rect = panel.getBoundingClientRect();
        panelLayout.left = Math.round(rect.left);
        panelLayout.top = Math.round(rect.top);
        panelLayout.width = Math.round(rect.width);
        panelLayout.height = Math.round(rect.height);
        panelLayout.positioned = true;
        savePanelLayout();
        panelResizeState = null;
      };
      resizeHandle.addEventListener('pointerup', finishPanelResize);
      resizeHandle.addEventListener('pointercancel', finishPanelResize);
    };
    panelResizeHandles.forEach(bindPanelResize);

    const hideFloatingTool = () => {
      panel.classList.remove('open');
      panelScrollHideButton.hidden = true;
      closePanelSettings();
      buttonState.hidden = true;
      buttonState.showHidden = false;
      applyButtonState();
      saveButtonState();
    };
    const hideShowTool = () => {
      buttonState.showHidden = true;
      applyButtonState();
      saveButtonState();
    };
    mainHideButton.addEventListener('click', hideFloatingTool);
    showHideButton.addEventListener('click', hideShowTool);
    panelHideToolButton.addEventListener('click', hideFloatingTool);
    panelScrollHideButton.addEventListener('click', hideFloatingTool);
    panel.addEventListener('scroll', updatePanelScrollHideButton, { passive: true });
    const restoreFloatingTool = () => {
      buttonState.hidden = false;
      buttonState.showHidden = false;
      applyButtonState();
      saveButtonState();
      panel.classList.add('open');
      requestAnimationFrame(updatePanelScrollHideButton);
    };
    window.addEventListener('keydown', (event) => {
      if (event.altKey && event.shiftKey && event.code === 'KeyI') {
        event.preventDefault();
        restoreFloatingTool();
      }
    });
    if (typeof GM_registerMenuCommand === 'function') {
      GM_registerMenuCommand('显示接口工具', restoreFloatingTool);
    }
    window.addEventListener('resize', () => {
      if (!buttonState.hidden && buttonState.mainPositioned
        && buttonState.left !== null && buttonState.top !== null) {
        setButtonPosition(buttonState.left, buttonState.top);
      }
      if (buttonState.hidden && !buttonState.showHidden && buttonState.showPositioned
        && buttonState.showLeft !== null && buttonState.showTop !== null) {
        setShowToolPosition(buttonState.showLeft, buttonState.showTop);
      }
      if (panelLayout.positioned || panelLayout.width !== null || panelLayout.height !== null) {
        applyPanelLayout();
        savePanelLayout();
      }
      updatePanelScrollHideButton();
      saveButtonState();
    });
    urlModeInputs.forEach((input) => {
      input.addEventListener('click', () => {
        if (input.value !== 'current-page' || !input.checked || activeUrlMode !== 'current-page') {
          return;
        }
        syncCurrentPageRequest();
        updateBodyEditor();
        saveRequestState({ persist: !preservedRequestDraft });
        display('已重新载入当前浏览器地址;可继续修改请求 URL 和参数。');
      });
      input.addEventListener('change', () => {
        updateUrlMode(true);
        saveRequestState({ persist: !preservedRequestDraft });
      });
    });
    method.addEventListener('change', updateBodyEditor);
    headers.addEventListener('change', updateBodyEditor);
    headers.addEventListener('input', updateBodyEditor);
    responseMode.addEventListener('change', updateResponseEditor);
    batchExecutionMode.addEventListener('change', () => {
      saveRequestState({ persist: !preservedRequestDraft });
    });
    batchIntervalInput.addEventListener('change', () => {
      batchIntervalInput.value = String(normalizeBatchInterval(batchIntervalInput.value));
      saveRequestState({ persist: !preservedRequestDraft });
    });
    batchSuccessMode.addEventListener('change', () => {
      updateBatchSuccessEditor();
      saveRequestState({ persist: !preservedRequestDraft });
    });
    batchSuccessOperator.addEventListener('change', () => {
      updateBatchSuccessEditor();
      saveRequestState({ persist: !preservedRequestDraft });
    });
    panelSettingsButton.addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      const willOpen = panelSettingsPopup.hidden;
      panelSettingsPopup.hidden = !willOpen;
      panelSettingsButton.setAttribute('aria-expanded', String(willOpen));
      if (willOpen) {
        panelOpacityInput.focus({ preventScroll: true });
      }
    });
    document.addEventListener('pointerdown', (event) => {
      const eventPath = typeof event.composedPath === 'function' ? event.composedPath() : [];
      if (activeCurlMenu && !eventPath.includes(activeCurlMenu.menu) && !eventPath.includes(activeCurlMenu.trigger)) {
        activeCurlMenu.close();
      }
      if (!panelSettingsPopup.hidden && !eventPath.includes(panelSettingsWrap)) {
        closePanelSettings();
      }
    });
    window.addEventListener('keydown', (event) => {
      if (event.key === 'Escape' && activeCurlMenu) {
        const menuToClose = activeCurlMenu;
        menuToClose.close();
        menuToClose.trigger.focus({ preventScroll: true });
        event.preventDefault();
      }
      if (event.key === 'Escape' && !panelSettingsPopup.hidden) {
        closePanelSettings();
        panelSettingsButton.focus({ preventScroll: true });
      }
    });
    panelOpacityInput.addEventListener('input', () => {
      applyPanelOpacity(panelOpacityInput.value);
      savePanelSettings(panelSettings);
    });
    requestTimeoutInput.addEventListener('change', () => {
      applyRequestTimeout(requestTimeoutInput.value);
      savePanelSettings(panelSettings);
    });
    curlTargetInput.addEventListener('change', () => {
      applyCurlTarget(curlTargetInput.value);
      savePanelSettings(panelSettings);
    });
    debugRecordLimitInput.addEventListener('change', () => {
      applyDebugRecordLimit(debugRecordLimitInput.value);
      trimDebugRecords();
      renderDebugRecords();
      scheduleDebugPersistence();
      savePanelSettings(panelSettings);
    });
    debugPersistenceInput.addEventListener('change', () => {
      applyDebugRecordPersistence(debugPersistenceInput.checked);
      if (panelSettings.persistDebugRecords) {
        scheduleDebugPersistence();
      }
      savePanelSettings(panelSettings);
    });
    panelResetLayoutButton.addEventListener('click', () => {
      resetPanelLayout();
      panelSettingsHint.textContent = '已恢复默认位置和大小。';
      window.setTimeout(() => {
        panelSettingsHint.textContent = '不透明度只影响外层背景;开启保留后会在当前网站保存最近 50 条记录,URL 参数和常见授权请求头会脱敏。';
      }, 1500);
    });
    moduleTabs.forEach((tab) => {
      tab.addEventListener('click', () => activateModule(tab.dataset.moduleTab));
    });
    debugEnabledInput.addEventListener('change', () => {
      updateDebugEnabledState(debugEnabledInput.checked, true);
    });
    debugFilterInput.addEventListener('input', renderDebugRecords);
    debugLog.addEventListener('scroll', () => activeCurlMenu?.close(), { passive: true });
    window.addEventListener('resize', () => activeCurlMenu?.close(), { passive: true });
    clearDebugButton.addEventListener('click', clearDebugRecords);
    window.addEventListener(DEBUG_EVENT_NAME, receiveDebugEvent);
    window.addEventListener('message', receiveFrameDebugMessage);
    window.dispatchEvent(new CustomEvent(DEBUG_SYNC_EVENT_NAME));
    window.addEventListener('popstate', recordCurrentPageAddress);
    window.addEventListener('hashchange', recordCurrentPageAddress);
    window.addEventListener('pageshow', recordCurrentPageAddress);
    restorePersistedDebugRecords();
    updateDebugEnabledState(loadRewriteDebugEnabled());
    renderDebugRecords();
    const bindSummaryAction = (actionButton, action) => {
      actionButton.addEventListener('click', (event) => {
        event.preventDefault();
        event.stopPropagation();
        const section = actionButton.closest('details');
        if (section) {
          section.open = true;
        }
        action();
      });
    };
    bindSummaryAction(get('#tm-add-param'), () => addRow(
      params, {}, { key: '参数名,例如 page', value: '参数值,例如 1' }
    ));
    bindSummaryAction(get('#tm-add-header'), () => addHeaderRow(headers));
    bindSummaryAction(addBodyFieldButton, () => addBodyField());
    bindSummaryAction(addBatchFieldButton, () => addBatchField());
    bindSummaryAction(get('#tm-add-rule'), () => createRuleCard());

    restoreCapturedDraftButton.addEventListener('click', () => {
      if (!preservedRequestDraft) {
        return;
      }
      restoreRequestDraft(preservedRequestDraft);
      preservedRequestDraft = null;
      capturedEditBanner.hidden = true;
      saveRequestState();
      display('已恢复原接口请求配置。', true);
    });

    get('#tm-save').addEventListener('click', () => {
      try {
        saveRequestState();
        const replacedOriginalDraft = Boolean(preservedRequestDraft);
        preservedRequestDraft = null;
        capturedEditBanner.hidden = true;
        display(replacedOriginalDraft ? '捕获请求已保存为当前接口请求配置。' : '接口请求配置已保存。');
      } catch (error) {
        display(`保存失败:${error.message}`);
      }
    });

    get('#tm-save-rules').addEventListener('click', () => {
      try {
        const parsedRules = readRuleCards();
        const nextState = { ...getCurrentState(), rules: JSON.stringify(parsedRules) };
        saveState(nextState);
        localStorage.setItem(RULES_KEY, nextState.rules);
        window.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: parsedRules }));
        state.rules = nextState.rules;
        displayRewriteStatus('改写规则已保存。此后发出的网页请求会立即按新规则处理;如需拦截页面初始化请求,请保存后刷新页面。');
      } catch (error) {
        displayRewriteStatus(`保存失败:${error.message}`, true);
      }
    });

    get('#tm-example').addEventListener('click', () => {
      createRuleCard({
        enabled: true,
        match: '/api/v1/old-path',
        matchType: 'contains',
        methods: ['POST'],
        replaceUrl: '/api/v2/new-path',
        headers: { Authorization: 'Bearer YOUR_TOKEN' },
        bodyMode: 'replace',
        body: '{"source":"tampermonkey"}'
      });
      displayRewriteStatus('已添加示例规则。填写目标接口和 Token 后,点击“保存改写规则”。');
    });

    get('#tm-send').addEventListener('click', () => {
      void sendSingleRequest();
    });
    batchSendButton.addEventListener('click', () => {
      void sendBatchRequests();
    });
  }

  if (document.body) {
    createUi();
  } else {
    window.addEventListener('DOMContentLoaded', createUi, { once: true });
  }
})();