Send API requests and rewrite this site's fetch/XHR requests and responses.
// ==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 });
}
})();