API Console and Request Rewriter

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

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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