Files
2025-12-25 18:04:10 +08:00

554 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* API Tester - 前端应用主逻辑
* @author huazm
*/
// ==================== 粒子背景 ====================
class ParticleBackground {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.particles = [];
this.particleCount = 80;
this.resize();
this.init();
this.animate();
window.addEventListener('resize', () => this.resize());
}
resize() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
}
init() {
this.particles = [];
for (let i = 0; i < this.particleCount; i++) {
this.particles.push({
x: Math.random() * this.canvas.width,
y: Math.random() * this.canvas.height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
size: Math.random() * 2 + 1,
opacity: Math.random() * 0.5 + 0.2
});
}
}
animate() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 获取当前主题色
const style = getComputedStyle(document.documentElement);
const accentColor = style.getPropertyValue('--accent-primary').trim() || '#3b82f6';
this.particles.forEach((p, i) => {
// 更新位置
p.x += p.vx;
p.y += p.vy;
// 边界检测
if (p.x < 0 || p.x > this.canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > this.canvas.height) p.vy *= -1;
// 绘制粒子
this.ctx.beginPath();
this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
this.ctx.fillStyle = accentColor.replace(')', `, ${p.opacity})`).replace('rgb', 'rgba');
this.ctx.fill();
// 连接附近粒子
for (let j = i + 1; j < this.particles.length; j++) {
const p2 = this.particles[j];
const dx = p.x - p2.x;
const dy = p.y - p2.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 150) {
this.ctx.beginPath();
this.ctx.moveTo(p.x, p.y);
this.ctx.lineTo(p2.x, p2.y);
this.ctx.strokeStyle = accentColor.replace(')', `, ${0.1 * (1 - dist / 150)})`).replace('rgb', 'rgba');
this.ctx.stroke();
}
}
});
requestAnimationFrame(() => this.animate());
}
}
// ==================== 主题管理 ====================
const themes = ['default', 'nebula', 'matrix', 'cyber', 'frost'];
let currentThemeIndex = 0;
function toggleTheme() {
currentThemeIndex = (currentThemeIndex + 1) % themes.length;
const theme = themes[currentThemeIndex];
if (theme === 'default') {
document.documentElement.removeAttribute('data-theme');
} else {
document.documentElement.setAttribute('data-theme', theme);
}
localStorage.setItem('api-tester-theme', theme);
}
function loadTheme() {
const saved = localStorage.getItem('api-tester-theme');
if (saved && themes.includes(saved)) {
currentThemeIndex = themes.indexOf(saved);
if (saved !== 'default') {
document.documentElement.setAttribute('data-theme', saved);
}
}
}
// ==================== API 请求 ====================
async function sendRequest() {
const sendBtn = document.getElementById('send-btn');
const method = document.getElementById('method-select').value;
const url = document.getElementById('url-input').value.trim();
if (!url) {
showToast('请输入 URL', 'error');
return;
}
// 收集请求数据
const headers = collectKeyValues('headers-editor');
const params = collectKeyValues('params-editor');
const bodyType = document.querySelector('input[name="bodyType"]:checked')?.value || 'none';
const body = bodyType !== 'none' ? document.getElementById('body-editor').value : null;
const authType = document.getElementById('auth-type').value;
const authConfig = collectAuthConfig(authType);
// 显示加载状态
sendBtn.classList.add('loading');
try {
const response = await fetch('/api/request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
method, url, headers, params, body,
bodyType: bodyType !== 'none' ? bodyType : null,
authType: authType !== 'none' ? authType : null,
authConfig
})
});
const result = await response.json();
displayResponse(result);
} catch (error) {
displayResponse({ success: false, error: error.message });
} finally {
sendBtn.classList.remove('loading');
}
}
// ==================== 辅助函数 ====================
function collectKeyValues(editorId) {
const editor = document.getElementById(editorId);
const rows = editor.querySelectorAll('.kv-row');
const result = {};
rows.forEach(row => {
const key = row.querySelector('.kv-key')?.value.trim();
const value = row.querySelector('.kv-value')?.value.trim();
if (key) result[key] = value || '';
});
return Object.keys(result).length > 0 ? result : null;
}
function collectAuthConfig(authType) {
if (authType === 'none') return null;
const config = {};
const container = document.getElementById('auth-config');
const inputs = container.querySelectorAll('input');
inputs.forEach(input => {
if (input.dataset.key) {
config[input.dataset.key] = input.value;
}
});
return config;
}
function displayResponse(result) {
const statusCode = document.getElementById('status-code');
const statusText = document.getElementById('status-text');
const responseTime = document.getElementById('response-time');
const responseSize = document.getElementById('response-size');
const responseBody = document.getElementById('response-body');
const responseHeaders = document.getElementById('response-headers-view');
if (result.success) {
const code = result.statusCode;
statusCode.textContent = code;
statusCode.className = 'status-code ' + (code < 300 ? 'success' : code < 400 ? 'redirect' : 'error');
statusText.textContent = getStatusText(code);
responseTime.textContent = `${result.duration}ms`;
responseSize.textContent = formatBytes(result.size || 0);
// 格式化响应体
let body = result.body;
try {
const parsed = JSON.parse(body);
body = JSON.stringify(parsed, null, 2);
} catch {}
responseBody.querySelector('code').textContent = body;
// 响应头
if (result.headers) {
responseHeaders.querySelector('code').textContent =
JSON.stringify(result.headers, null, 2);
}
} else {
statusCode.textContent = 'ERR';
statusCode.className = 'status-code error';
statusText.textContent = result.error || '请求失败';
responseTime.textContent = result.duration ? `${result.duration}ms` : '--';
responseSize.textContent = '--';
responseBody.querySelector('code').textContent = result.error || '请求失败';
}
}
function getStatusText(code) {
const statusTexts = {
200: 'OK', 201: 'Created', 204: 'No Content',
301: 'Moved Permanently', 302: 'Found', 304: 'Not Modified',
400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden',
404: 'Not Found', 405: 'Method Not Allowed', 500: 'Internal Server Error',
502: 'Bad Gateway', 503: 'Service Unavailable'
};
return statusTexts[code] || 'Unknown';
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
padding: 12px 24px;
background: var(--bg-tertiary);
border: 1px solid var(--${type === 'error' ? 'error' : 'accent-primary'});
color: var(--text-primary);
border-radius: 8px;
z-index: 9999;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000);
}
// ==================== 认证配置 UI ====================
function updateAuthUI(authType) {
const container = document.getElementById('auth-config');
container.innerHTML = '';
const configs = {
bearer: [{ key: 'token', label: 'Token', type: 'password' }],
basic: [
{ key: 'username', label: '用户名', type: 'text' },
{ key: 'password', label: '密码', type: 'password' }
],
apikey: [
{ key: 'key', label: 'Key Name', type: 'text' },
{ key: 'value', label: 'Key Value', type: 'password' },
{ key: 'location', label: '位置 (header/query)', type: 'text', default: 'header' }
]
};
const fields = configs[authType] || [];
fields.forEach(field => {
const group = document.createElement('div');
group.className = 'auth-input-group';
group.innerHTML = `
<label>${field.label}</label>
<input type="${field.type}" data-key="${field.key}"
value="${field.default || ''}" placeholder="${field.label}">
`;
container.appendChild(group);
});
}
// ==================== Key-Value 编辑器 ====================
function addKVRow(editorId) {
const editor = document.getElementById(editorId);
const row = document.createElement('div');
row.className = 'kv-row';
row.innerHTML = `
<input type="text" class="kv-key" placeholder="Key">
<input type="text" class="kv-value" placeholder="Value">
<button class="kv-remove">×</button>
`;
editor.appendChild(row);
row.querySelector('.kv-remove').addEventListener('click', () => row.remove());
}
// ==================== 标签页切换 ====================
function setupTabs() {
// 请求配置标签页
document.querySelectorAll('.request-tabs .tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.request-tabs .tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.request-content .tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`${btn.dataset.tab}-content`).classList.add('active');
});
});
// 响应标签页
document.querySelectorAll('.response-tabs .tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.response-tabs .tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const isBody = btn.dataset.tab === 'response-body';
document.getElementById('response-body').classList.toggle('hidden', !isBody);
document.getElementById('response-headers-view').classList.toggle('hidden', isBody);
});
});
// 主导航标签页
document.querySelectorAll('.header-nav .nav-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.header-nav .nav-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// 切换面板显示
const tab = btn.dataset.tab;
document.getElementById('request-panel').classList.toggle('hidden', tab !== 'request');
document.getElementById('response-panel').classList.toggle('hidden', tab !== 'request');
document.getElementById('history-panel').classList.toggle('hidden', tab !== 'history');
if (tab === 'history') loadHistory();
});
});
}
// ==================== 历史记录 ====================
async function loadHistory() {
const list = document.getElementById('history-list');
list.innerHTML = '<div style="text-align:center;color:var(--text-muted);">加载中...</div>';
try {
const response = await fetch('/api/history?size=50');
const result = await response.json();
if (result.success && result.data.length > 0) {
list.innerHTML = result.data.map(item => `
<div class="history-item" data-id="${item.id}" onclick="loadHistoryItem(${item.id})">
<span class="history-method ${item.method.toLowerCase()}">${item.method}</span>
<span class="history-url">${item.url}</span>
<span class="history-status">${item.statusCode || '--'}</span>
<span class="history-time">${new Date(item.createdAt).toLocaleString()}</span>
</div>
`).join('');
} else {
list.innerHTML = '<div style="text-align:center;color:var(--text-muted);">暂无历史记录</div>';
}
} catch (error) {
list.innerHTML = '<div style="text-align:center;color:var(--error);">加载失败</div>';
}
}
async function loadHistoryItem(id) {
try {
const response = await fetch(`/api/history?size=1000`);
const result = await response.json();
const item = result.data.find(h => h.id === id);
if (item) {
document.getElementById('method-select').value = item.method;
document.getElementById('url-input').value = item.url;
// 切换回请求面板
document.querySelectorAll('.header-nav .nav-btn').forEach(b => b.classList.remove('active'));
document.querySelector('.header-nav .nav-btn[data-tab="request"]').classList.add('active');
document.getElementById('request-panel').classList.remove('hidden');
document.getElementById('response-panel').classList.remove('hidden');
document.getElementById('history-panel').classList.add('hidden');
showToast('已加载历史记录', 'info');
}
} catch (error) {
showToast('加载失败', 'error');
}
}
// ==================== 语音输入 ====================
function setupVoiceInput() {
const voiceBtn = document.getElementById('voice-input');
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
voiceBtn.style.display = 'none';
return;
}
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.lang = 'zh-CN';
recognition.continuous = false;
let isRecording = false;
voiceBtn.addEventListener('click', () => {
if (isRecording) {
recognition.stop();
voiceBtn.classList.remove('recording');
isRecording = false;
} else {
recognition.start();
voiceBtn.classList.add('recording');
isRecording = true;
}
});
recognition.onresult = (event) => {
const text = event.results[0][0].transcript;
const urlInput = document.getElementById('url-input');
urlInput.value = text;
voiceBtn.classList.remove('recording');
isRecording = false;
showToast('语音识别成功', 'info');
};
recognition.onerror = () => {
voiceBtn.classList.remove('recording');
isRecording = false;
showToast('语音识别失败', 'error');
};
}
// ==================== JSON 格式化 ====================
function setupFormatButton() {
document.getElementById('format-json-btn').addEventListener('click', () => {
const editor = document.getElementById('body-editor');
try {
const parsed = JSON.parse(editor.value);
editor.value = JSON.stringify(parsed, null, 2);
showToast('格式化成功', 'info');
} catch {
showToast('JSON 格式错误', 'error');
}
});
}
// ==================== 预设按钮 ====================
function setupPresets() {
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.key;
const value = btn.dataset.value;
// 添加到 headers 编辑器
const editor = document.getElementById('headers-editor');
const rows = editor.querySelectorAll('.kv-row');
// 检查是否已存在该 key
let existingRow = null;
for (const row of rows) {
const keyInput = row.querySelector('.kv-key');
if (keyInput.value === key) {
existingRow = row;
break;
}
}
if (existingRow) {
// 更新现有行
const valueInput = existingRow.querySelector('.kv-value');
valueInput.value = value;
valueInput.focus();
// 如果是鉴权类型,光标定位到值末尾方便输入 token
if (btn.classList.contains('auth-preset')) {
valueInput.setSelectionRange(value.length, value.length);
}
} else {
// 添加新行
addKVRow('headers-editor');
const newRow = editor.lastElementChild;
newRow.querySelector('.kv-key').value = key;
const valueInput = newRow.querySelector('.kv-value');
valueInput.value = value;
// 如果是鉴权或空值类型,自动聚焦到值输入框
if (btn.classList.contains('auth-preset') || value === '') {
valueInput.focus();
valueInput.setSelectionRange(value.length, value.length);
}
}
showToast(`已添加 ${key}`, 'info');
});
});
}
// ==================== 初始化 ====================
document.addEventListener('DOMContentLoaded', () => {
// 初始化粒子背景
new ParticleBackground(document.getElementById('particles-canvas'));
// 加载主题
loadTheme();
// 设置事件监听
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
document.getElementById('send-btn').addEventListener('click', sendRequest);
document.getElementById('auth-type').addEventListener('change', (e) => updateAuthUI(e.target.value));
// 添加行按钮
document.querySelectorAll('.add-row-btn').forEach(btn => {
btn.addEventListener('click', () => addKVRow(btn.dataset.target));
});
// 删除行按钮
document.querySelectorAll('.kv-remove').forEach(btn => {
btn.addEventListener('click', () => btn.parentElement.remove());
});
// 清空历史
document.getElementById('clear-history')?.addEventListener('click', async () => {
if (confirm('确定要清空所有历史记录吗?')) {
await fetch('/api/history/clear', { method: 'DELETE' });
loadHistory();
}
});
// 设置标签页
setupTabs();
setupVoiceInput();
setupFormatButton();
setupPresets();
// 键盘快捷键
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
sendRequest();
}
});
});