工具助手添加

This commit is contained in:
2025-12-25 18:04:10 +08:00
parent f4bc9f6dab
commit 98081456b7
31 changed files with 4306 additions and 0 deletions
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
"""
HTTP 接口测试工具
Flask 应用入口
@author huazm
"""
import os
import sys
# 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from flask import Flask, render_template
from flask_cors import CORS
from config import Config
from models import db
from routes import api_bp
def create_app():
"""创建 Flask 应用"""
app = Flask(__name__)
app.config.from_object(Config)
# 初始化扩展
CORS(app)
db.init_app(app)
# 注册蓝图
app.register_blueprint(api_bp)
# 创建数据库表
with app.app_context():
db.create_all()
# 主页路由
@app.route('/')
def index():
return render_template('index.html')
return app
# 创建应用实例
app = create_app()
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5050, debug=True)
+33
View File
@@ -0,0 +1,33 @@
"""
配置文件
HTTP 接口测试工具的配置项
@author huazm
"""
import os
# 基础路径
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, 'data')
# 确保数据目录存在
os.makedirs(DATA_DIR, exist_ok=True)
class Config:
"""基础配置类"""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'api-tester-secret-key-2024'
SQLALCHEMY_DATABASE_URI = f'sqlite:///{os.path.join(DATA_DIR, "api_tester.db")}'
SQLALCHEMY_TRACK_MODIFICATIONS = False
# HTTP 客户端配置
REQUEST_TIMEOUT = 30 # 请求超时时间(秒)
MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 最大响应大小(10MB
# 历史记录配置
MAX_HISTORY_RECORDS = 1000 # 最大历史记录数
# 批量请求配置
MAX_BATCH_SIZE = 100 # 最大批量请求数
DEFAULT_BATCH_INTERVAL = 100 # 默认请求间隔(毫秒)
+9
View File
@@ -0,0 +1,9 @@
"""
数据模型模块
@author huazm
"""
from .database import db, RequestHistory, Collection, Environment
__all__ = ['db', 'RequestHistory', 'Collection', 'Environment']
Binary file not shown.
Binary file not shown.
+118
View File
@@ -0,0 +1,118 @@
"""
数据库模型定义
包含请求历史、收藏夹、环境变量等模型
@author huazm
"""
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class RequestHistory(db.Model):
"""请求历史记录模型"""
__tablename__ = 'request_history'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
method = db.Column(db.String(10), nullable=False)
url = db.Column(db.Text, nullable=False)
headers = db.Column(db.Text) # JSON 格式
params = db.Column(db.Text) # JSON 格式
body = db.Column(db.Text)
body_type = db.Column(db.String(20)) # json, form, xml, raw
auth_type = db.Column(db.String(20)) # bearer, basic, apikey, oauth2
auth_config = db.Column(db.Text) # JSON 格式
# 响应信息
response_body = db.Column(db.Text)
response_headers = db.Column(db.Text) # JSON 格式
status_code = db.Column(db.Integer)
duration = db.Column(db.Float) # 请求耗时(毫秒)
# 元信息
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'method': self.method,
'url': self.url,
'headers': self.headers,
'params': self.params,
'body': self.body,
'bodyType': self.body_type,
'authType': self.auth_type,
'authConfig': self.auth_config,
'responseBody': self.response_body,
'responseHeaders': self.response_headers,
'statusCode': self.status_code,
'duration': self.duration,
'createdAt': self.created_at.isoformat() if self.created_at else None
}
class Collection(db.Model):
"""收藏夹模型"""
__tablename__ = 'collections'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
folder = db.Column(db.String(100)) # 文件夹分组
method = db.Column(db.String(10), nullable=False)
url = db.Column(db.Text, nullable=False)
headers = db.Column(db.Text)
params = db.Column(db.Text)
body = db.Column(db.Text)
body_type = db.Column(db.String(20))
auth_type = db.Column(db.String(20))
auth_config = db.Column(db.Text)
tags = db.Column(db.Text) # JSON 数组
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'folder': self.folder,
'method': self.method,
'url': self.url,
'headers': self.headers,
'params': self.params,
'body': self.body,
'bodyType': self.body_type,
'authType': self.auth_type,
'authConfig': self.auth_config,
'tags': self.tags,
'createdAt': self.created_at.isoformat() if self.created_at else None,
'updatedAt': self.updated_at.isoformat() if self.updated_at else None
}
class Environment(db.Model):
"""环境变量模型"""
__tablename__ = 'environments'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(50), nullable=False, unique=True)
variables = db.Column(db.Text) # JSON 格式 {"key": "value"}
is_active = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
"""转换为字典"""
return {
'id': self.id,
'name': self.name,
'variables': self.variables,
'isActive': self.is_active,
'createdAt': self.created_at.isoformat() if self.created_at else None
}
+14
View File
@@ -0,0 +1,14 @@
# Flask 核心
Flask==3.0.0
Flask-CORS==4.0.0
# HTTP 客户端
requests==2.31.0
httpx==0.25.2
# 数据库
Flask-SQLAlchemy==3.1.1
# 工具
python-dotenv==1.0.0
+9
View File
@@ -0,0 +1,9 @@
"""
路由模块
@author huazm
"""
from .api import api_bp
__all__ = ['api_bp']
Binary file not shown.
Binary file not shown.
+324
View File
@@ -0,0 +1,324 @@
"""
API 路由
处理 HTTP 请求、历史记录、收藏夹等 API
@author huazm
"""
import json
import re
from flask import Blueprint, request, jsonify
from models import db, RequestHistory, Collection, Environment
from services import HttpClient
api_bp = Blueprint('api', __name__, url_prefix='/api')
http_client = HttpClient()
@api_bp.route('/request', methods=['POST'])
def send_request():
"""发送 HTTP 请求"""
data = request.get_json()
if not data or not data.get('url'):
return jsonify({'success': False, 'error': '请提供 URL'}), 400
# 解析请求参数
method = data.get('method', 'GET')
url = data.get('url')
headers = data.get('headers')
params = data.get('params')
body = data.get('body')
body_type = data.get('bodyType', 'json')
auth_type = data.get('authType')
auth_config = data.get('authConfig')
# 解析 headers 和 params(如果是字符串)
if isinstance(headers, str):
try:
headers = json.loads(headers)
except:
headers = {}
if isinstance(params, str):
try:
params = json.loads(params)
except:
params = {}
if isinstance(auth_config, str):
try:
auth_config = json.loads(auth_config)
except:
auth_config = {}
# 发送请求
result = http_client.send_request(
method=method,
url=url,
headers=headers,
params=params,
body=body,
body_type=body_type,
auth_type=auth_type,
auth_config=auth_config
)
# 保存到历史记录
if data.get('saveHistory', True):
history = RequestHistory(
method=method,
url=url,
headers=json.dumps(headers) if headers else None,
params=json.dumps(params) if params else None,
body=body,
body_type=body_type,
auth_type=auth_type,
auth_config=json.dumps(auth_config) if auth_config else None,
response_body=result.get('body'),
response_headers=json.dumps(result.get('headers')) if result.get('headers') else None,
status_code=result.get('statusCode'),
duration=result.get('duration')
)
db.session.add(history)
db.session.commit()
result['historyId'] = history.id
return jsonify(result)
@api_bp.route('/batch', methods=['POST'])
def batch_request():
"""批量发送请求"""
data = request.get_json()
if not data or not data.get('requests'):
return jsonify({'success': False, 'error': '请提供请求列表'}), 400
requests_list = data.get('requests', [])
interval = data.get('interval', 100)
results = http_client.batch_request(requests_list, interval)
return jsonify({
'success': True,
'results': results,
'total': len(results),
'successCount': sum(1 for r in results if r.get('success')),
'failCount': sum(1 for r in results if not r.get('success'))
})
@api_bp.route('/history', methods=['GET'])
def get_history():
"""获取历史记录"""
page = request.args.get('page', 1, type=int)
size = request.args.get('size', 20, type=int)
search = request.args.get('search', '')
method = request.args.get('method', '')
query = RequestHistory.query
if search:
query = query.filter(RequestHistory.url.contains(search))
if method:
query = query.filter(RequestHistory.method == method.upper())
query = query.order_by(RequestHistory.created_at.desc())
pagination = query.paginate(page=page, per_page=size, error_out=False)
return jsonify({
'success': True,
'data': [h.to_dict() for h in pagination.items],
'total': pagination.total,
'page': page,
'size': size
})
@api_bp.route('/history/<int:id>', methods=['DELETE'])
def delete_history(id):
"""删除历史记录"""
history = RequestHistory.query.get(id)
if not history:
return jsonify({'success': False, 'error': '记录不存在'}), 404
db.session.delete(history)
db.session.commit()
return jsonify({'success': True})
@api_bp.route('/history/clear', methods=['DELETE'])
def clear_history():
"""清空历史记录"""
RequestHistory.query.delete()
db.session.commit()
return jsonify({'success': True})
# ==================== 收藏夹 API ====================
@api_bp.route('/collections', methods=['GET'])
def get_collections():
"""获取收藏夹列表"""
folder = request.args.get('folder', '')
search = request.args.get('search', '')
query = Collection.query
if folder:
query = query.filter(Collection.folder == folder)
if search:
query = query.filter(
(Collection.name.contains(search)) |
(Collection.url.contains(search))
)
collections = query.order_by(Collection.updated_at.desc()).all()
return jsonify({
'success': True,
'data': [c.to_dict() for c in collections]
})
@api_bp.route('/collections', methods=['POST'])
def save_collection():
"""保存到收藏夹"""
data = request.get_json()
if not data or not data.get('name') or not data.get('url'):
return jsonify({'success': False, 'error': '请提供名称和 URL'}), 400
collection = Collection(
name=data.get('name'),
description=data.get('description'),
folder=data.get('folder'),
method=data.get('method', 'GET'),
url=data.get('url'),
headers=json.dumps(data.get('headers')) if data.get('headers') else None,
params=json.dumps(data.get('params')) if data.get('params') else None,
body=data.get('body'),
body_type=data.get('bodyType'),
auth_type=data.get('authType'),
auth_config=json.dumps(data.get('authConfig')) if data.get('authConfig') else None,
tags=json.dumps(data.get('tags')) if data.get('tags') else None
)
db.session.add(collection)
db.session.commit()
return jsonify({'success': True, 'data': collection.to_dict()})
@api_bp.route('/collections/<int:id>', methods=['DELETE'])
def delete_collection(id):
"""删除收藏"""
collection = Collection.query.get(id)
if not collection:
return jsonify({'success': False, 'error': '收藏不存在'}), 404
db.session.delete(collection)
db.session.commit()
return jsonify({'success': True})
# ==================== 环境变量 API ====================
@api_bp.route('/environments', methods=['GET'])
def get_environments():
"""获取环境变量列表"""
environments = Environment.query.all()
return jsonify({
'success': True,
'data': [e.to_dict() for e in environments]
})
@api_bp.route('/environments', methods=['POST'])
def save_environment():
"""保存环境变量"""
data = request.get_json()
if not data or not data.get('name'):
return jsonify({'success': False, 'error': '请提供环境名称'}), 400
env = Environment(
name=data.get('name'),
variables=json.dumps(data.get('variables', {})),
is_active=data.get('isActive', False)
)
# 如果设为激活,取消其他环境的激活状态
if env.is_active:
Environment.query.update({Environment.is_active: False})
db.session.add(env)
db.session.commit()
return jsonify({'success': True, 'data': env.to_dict()})
@api_bp.route('/environments/<int:id>/activate', methods=['POST'])
def activate_environment(id):
"""激活环境"""
env = Environment.query.get(id)
if not env:
return jsonify({'success': False, 'error': '环境不存在'}), 404
Environment.query.update({Environment.is_active: False})
env.is_active = True
db.session.commit()
return jsonify({'success': True})
# ==================== 导入功能 API ====================
@api_bp.route('/import/curl', methods=['POST'])
def import_curl():
"""导入 cURL 命令"""
data = request.get_json()
curl_command = data.get('curl', '')
if not curl_command:
return jsonify({'success': False, 'error': '请提供 cURL 命令'}), 400
parsed = parse_curl(curl_command)
return jsonify({'success': True, 'data': parsed})
def parse_curl(curl_command: str) -> dict:
"""解析 cURL 命令"""
result = {
'method': 'GET',
'url': '',
'headers': {},
'body': None
}
# 提取 URL
url_match = re.search(r"curl\s+['\"]?([^'\"\s]+)['\"]?", curl_command)
if url_match:
result['url'] = url_match.group(1)
# 提取方法
method_match = re.search(r'-X\s+(\w+)', curl_command)
if method_match:
result['method'] = method_match.group(1).upper()
# 提取 headers
header_matches = re.findall(r"-H\s+['\"]([^'\"]+)['\"]", curl_command)
for header in header_matches:
if ':' in header:
key, value = header.split(':', 1)
result['headers'][key.strip()] = value.strip()
# 提取 body
body_match = re.search(r"(?:-d|--data|--data-raw)\s+['\"]([^'\"]+)['\"]", curl_command)
if body_match:
result['body'] = body_match.group(1)
if result['method'] == 'GET':
result['method'] = 'POST'
return result
+9
View File
@@ -0,0 +1,9 @@
"""
服务模块
@author huazm
"""
from .http_client import HttpClient
__all__ = ['HttpClient']
Binary file not shown.
+194
View File
@@ -0,0 +1,194 @@
"""
HTTP 客户端服务
处理 HTTP 请求发送和响应处理
@author huazm
"""
import time
import json
import base64
import httpx
from typing import Dict, Any, Optional, List
from urllib.parse import urlencode
class HttpClient:
"""HTTP 客户端,支持多种请求方法和认证方式"""
def __init__(self, timeout: int = 30):
self.timeout = timeout
def send_request(
self,
method: str,
url: str,
headers: Optional[Dict[str, str]] = None,
params: Optional[Dict[str, str]] = None,
body: Optional[str] = None,
body_type: str = 'json',
auth_type: Optional[str] = None,
auth_config: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
发送 HTTP 请求
Args:
method: 请求方法 (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)
url: 请求 URL
headers: 请求头
params: URL 参数
body: 请求体
body_type: 请求体类型 (json, form, xml, raw)
auth_type: 认证类型 (bearer, basic, apikey, oauth2)
auth_config: 认证配置
Returns:
包含响应信息的字典
"""
start_time = time.time()
# 初始化请求头
request_headers = dict(headers) if headers else {}
# 处理认证
self._apply_auth(request_headers, auth_type, auth_config, params)
# 处理请求体
content = None
data = None
if body and method.upper() not in ['GET', 'HEAD', 'OPTIONS']:
if body_type == 'json':
request_headers.setdefault('Content-Type', 'application/json')
content = body
elif body_type == 'form':
request_headers.setdefault('Content-Type', 'application/x-www-form-urlencoded')
try:
data = json.loads(body)
except:
data = body
elif body_type == 'xml':
request_headers.setdefault('Content-Type', 'application/xml')
content = body
else:
content = body
try:
with httpx.Client(timeout=self.timeout, verify=False, follow_redirects=True) as client:
response = client.request(
method=method.upper(),
url=url,
headers=request_headers,
params=params,
content=content,
data=data
)
duration = (time.time() - start_time) * 1000 # 转换为毫秒
# 尝试解析响应体
try:
response_body = response.text
except:
response_body = str(response.content)
return {
'success': True,
'statusCode': response.status_code,
'headers': dict(response.headers),
'body': response_body,
'duration': round(duration, 2),
'size': len(response.content)
}
except httpx.TimeoutException:
return {
'success': False,
'error': '请求超时',
'duration': round((time.time() - start_time) * 1000, 2)
}
except httpx.ConnectError as e:
return {
'success': False,
'error': f'连接失败: {str(e)}',
'duration': round((time.time() - start_time) * 1000, 2)
}
except Exception as e:
return {
'success': False,
'error': f'请求错误: {str(e)}',
'duration': round((time.time() - start_time) * 1000, 2)
}
def _apply_auth(
self,
headers: Dict[str, str],
auth_type: Optional[str],
auth_config: Optional[Dict[str, Any]],
params: Optional[Dict[str, str]]
):
"""应用认证配置到请求头或参数"""
if not auth_type or not auth_config:
return
if auth_type == 'bearer':
token = auth_config.get('token', '')
headers['Authorization'] = f'Bearer {token}'
elif auth_type == 'basic':
username = auth_config.get('username', '')
password = auth_config.get('password', '')
credentials = base64.b64encode(f'{username}:{password}'.encode()).decode()
headers['Authorization'] = f'Basic {credentials}'
elif auth_type == 'apikey':
key = auth_config.get('key', '')
value = auth_config.get('value', '')
location = auth_config.get('location', 'header') # header 或 query
if location == 'header':
headers[key] = value
elif location == 'query' and params is not None:
params[key] = value
elif auth_type == 'oauth2':
token = auth_config.get('accessToken', '')
headers['Authorization'] = f'Bearer {token}'
def batch_request(
self,
requests: List[Dict[str, Any]],
interval: int = 100
) -> List[Dict[str, Any]]:
"""
批量发送请求
Args:
requests: 请求配置列表
interval: 请求间隔(毫秒)
Returns:
响应结果列表
"""
results = []
for i, req in enumerate(requests):
result = self.send_request(
method=req.get('method', 'GET'),
url=req.get('url', ''),
headers=req.get('headers'),
params=req.get('params'),
body=req.get('body'),
body_type=req.get('bodyType', 'json'),
auth_type=req.get('authType'),
auth_config=req.get('authConfig')
)
result['index'] = i
results.append(result)
# 请求间隔
if interval > 0 and i < len(requests) - 1:
time.sleep(interval / 1000)
return results
+894
View File
@@ -0,0 +1,894 @@
/**
* API Tester - 科技感未来主题样式
* @author huazm
*/
/* ==================== CSS 变量和主题 ==================== */
:root {
/* 默认主题: 深蓝科技 */
--bg-primary: #0a0e17;
--bg-secondary: #111827;
--bg-tertiary: #1f2937;
--bg-hover: #374151;
--text-primary: #f3f4f6;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--accent-primary: #3b82f6;
--accent-secondary: #60a5fa;
--accent-glow: rgba(59, 130, 246, 0.5);
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--info: #06b6d4;
--border-color: #374151;
--border-glow: rgba(59, 130, 246, 0.3);
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-glow: 0 0 20px var(--accent-glow);
--transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 主题: 紫色星云 */
[data-theme="nebula"] {
--bg-primary: #0f0a1a;
--bg-secondary: #1a1028;
--bg-tertiary: #2d1f4a;
--accent-primary: #a855f7;
--accent-secondary: #c084fc;
--accent-glow: rgba(168, 85, 247, 0.5);
--border-glow: rgba(168, 85, 247, 0.3);
}
/* 主题: 翠绿矩阵 */
[data-theme="matrix"] {
--bg-primary: #0a0f0a;
--bg-secondary: #0f1a0f;
--bg-tertiary: #1a2f1a;
--accent-primary: #22c55e;
--accent-secondary: #4ade80;
--accent-glow: rgba(34, 197, 94, 0.5);
--border-glow: rgba(34, 197, 94, 0.3);
}
/* 主题: 赛博橙 */
[data-theme="cyber"] {
--bg-primary: #0f0a05;
--bg-secondary: #1a1008;
--bg-tertiary: #2f1f0a;
--accent-primary: #f97316;
--accent-secondary: #fb923c;
--accent-glow: rgba(249, 115, 22, 0.5);
--border-glow: rgba(249, 115, 22, 0.3);
}
/* 主题: 冰霜白 */
[data-theme="frost"] {
--bg-primary: #f0f4f8;
--bg-secondary: #e2e8f0;
--bg-tertiary: #cbd5e1;
--bg-hover: #94a3b8;
--text-primary: #1e293b;
--text-secondary: #475569;
--text-muted: #64748b;
--accent-primary: #0ea5e9;
--accent-secondary: #38bdf8;
--border-color: #94a3b8;
}
/* ==================== 基础样式 ==================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* ==================== 粒子背景 ==================== */
#particles-canvas {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
/* ==================== 应用容器 ==================== */
.app-container {
position: relative;
z-index: 1;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ==================== 头部导航 ==================== */
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: linear-gradient(180deg, var(--bg-secondary) 0%, transparent 100%);
border-bottom: 1px solid var(--border-color);
}
.logo {
display: flex;
align-items: center;
gap: 8px;
}
.logo-icon {
font-size: 24px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.1);
}
}
.logo-text {
font-size: 20px;
font-weight: 700;
background: linear-gradient(
135deg,
var(--accent-primary),
var(--accent-secondary)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo-badge {
font-size: 10px;
padding: 2px 6px;
background: var(--accent-primary);
color: white;
border-radius: 10px;
font-weight: 600;
}
.header-nav {
display: flex;
gap: 8px;
}
.nav-btn {
padding: 8px 16px;
background: transparent;
border: 1px solid transparent;
color: var(--text-secondary);
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-md);
transition: var(--transition);
}
.nav-btn:hover {
color: var(--text-primary);
background: var(--bg-tertiary);
}
.nav-btn.active {
color: var(--accent-primary);
border-color: var(--accent-primary);
box-shadow: var(--shadow-glow);
}
.header-actions {
display: flex;
gap: 8px;
}
.theme-btn,
.voice-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid var(--border-color);
background: var(--bg-tertiary);
cursor: pointer;
font-size: 18px;
transition: var(--transition);
}
.theme-btn:hover,
.voice-btn:hover {
border-color: var(--accent-primary);
box-shadow: var(--shadow-glow);
transform: scale(1.1);
}
.voice-btn.recording {
animation: recording 1s infinite;
border-color: var(--error);
}
@keyframes recording {
0%,
100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5);
}
50% {
box-shadow: 0 0 0 10px rgba(239, 68, 68, 0);
}
}
/* ==================== 主内容区 ==================== */
.app-main {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
padding: 24px;
max-width: 1800px;
margin: 0 auto;
width: 100%;
}
@media (max-width: 1200px) {
.app-main {
grid-template-columns: 1fr;
}
}
/* ==================== 面板通用样式 ==================== */
.panel {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
overflow: hidden;
}
.panel.hidden {
display: none;
}
/* ==================== URL 栏 ==================== */
.url-bar {
display: flex;
gap: 12px;
padding: 20px;
background: linear-gradient(
135deg,
var(--bg-tertiary) 0%,
var(--bg-secondary) 100%
);
border-bottom: 1px solid var(--border-color);
}
.method-select {
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--accent-primary);
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-md);
cursor: pointer;
min-width: 100px;
transition: var(--transition);
}
.method-select:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: var(--shadow-glow);
}
.url-input {
flex: 1;
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 14px;
border-radius: var(--radius-md);
transition: var(--transition);
}
.url-input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: var(--shadow-glow);
}
.url-input::placeholder {
color: var(--text-muted);
}
/* ==================== 发送按钮 ==================== */
.send-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 24px;
background: linear-gradient(
135deg,
var(--accent-primary),
var(--accent-secondary)
);
border: none;
color: white;
font-size: 14px;
font-weight: 600;
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.send-btn::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: 0.5s;
}
.send-btn:hover::before {
left: 100%;
}
.send-btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 20px var(--accent-glow);
}
.send-btn:active {
transform: translateY(0);
}
.send-btn.loading {
pointer-events: none;
opacity: 0.7;
}
.send-btn.loading .btn-text {
opacity: 0;
}
.send-btn.loading::after {
content: "";
position: absolute;
width: 20px;
height: 20px;
border: 2px solid white;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ==================== 标签页 ==================== */
.request-tabs,
.response-tabs {
display: flex;
padding: 0 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.tab-btn {
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: var(--transition);
}
.tab-btn:hover {
color: var(--text-primary);
}
.tab-btn.active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
}
/* ==================== 内容区 ==================== */
.request-content,
.response-content {
padding: 20px;
min-height: 200px;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* ==================== Key-Value 编辑器 ==================== */
.kv-editor {
display: flex;
flex-direction: column;
gap: 8px;
}
.kv-row {
display: flex;
gap: 8px;
align-items: center;
}
.kv-key,
.kv-value {
flex: 1;
padding: 10px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
border-radius: var(--radius-sm);
transition: var(--transition);
}
.kv-key:focus,
.kv-value:focus {
outline: none;
border-color: var(--accent-primary);
}
.kv-remove {
width: 32px;
height: 32px;
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-muted);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 18px;
transition: var(--transition);
}
.kv-remove:hover {
background: var(--error);
border-color: var(--error);
color: white;
}
.add-row-btn {
margin-top: 8px;
padding: 8px 16px;
background: transparent;
border: 1px dashed var(--border-color);
color: var(--text-secondary);
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition);
}
.add-row-btn:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
/* ==================== Header 预设 ==================== */
.header-presets-container {
margin-top: 20px;
padding: 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
}
.preset-group {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border-color);
}
.preset-group:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.preset-label {
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
min-width: 90px;
}
.preset-btn {
padding: 5px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-size: 11px;
border-radius: 14px;
cursor: pointer;
transition: var(--transition);
}
.preset-btn:hover {
background: var(--accent-primary);
border-color: var(--accent-primary);
color: white;
transform: translateY(-1px);
box-shadow: 0 2px 8px var(--accent-glow);
}
.preset-btn.auth-preset {
border-color: var(--warning);
color: var(--warning);
}
.preset-btn.auth-preset:hover {
background: var(--warning);
border-color: var(--warning);
color: white;
}
/* ==================== Body 编辑器 ==================== */
.body-type-selector {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.body-type-selector label {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
}
.body-type-selector input[type="radio"] {
accent-color: var(--accent-primary);
}
.body-editor-container {
position: relative;
}
.body-editor {
width: 100%;
min-height: 200px;
padding: 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
border-radius: var(--radius-md);
resize: vertical;
transition: var(--transition);
}
.body-editor:focus {
outline: none;
border-color: var(--accent-primary);
}
.format-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition);
}
.format-btn:hover {
background: var(--accent-primary);
color: white;
}
/* ==================== Auth 配置 ==================== */
.auth-type-selector {
margin-bottom: 16px;
}
.auth-type-selector select {
padding: 10px 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 14px;
border-radius: var(--radius-md);
cursor: pointer;
min-width: 200px;
}
.auth-config {
display: flex;
flex-direction: column;
gap: 12px;
}
.auth-input-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.auth-input-group label {
color: var(--text-secondary);
font-size: 12px;
}
.auth-input-group input {
padding: 10px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
border-radius: var(--radius-sm);
}
/* ==================== 响应面板 ==================== */
.response-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.response-status {
display: flex;
align-items: center;
gap: 12px;
}
.status-code {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 600;
padding: 4px 12px;
border-radius: var(--radius-sm);
background: var(--bg-primary);
}
.status-code.success {
color: var(--success);
}
.status-code.redirect {
color: var(--warning);
}
.status-code.error {
color: var(--error);
}
.status-text {
color: var(--text-secondary);
font-size: 14px;
}
.response-meta {
display: flex;
gap: 20px;
}
.meta-item {
font-size: 13px;
color: var(--text-secondary);
}
.meta-label {
color: var(--text-muted);
}
.response-body,
.response-headers-view {
background: var(--bg-primary);
padding: 16px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
}
/* ==================== 历史记录面板 ==================== */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-color);
}
.panel-header h2 {
font-size: 16px;
font-weight: 600;
}
.panel-actions {
display: flex;
gap: 12px;
}
.search-input {
padding: 8px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 13px;
border-radius: var(--radius-sm);
width: 200px;
}
.clear-btn {
padding: 8px 16px;
background: var(--error);
border: none;
color: white;
font-size: 13px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: var(--transition);
}
.clear-btn:hover {
opacity: 0.8;
}
.history-list {
padding: 16px;
display: flex;
flex-direction: column;
gap: 8px;
max-height: 500px;
overflow-y: auto;
}
.history-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
cursor: pointer;
transition: var(--transition);
}
.history-item:hover {
border-color: var(--accent-primary);
}
.history-method {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: var(--radius-sm);
min-width: 60px;
text-align: center;
}
.history-method.get {
background: #10b98120;
color: var(--success);
}
.history-method.post {
background: #3b82f620;
color: var(--accent-primary);
}
.history-method.put {
background: #f59e0b20;
color: var(--warning);
}
.history-method.delete {
background: #ef444420;
color: var(--error);
}
.history-method.patch {
background: #a855f720;
color: #a855f7;
}
.history-url {
flex: 1;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.history-time {
font-size: 12px;
color: var(--text-muted);
}
.history-status {
font-family: var(--font-mono);
font-size: 12px;
padding: 2px 8px;
border-radius: var(--radius-sm);
}
/* ==================== 滚动条样式 ==================== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-hover);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-primary);
}
+553
View File
@@ -0,0 +1,553 @@
/**
* 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();
}
});
});
+208
View File
@@ -0,0 +1,208 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>API Tester - 未来科技接口测试工具</title>
<link rel="stylesheet" href="/static/css/main.css">
<!-- 使用国内 CDN 加载字体,避免 Google Fonts 超时 -->
<link href="https://fonts.loli.net/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* 备用字体,防止 CDN 也超时 */
@font-face {
font-family: 'JetBrains Mono';
src: local('JetBrains Mono'), local('Menlo'), local('Monaco'), local('Consolas');
font-display: swap;
}
</style>
</head>
<body>
<!-- 动态粒子背景 -->
<canvas id="particles-canvas"></canvas>
<!-- 主容器 -->
<div class="app-container">
<!-- 头部导航 -->
<header class="app-header">
<div class="logo">
<span class="logo-icon"></span>
<span class="logo-text">API Tester</span>
<span class="logo-badge">Pro</span>
</div>
<nav class="header-nav">
<button class="nav-btn active" data-tab="request">请求</button>
<button class="nav-btn" data-tab="history">历史</button>
<button class="nav-btn" data-tab="collections">收藏</button>
<button class="nav-btn" data-tab="environments">环境</button>
</nav>
<div class="header-actions">
<button class="theme-btn" id="theme-toggle" title="切换主题">🎨</button>
<button class="voice-btn" id="voice-input" title="语音输入">🎤</button>
</div>
</header>
<!-- 主内容区 -->
<main class="app-main">
<!-- 请求面板 -->
<section class="panel request-panel" id="request-panel">
<!-- URL 输入区 -->
<div class="url-bar">
<select class="method-select" id="method-select">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
<option value="HEAD">HEAD</option>
<option value="OPTIONS">OPTIONS</option>
</select>
<input type="text" class="url-input" id="url-input"
placeholder="输入请求 URL,例如: https://api.example.com/users">
<button class="send-btn" id="send-btn">
<span class="btn-text">发送</span>
<span class="btn-icon"></span>
</button>
</div>
<!-- 请求配置标签页 -->
<div class="request-tabs">
<button class="tab-btn active" data-tab="params">Params</button>
<button class="tab-btn" data-tab="headers">Headers</button>
<button class="tab-btn" data-tab="body">Body</button>
<button class="tab-btn" data-tab="auth">Auth</button>
</div>
<!-- 请求配置内容 -->
<div class="request-content">
<!-- Params 面板 -->
<div class="tab-content active" id="params-content">
<div class="kv-editor" id="params-editor">
<div class="kv-row">
<input type="text" class="kv-key" placeholder="Key">
<input type="text" class="kv-value" placeholder="Value">
<button class="kv-remove">×</button>
</div>
</div>
<button class="add-row-btn" data-target="params-editor">+ 添加参数</button>
</div>
<!-- Headers 面板 -->
<div class="tab-content" id="headers-content">
<div class="kv-editor" id="headers-editor">
<div class="kv-row">
<input type="text" class="kv-key" placeholder="Header Name">
<input type="text" class="kv-value" placeholder="Header Value">
<button class="kv-remove">×</button>
</div>
</div>
<button class="add-row-btn" data-target="headers-editor">+ 添加请求头</button>
<!-- 常用请求头预设 -->
<div class="header-presets-container">
<div class="preset-group">
<span class="preset-label">Content-Type:</span>
<button class="preset-btn" data-key="Content-Type" data-value="application/json">JSON</button>
<button class="preset-btn" data-key="Content-Type" data-value="application/x-www-form-urlencoded">Form</button>
<button class="preset-btn" data-key="Content-Type" data-value="multipart/form-data">Multipart</button>
<button class="preset-btn" data-key="Content-Type" data-value="text/plain">Text</button>
<button class="preset-btn" data-key="Content-Type" data-value="application/xml">XML</button>
</div>
<div class="preset-group">
<span class="preset-label">Accept:</span>
<button class="preset-btn" data-key="Accept" data-value="application/json">JSON</button>
<button class="preset-btn" data-key="Accept" data-value="*/*">Any</button>
<button class="preset-btn" data-key="Accept" data-value="text/html">HTML</button>
</div>
<div class="preset-group">
<span class="preset-label">鉴权:</span>
<button class="preset-btn auth-preset" data-key="Authorization" data-value="Bearer ">Bearer Token</button>
<button class="preset-btn auth-preset" data-key="Authorization" data-value="Basic ">Basic Auth</button>
<button class="preset-btn" data-key="X-API-Key" data-value="">API Key</button>
<button class="preset-btn" data-key="X-Access-Token" data-value="">Access Token</button>
</div>
<div class="preset-group">
<span class="preset-label">其他常用:</span>
<button class="preset-btn" data-key="User-Agent" data-value="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36">User-Agent</button>
<button class="preset-btn" data-key="Cache-Control" data-value="no-cache">No Cache</button>
<button class="preset-btn" data-key="Origin" data-value="">Origin</button>
<button class="preset-btn" data-key="Referer" data-value="">Referer</button>
<button class="preset-btn" data-key="Cookie" data-value="">Cookie</button>
<button class="preset-btn" data-key="X-Requested-With" data-value="XMLHttpRequest">AJAX</button>
</div>
</div>
</div>
<!-- Body 面板 -->
<div class="tab-content" id="body-content">
<div class="body-type-selector">
<label><input type="radio" name="bodyType" value="none" checked> none</label>
<label><input type="radio" name="bodyType" value="json"> JSON</label>
<label><input type="radio" name="bodyType" value="form"> form-data</label>
<label><input type="radio" name="bodyType" value="xml"> XML</label>
<label><input type="radio" name="bodyType" value="raw"> raw</label>
</div>
<div class="body-editor-container">
<textarea class="body-editor" id="body-editor" placeholder="请求体内容..."></textarea>
<button class="format-btn" id="format-json-btn" title="格式化 JSON">{ }</button>
</div>
</div>
<!-- Auth 面板 -->
<div class="tab-content" id="auth-content">
<div class="auth-type-selector">
<select id="auth-type">
<option value="none">No Auth</option>
<option value="bearer">Bearer Token</option>
<option value="basic">Basic Auth</option>
<option value="apikey">API Key</option>
</select>
</div>
<div class="auth-config" id="auth-config">
<!-- 动态填充认证配置 -->
</div>
</div>
</div>
</section>
<!-- 响应面板 -->
<section class="panel response-panel" id="response-panel">
<div class="response-header">
<div class="response-status">
<span class="status-code" id="status-code">--</span>
<span class="status-text" id="status-text">等待请求</span>
</div>
<div class="response-meta">
<span class="meta-item"><span class="meta-label">耗时:</span> <span id="response-time">--</span></span>
<span class="meta-item"><span class="meta-label">大小:</span> <span id="response-size">--</span></span>
</div>
</div>
<div class="response-tabs">
<button class="tab-btn active" data-tab="response-body">Body</button>
<button class="tab-btn" data-tab="response-headers">Headers</button>
</div>
<div class="response-content">
<pre class="response-body" id="response-body"><code>// 响应将显示在这里</code></pre>
<pre class="response-headers hidden" id="response-headers-view"><code></code></pre>
</div>
</section>
</main>
<!-- 历史记录面板 (隐藏) -->
<section class="panel history-panel hidden" id="history-panel">
<div class="panel-header">
<h2>请求历史</h2>
<div class="panel-actions">
<input type="text" class="search-input" id="history-search" placeholder="搜索...">
<button class="clear-btn" id="clear-history">清空历史</button>
</div>
</div>
<div class="history-list" id="history-list">
<!-- 动态填充 -->
</div>
</section>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>