This commit is contained in:
star7th
2025-04-10 18:06:45 +08:00
commit 54650cd944
154 changed files with 29090 additions and 0 deletions
@@ -0,0 +1,23 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
interface ErrorDisplayProps {
error: string | null;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error }) => {
if (!error) {
return null;
}
return (
<div className={styles.errorBox}>
<FontAwesomeIcon icon={faExclamationTriangle} className="mr-2 text-warning" />
{error}
</div>
);
};
export default ErrorDisplay;
@@ -0,0 +1,99 @@
import React from 'react';
interface JsonRendererProps {
data: unknown;
}
/**
* JSON格式化和语法高亮组件
*/
const JsonRenderer: React.FC<JsonRendererProps> = ({ data }) => {
// 递归渲染JSON对象
const renderJsonValue = (value: unknown, depth = 0, isLast = true): React.ReactNode => {
const indent = Array(depth * 2).fill(' ').join('');
// 处理不同类型的值
if (value === null) return <span className="text-error">null</span>;
if (value === undefined) return <span className="text-tertiary">undefined</span>;
if (typeof value === 'boolean') {
return <span className="text-warning">{value.toString()}</span>;
}
if (typeof value === 'number') {
return <span className="text-success">{value}</span>;
}
if (typeof value === 'string') {
return <span className="text-primary-light">&quot;{value}&quot;</span>;
}
// 处理数组
if (Array.isArray(value)) {
if (value.length === 0) return <span>[]</span>;
return (
<span>
<span>[</span>
<div style={{ paddingLeft: '20px' }}>
{value.map((item, index) => (
<div key={index}>
{renderJsonValue(item, depth + 1, index === value.length - 1)}
{index !== value.length - 1 && <span className="text-tertiary">,</span>}
</div>
))}
</div>
<span>{indent}]</span>
{!isLast && <span className="text-tertiary">,</span>}
</span>
);
}
// 处理对象
if (typeof value === 'object') {
const entries = Object.entries(value as Record<string, unknown>);
if (entries.length === 0) return <span>{'{}'}</span>;
return (
<span>
<span>{'{'}</span>
<div style={{ paddingLeft: '20px' }}>
{entries.map(([key, val], index) => (
<div key={key}>
<span className="text-purple">&quot;{key}&quot;</span>
<span className="text-tertiary">: </span>
{renderJsonValue(val, depth + 1, index === entries.length - 1)}
{index !== entries.length - 1 && <span className="text-tertiary">,</span>}
</div>
))}
</div>
<span>{indent}{'}'}</span>
{!isLast && <span className="text-tertiary">,</span>}
</span>
);
}
return <span>{String(value)}</span>;
};
// 解析JSON字符串 (如果传入的是字符串)
const parseAndRender = () => {
try {
if (typeof data === 'string') {
const parsedData = JSON.parse(data);
return renderJsonValue(parsedData);
}
return renderJsonValue(data);
} catch {
return <span className="text-error">JSON: {String(data)}</span>;
}
};
return (
<div className="font-mono text-sm overflow-x-auto">
{parseAndRender()}
</div>
);
};
export default JsonRenderer;
@@ -0,0 +1,189 @@
import React, { useRef, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCopy, faCheck, faDownload, faTimes, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons';
import { createPortal } from 'react-dom';
interface MarkdownPreviewProps {
markdown: string;
isOpen: boolean;
onClose: () => void;
}
const MarkdownPreview: React.FC<MarkdownPreviewProps> = ({ markdown, isOpen, onClose }) => {
const [copied, setCopied] = React.useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 处理关闭模态框
const handleClose = () => {
onClose();
};
// 处理点击模态框外部关闭
const handleOutsideClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (modalRef.current && e.target === e.currentTarget) {
handleClose();
}
};
// 处理复制文档内容
const handleCopy = () => {
navigator.clipboard.writeText(markdown)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => console.error('复制失败', err));
};
// 处理下载文档
const handleDownload = () => {
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// 使用固定的文件名,不再从文档标题提取
const fileName = 'api_document.md';
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// 打开ShowDoc官网
const openShowDoc = () => {
window.open('https://www.showdoc.com.cn/', '_blank');
};
// 监听ESC键关闭模态窗口
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
handleClose();
}
};
window.addEventListener('keydown', handleEsc);
return () => {
window.removeEventListener('keydown', handleEsc);
};
}, [isOpen]);
// 当打开模态框时,禁止背景滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
// 自动聚焦文本框,但不选中内容,以便用户可以正常阅读
if (textareaRef.current) {
textareaRef.current.focus();
}
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// 如果模态框未打开,不渲染任何内容
if (!isOpen) return null;
// 模态窗口的内容
const modalContent = (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center p-4 bg-block-strong/80 backdrop-blur-sm"
onClick={handleOutsideClick}
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0 }}
>
<div
ref={modalRef}
className="relative bg-block border border-purple-glow rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] flex flex-col"
>
{/* 模态框标题 */}
<div className="flex justify-between items-center px-6 py-4 border-b border-purple-glow/30">
<h3 className="text-lg font-medium text-primary"></h3>
<button
className="text-tertiary hover:text-primary"
onClick={handleClose}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* ShowDoc推荐信息 */}
<div className="px-6 py-3 bg-purple/10 border-b border-purple-glow/20">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm text-secondary">
<span className="text-purple font-medium"></span>
使ShowDoc风格编写ShowDoc平台进行团队分享和管理
</span>
</div>
<button
onClick={openShowDoc}
className="text-xs text-purple hover:text-purple-hover flex items-center gap-1 transition-colors"
>
访ShowDoc官网
<FontAwesomeIcon icon={faExternalLinkAlt} className="text-[10px]" />
</button>
</div>
</div>
{/* 模态框内容 */}
<div className="flex-1 overflow-auto p-6">
<textarea
ref={textareaRef}
className="w-full h-full min-h-[500px] p-5 bg-block-strong border border-purple-glow/30 rounded-lg text-primary font-mono text-sm leading-relaxed resize-none focus:outline-none focus:border-purple-glow"
value={markdown}
readOnly
style={{
lineHeight: '1.7',
letterSpacing: '0.3px',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
scrollBehavior: 'smooth',
whiteSpace: 'pre',
overflowWrap: 'normal',
tabSize: 2
}}
/>
</div>
{/* 模态框底部操作按钮 */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-purple-glow/30">
<span className="text-sm text-tertiary mr-auto">
使 <a href="https://www.showdoc.com.cn/" target="_blank" rel="noopener noreferrer" className="text-purple hover:underline">ShowDoc</a>
</span>
<button
className="btn-secondary flex items-center gap-2 px-4 py-2 text-sm"
onClick={handleDownload}
>
<FontAwesomeIcon icon={faDownload} />
</button>
<button
className="btn-primary flex items-center gap-2 px-4 py-2 text-sm"
onClick={handleCopy}
>
<FontAwesomeIcon icon={copied ? faCheck : faCopy} />
{copied ? '已复制' : '复制文档'}
</button>
</div>
</div>
</div>
);
// 使用React Portal将模态窗口渲染到body元素下,保证它不受父元素影响
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body);
}
return null;
};
export default MarkdownPreview;
@@ -0,0 +1,124 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { FormField } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestBodyProps {
bodyFormat: 'json' | 'text' | 'form';
body: string;
formFields: FormField[];
onBodyChange: (body: string) => void;
onBodyFormatChange: (format: 'json' | 'text' | 'form') => void;
onAddFormField: () => void;
onUpdateFormField: (id: string, key: string, value: string) => void;
onRemoveFormField: (id: string) => void;
}
const RequestBody: React.FC<RequestBodyProps> = ({
bodyFormat,
body,
formFields,
onBodyChange,
onBodyFormatChange,
onAddFormField,
onUpdateFormField,
onRemoveFormField,
}) => {
const { t } = useLanguage();
return (
<div>
{/* 请求体格式选择 */}
<div className="flex gap-2 mb-4">
<button
className={styles.methodButton(bodyFormat === 'json')}
onClick={() => onBodyFormatChange('json')}
>
{t('tools.http_tester.json_format')}
</button>
<button
className={styles.methodButton(bodyFormat === 'text')}
onClick={() => onBodyFormatChange('text')}
>
{t('tools.http_tester.text_format')}
</button>
<button
className={styles.methodButton(bodyFormat === 'form')}
onClick={() => onBodyFormatChange('form')}
>
{t('tools.http_tester.form_format')}
</button>
</div>
{bodyFormat === 'json' && (
<>
<textarea
value={body}
onChange={(e) => onBodyChange(e.target.value)}
placeholder='{\n "key": "value"\n}'
className={styles.textArea}
/>
<div className="mt-2 text-xs text-tertiary">
{t('tools.http_tester.enter_request_body')}
</div>
</>
)}
{bodyFormat === 'text' && (
<textarea
value={body}
onChange={(e) => onBodyChange(e.target.value)}
placeholder={t('tools.http_tester.enter_request_body')}
className={styles.textArea}
/>
)}
{bodyFormat === 'form' && (
<div>
<div className="mb-4">
<button
className="btn-secondary text-xs px-3 py-1"
onClick={onAddFormField}
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
{t('tools.http_tester.add_form_field')}
</button>
</div>
{formFields.map(field => (
<div key={field.id} className={styles.headerRow}>
<input
type="text"
placeholder={t('tools.http_tester.form_field_key')}
value={field.key}
onChange={(e) => onUpdateFormField(field.id, e.target.value, field.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<input
type="text"
placeholder={t('tools.http_tester.form_field_value')}
value={field.value}
onChange={(e) => onUpdateFormField(field.id, field.key, e.target.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<button
onClick={() => onRemoveFormField(field.id)}
className={styles.iconButton}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
))}
<div className="mt-2 text-xs text-tertiary">
{t('tools.http_tester.enter_request_body')}
</div>
</div>
)}
</div>
);
};
export default RequestBody;
@@ -0,0 +1,629 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlane, faInfoCircle, faTimes, faCode, faServer, faNetworkWired } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { HttpMethod, NetworkType } from '../types';
import { useLanguage } from '@/context/LanguageContext';
// 定义跨域配置弹窗组件
interface CorsModalProps {
isOpen: boolean;
onClose: () => void;
}
const CorsModal: React.FC<CorsModalProps> = ({ isOpen, onClose }) => {
const [activeTab, setActiveTab] = React.useState<'nginx' | 'php' | 'node' | 'java' | 'python' | 'go'>('nginx');
const { t } = useLanguage();
if (!isOpen) return null;
const tabClasses = (isActive: boolean) =>
`px-3 py-2 text-xs font-medium rounded-t-md ${
isActive
? 'bg-background text-purple border-t border-l border-r border-purple-glow/30'
: 'bg-card hover:bg-background/60 text-tertiary hover:text-secondary transition-colors'
}`;
return (
<>
{/* 背景蒙层 - 使用透明度动画 */}
<div
className="fixed inset-0 bg-black/70 z-50 backdrop-blur-sm animate-fadeIn"
onClick={onClose}
role="button"
aria-label={t('tools.http_tester.close')}
tabIndex={0}
></div>
{/* 弹窗本身 - 使用弹性盒使其居中 */}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 animate-scaleIn">
<div
className="bg-card w-full max-w-3xl rounded-lg shadow-xl border border-purple-glow/30 overflow-hidden flex flex-col"
style={{
maxHeight: 'calc(100vh - 40px)',
transform: 'translate3d(0,0,0)' // 强制硬件加速,避免某些浏览器渲染问题
}}
onClick={(e) => e.stopPropagation()}
>
{/* 标题栏 */}
<div className="bg-background p-4 flex items-center justify-between border-b border-purple-glow/30 shrink-0">
<div className="flex items-center">
<FontAwesomeIcon icon={faNetworkWired} className="text-purple mr-2" />
<h3 className="text-primary font-medium">{t('tools.http_tester.cors_settings')}</h3>
</div>
<button
onClick={onClose}
className="text-tertiary hover:text-purple transition-colors p-1 rounded-full hover:bg-background/60"
aria-label={t('tools.http_tester.close')}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
{/* 内容区 */}
<div className="p-5 overflow-auto flex-grow">
<p className="text-secondary mb-4">
{t('tools.http_tester.cors_description')}
</p>
<div className="bg-amber-900/20 border border-amber-500/30 p-3 rounded-md mb-5">
<p className="text-amber-400 text-sm flex items-start">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2 mt-0.5" />
<span>{t('tools.http_tester.cors_warning')}</span>
</p>
</div>
{/* HTTPS到HTTP问题说明 */}
<div className="bg-purple-900/20 border border-purple-500/30 p-3 rounded-md mb-5">
<h4 className="font-medium text-purple-400 mb-1 flex items-center text-sm">
<FontAwesomeIcon icon={faInfoCircle} className="mr-2" />
{t('tools.http_tester.https_to_http_title')}
</h4>
<p className="text-secondary text-sm mb-2">
{t('tools.http_tester.https_to_http_description')}
</p>
<ul className="text-secondary text-sm list-disc pl-5 space-y-1">
<li className="font-medium">{t('tools.http_tester.solution_one')}
<ul className="list-disc ml-5 mt-1 text-xs font-normal">
<li>{t('tools.http_tester.solution_one_1')}</li>
<li>{t('tools.http_tester.solution_one_2')}</li>
<li>{t('tools.http_tester.solution_one_3')}</li>
</ul>
</li>
<li className="font-medium mt-2">{t('tools.http_tester.solution_two')}
<ul className="list-disc ml-5 mt-1 text-xs font-normal">
<li>{t('tools.http_tester.solution_two_1')}</li>
<li>{t('tools.http_tester.solution_two_2')}</li>
<li>{t('tools.http_tester.solution_two_3')}</li>
</ul>
</li>
</ul>
<div className="bg-red-900/20 border border-red-500/30 p-2 rounded mt-3 text-xs text-red-300">
<span className="font-medium">{t('tools.http_tester.security_note')}</span>
</div>
</div>
{/* 选项卡 - 使用sticky定位保持在顶部 */}
<div className="flex mb-0 gap-1 flex-wrap sticky top-0 bg-card pt-1 -mt-1 -mx-1 px-1 pb-1 z-10">
<button className={tabClasses(activeTab === 'nginx')} onClick={() => setActiveTab('nginx')}>
<FontAwesomeIcon icon={faServer} className="mr-1" /> Nginx
</button>
<button className={tabClasses(activeTab === 'php')} onClick={() => setActiveTab('php')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> PHP
</button>
<button className={tabClasses(activeTab === 'node')} onClick={() => setActiveTab('node')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Node.js
</button>
<button className={tabClasses(activeTab === 'python')} onClick={() => setActiveTab('python')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Python
</button>
<button className={tabClasses(activeTab === 'java')} onClick={() => setActiveTab('java')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Java
</button>
<button className={tabClasses(activeTab === 'go')} onClick={() => setActiveTab('go')}>
<FontAwesomeIcon icon={faCode} className="mr-1" /> Go
</button>
</div>
{/* 代码区 */}
<div className="bg-background border border-purple-glow/30 rounded-md p-4 overflow-auto mt-2" style={{ maxHeight: '350px' }}>
{activeTab === 'nginx' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`# 在 Nginx 的 server 或 location 块中添加:
location /api/ {
# 允许所有来源访问(开发环境使用)
add_header 'Access-Control-Allow-Origin' '*' always;
# 允许的请求方法
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH' always;
# 允许的请求头
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie' always;
# 允许浏览器缓存预检请求结果,单位秒
add_header 'Access-Control-Max-Age' '3600' always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS, PATCH';
add_header 'Access-Control-Allow-Headers' 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie';
add_header 'Access-Control-Max-Age' '3600';
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' '0';
return 204;
}
# 你的其他配置...
}`}</code>
</pre>
)}
{activeTab === 'php' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`<?php
// 在 PHP 脚本开头添加以下代码:
// 允许所有来源访问(开发环境使用)
header("Access-Control-Allow-Origin: *");
// 如果需要发送 Cookie
// header("Access-Control-Allow-Origin: http://localhost:3000"); // 指定来源
// header("Access-Control-Allow-Credentials: true");
// 允许的请求方法
header("Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, PATCH");
// 允许的请求头
header("Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization, Connection, User-Agent, Cookie");
// 处理 OPTIONS 预检请求
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
header("HTTP/1.1 204 No Content");
exit;
}
// 你的 PHP 代码...
`}</code>
</pre>
)}
{activeTab === 'node' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 Express 框架和 cors 中间件
const express = require('express');
const cors = require('cors');
const app = express();
// 基本配置: 允许所有来源
app.use(cors());
// 高级配置
app.use(cors({
origin: '*', // 或特定域名 'http://localhost:3000'
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: false // 如果需要发送 Cookie,设为 true
}));
// 方法 2: 不使用中间件,手动设置响应头
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH');
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
// 处理 OPTIONS 请求
if (req.method === 'OPTIONS') {
return res.status(204).send();
}
next();
});
// 你的路由代码...
`}</code>
</pre>
)}
{activeTab === 'python' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`# 方法 1: 使用 Flask
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
# 允许所有路由的 CORS
CORS(app)
# 或者,更具体的配置
CORS(app, resources={
r"/api/*": {
"origins": "*", # 或特定域名 ["http://localhost:3000"]
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
"allow_headers": ["Content-Type", "Authorization"]
}
})
# 方法 2: 使用 FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 允许所有来源
allow_credentials=False, # 是否支持 cookies
allow_methods=["*"], # 允许所有方法
allow_headers=["*"], # 允许所有头
)
# 方法 3: 使用 Django
# 在 settings.py 中添加:
INSTALLED_APPS = [
# ...其他应用
'corsheaders',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
# ...其他中间件
]
CORS_ALLOW_ALL_ORIGINS = True # 允许所有来源
# 或者指定来源
# CORS_ALLOWED_ORIGINS = [
# "http://localhost:3000",
# ]
`}</code>
</pre>
)}
{activeTab === 'java' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 Spring Boot (添加过滤器)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
// 允许所有来源访问
config.addAllowedOrigin("*");
// 或者允许特定来源
// config.addAllowedOrigin("http://localhost:3000");
// 允许发送 Cookie
// config.setAllowCredentials(true);
// 允许的请求方法
config.addAllowedMethod("GET");
config.addAllowedMethod("POST");
config.addAllowedMethod("PUT");
config.addAllowedMethod("DELETE");
config.addAllowedMethod("OPTIONS");
// 允许的请求头
config.addAllowedHeader("*");
// 预检请求的缓存时间
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}
// 方法 2: 使用 Spring Boot (使用 @CrossOrigin 注解)
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@CrossOrigin(origins = "*", allowedHeaders = "*")
public class MyController {
@GetMapping("/api/data")
public String getData() {
return "数据响应";
}
}
// 方法 3: 在 Servlet 中手动设置响应头
@WebServlet("/api/*")
public class ApiServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 设置 CORS 响应头
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
// 正常处理请求...
}
@Override
protected void doOptions(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 处理预检请求
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
response.setHeader("Access-Control-Max-Age", "3600");
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
}
}
`}</code>
</pre>
)}
{activeTab === 'go' && (
<pre className="text-xs text-secondary font-mono whitespace-pre">
<code>{`// 方法 1: 使用 net/http 标准库
package main
import (
"net/http"
)
func setCorsHeaders(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
setCorsHeaders(w)
// 处理预检请求
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// API 处理逻辑...
})
// 应用中间件
http.Handle("/api/", corsMiddleware(apiHandler))
http.ListenAndServe(":8080", nil)
}
// 方法 2: 使用 Gin 框架
package main
import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"time"
)
func main() {
r := gin.Default()
// CORS 中间件配置
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
AllowHeaders: []string{"Origin", "Content-Type", "Authorization"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false,
MaxAge: 12 * time.Hour,
}))
// 路由处理
r.GET("/api/data", func(c *gin.Context) {
c.JSON(200, gin.H{
"message": "数据响应",
})
})
r.Run(":8080")
}
`}</code>
</pre>
)}
</div>
</div>
{/* 底部按钮 */}
<div className="bg-background p-4 border-t border-purple-glow/30 flex justify-end shrink-0">
<button
className="btn-primary"
onClick={onClose}
>
</button>
</div>
</div>
</div>
</>
);
};
interface RequestFormProps {
url: string;
method: HttpMethod;
loading: boolean;
networkType: NetworkType;
onUrlChange: (url: string) => void;
onMethodChange: (method: HttpMethod) => void;
onNetworkTypeChange: (type: NetworkType) => void;
onSendRequest: () => void;
}
const RequestForm: React.FC<RequestFormProps> = ({
url,
method,
loading,
networkType,
onUrlChange,
onMethodChange,
onNetworkTypeChange,
onSendRequest,
}) => {
const { t } = useLanguage();
// HTTP方法列表
const methods: HttpMethod[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'];
// 显示或隐藏CORS设置弹窗
const [showCorsModal, setShowCorsModal] = React.useState(false);
// 检测是否为HTTP URL
const isHttpUrl = React.useMemo(() => {
try {
return url.trim().toLowerCase().startsWith('http://');
} catch {
return false;
}
}, [url]);
// 检测当前页面是否为HTTPS
const [isCurrentPageHttps, setIsCurrentPageHttps] = React.useState(false);
React.useEffect(() => {
// 仅在客户端执行
if (typeof window !== 'undefined') {
setIsCurrentPageHttps(window.location.protocol === 'https:');
}
}, []);
// 显示混合内容警告的条件
const showMixedContentWarning = networkType === 'local' && isHttpUrl && isCurrentPageHttps;
return (
<div className="mb-6">
<div className="flex gap-2 mb-2">
{methods.map(m => (
<button
key={m}
className={styles.methodButton(method === m)}
onClick={() => onMethodChange(m)}
>
{m}
</button>
))}
</div>
<div className="flex gap-2 mb-2">
<input
type="text"
value={url}
onChange={(e) => onUrlChange(e.target.value)}
placeholder={t('tools.http_tester.enter_url')}
className={`${styles.input} ${showMixedContentWarning ? 'border-amber-500 focus:border-amber-500' : ''}`}
/>
<button
className="btn-primary whitespace-nowrap"
onClick={onSendRequest}
disabled={loading}
>
{loading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{t('tools.http_tester.loading')}
</span>
) : (
<span className="flex items-center">
<FontAwesomeIcon icon={faPaperPlane} className="mr-2" />
{t('tools.http_tester.send_request')}
</span>
)}
</button>
</div>
{/* 混合内容特别警告 - 当检测到HTTPS页面请求HTTP URL时 */}
{showMixedContentWarning && (
<div className="mt-2 text-xs text-red-400 flex items-center mb-2">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
<span>{t('tools.http_tester.https_to_http_title')}</span>
<button
className="ml-2 underline text-purple text-xs hover:text-purple-light transition-colors"
onClick={() => setShowCorsModal(true)}
>
{t('tools.http_tester.cors_settings')}
</button>
</div>
)}
{/* 本地/局域网选项 */}
<div className="flex items-center gap-2 text-sm text-tertiary">
<div className="flex items-center">
<input
type="checkbox"
id="localNetwork"
checked={networkType === 'local'}
onChange={() => onNetworkTypeChange(networkType === 'local' ? 'public' : 'local')}
className="mr-1 accent-purple cursor-pointer"
/>
<label htmlFor="localNetwork" className="cursor-pointer">
{t('tools.http_tester.local_network')}
</label>
</div>
{networkType === 'local' && (
<div className="flex items-center gap-1">
<span className="text-amber-400 text-xs">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.cors_settings')}
</span>
<button
className="text-purple text-xs border border-purple-glow/30 px-2 py-0.5 rounded hover:bg-background transition-colors"
onClick={() => setShowCorsModal(true)}
>
{t('tools.http_tester.cors_settings')}
</button>
</div>
)}
</div>
{/* 替换原来的大型警告框为一个小链接 */}
{networkType === 'local' && !showMixedContentWarning && (
<div className="mt-2 text-xs flex items-center">
<button
className="text-purple hover:text-purple-light transition-colors underline flex items-center"
onClick={() => setShowCorsModal(true)}
>
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.https_to_http_title')}
</button>
</div>
)}
{/* CORS设置弹窗 */}
<CorsModal
isOpen={showCorsModal}
onClose={() => setShowCorsModal(false)}
/>
</div>
);
};
export default RequestForm;
@@ -0,0 +1,63 @@
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { RequestHeader } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestHeadersProps {
headers: RequestHeader[];
onAddHeader: () => void;
onUpdateHeader: (id: string, key: string, value: string) => void;
onRemoveHeader: (id: string) => void;
}
const RequestHeaders: React.FC<RequestHeadersProps> = ({
headers,
onAddHeader,
onUpdateHeader,
onRemoveHeader,
}) => {
const { t } = useLanguage();
return (
<div>
<div className="mb-4">
<button
className="btn-secondary text-xs px-3 py-1"
onClick={onAddHeader}
>
<FontAwesomeIcon icon={faPlus} className="mr-1" />
{t('tools.http_tester.add_header')}
</button>
</div>
{headers.map(header => (
<div key={header.id} className={styles.headerRow}>
<input
type="text"
placeholder={t('tools.http_tester.header_key')}
value={header.key}
onChange={(e) => onUpdateHeader(header.id, e.target.value, header.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<input
type="text"
placeholder={t('tools.http_tester.header_value')}
value={header.value}
onChange={(e) => onUpdateHeader(header.id, header.key, e.target.value)}
className="flex-1 bg-block text-primary px-3 py-1 text-sm rounded-md border border-purple-glow/20 focus:border-purple-glow"
/>
<button
onClick={() => onRemoveHeader(header.id)}
className={styles.iconButton}
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
))}
</div>
);
};
export default RequestHeaders;
@@ -0,0 +1,88 @@
import React from 'react';
import styles from '../styles';
import { HistoryItem, HttpMethod } from '../types';
import { useLanguage } from '@/context/LanguageContext';
interface RequestHistoryProps {
history: HistoryItem[];
showHistory: boolean;
onToggleHistory: () => void;
onClearHistory: () => void;
onLoadFromHistory: (item: {url: string, method: HttpMethod}) => void;
}
const RequestHistory: React.FC<RequestHistoryProps> = ({
history,
showHistory,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onToggleHistory,
onClearHistory,
onLoadFromHistory,
}) => {
const { t } = useLanguage();
// 格式化日期
const formatDate = (date: Date) => {
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
return (
<div className={styles.card}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg text-primary font-medium">{t('tools.http_tester.history')}</h2>
<div className="flex gap-2">
{/* <button
className="text-sm text-tertiary hover:text-secondary"
onClick={onToggleHistory}
>
{showHistory ? '隐藏' : '显示'}
</button> */}
{history.length > 0 && (
<button
className="text-sm text-tertiary hover:text-error"
onClick={onClearHistory}
>
{t('tools.http_tester.clear_history')}
</button>
)}
</div>
</div>
{history.length > 0 ? (
<div className="space-y-1">
{history.map((item, index) => (
<div
key={index}
className={styles.historyItem}
onClick={() => onLoadFromHistory(item)}
>
<div className="flex items-center gap-2">
<span className={styles.historyMethod(item.method)}>
{item.method}
</span>
<span className="text-sm text-primary truncate max-w-[200px]">
{item.url}
</span>
</div>
<span className="text-xs text-tertiary">
{formatDate(item.timestamp)}
</span>
</div>
))}
</div>
) : (
<div className="text-center text-tertiary py-4">
{t('tools.http_tester.history_empty')}
</div>
)}
{!showHistory && history.length > 0 && (
<div className="text-sm text-tertiary mt-4">
<p>{t('tools.http_tester.history')}</p>
</div>
)}
</div>
);
};
export default RequestHistory;
@@ -0,0 +1,321 @@
import React, { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe, faCopy, faCheck, faFileAlt, faInfoCircle } from '@fortawesome/free-solid-svg-icons';
import styles from '../styles';
import { HttpResponse, HttpMethod, NetworkType } from '../types';
import JsonRenderer from './JsonRenderer';
import { generateMarkdownDoc } from '../utils';
import MarkdownPreview from './MarkdownPreview';
import { useLanguage } from '@/context/LanguageContext';
interface ResponseDisplayProps {
response: HttpResponse | null;
url: string;
method: HttpMethod;
headers: {key: string; value: string; id: string}[];
body: string;
bodyFormat: 'json' | 'text' | 'form';
formFields: {key: string; value: string; id: string}[];
networkType: NetworkType;
}
const ResponseDisplay: React.FC<ResponseDisplayProps> = ({
response,
url,
method,
headers,
body,
bodyFormat,
formFields,
networkType
}) => {
const { t } = useLanguage();
const [responseTab, setResponseTab] = useState<'body' | 'headers' | 'info'>('body');
const [copiedJson, setCopiedJson] = useState(false);
const [markdownContent, setMarkdownContent] = useState<string>('');
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
// 美化JSON
const formatJson = (json: string): string => {
try {
return JSON.stringify(JSON.parse(json), null, 2);
} catch {
return json;
}
};
// 检测内容是否为JSON
const isJsonContent = (data: unknown): boolean => {
// 检查响应头中的Content-Type
if (response?.headers && response.headers['content-type']?.includes('application/json')) {
return true;
}
// 对于对象类型的数据,直接判定为JSON
if (typeof data === 'object' && data !== null) {
return true;
}
// 尝试解析字符串
if (typeof data === 'string') {
try {
JSON.parse(data);
return true;
} catch {
return false;
}
}
return false;
};
// 复制响应内容
const copyResponse = () => {
if (!response) return;
const textToCopy = responseTab === 'body'
? typeof response.data === 'string'
? response.data
: JSON.stringify(response.data, null, 2)
: Object.entries(response.headers)
.map(([key, value]) => `${key}: ${value}`)
.join('\n');
navigator.clipboard.writeText(textToCopy)
.then(() => {
setCopiedJson(true);
setTimeout(() => setCopiedJson(false), 2000);
})
.catch(err => console.error(t('tools.http_tester.copy_failed'), err));
};
// 生成并显示Markdown接口文档
const handleGenerateMarkdown = () => {
if (!response) return;
const markdownDoc = generateMarkdownDoc(
url,
method,
headers,
body,
bodyFormat,
formFields,
response,
networkType
);
setMarkdownContent(markdownDoc);
setShowMarkdownPreview(true);
};
// 关闭Markdown预览窗口
const handleCloseMarkdownPreview = () => {
setShowMarkdownPreview(false);
};
// 调整响应区域高度以适应可用空间并跟随内容变化
useEffect(() => {
if (!response) return;
const adjustHeight = () => {
if (!containerRef.current || !contentRef.current) return;
// 计算可用的视窗高度
const viewportHeight = window.innerHeight;
// 获取容器到视窗顶部的距离
const containerTop = containerRef.current.getBoundingClientRect().top;
// 设置底部边距
const bottomMargin = 40;
// 计算容器可用的最大高度(视口高度限制)
const maxViewportHeight = viewportHeight - containerTop - bottomMargin;
// 获取内容实际高度
const contentHeight = contentRef.current.scrollHeight;
// 设置容器初始高度为视口可用高度
let targetHeight = Math.max(600, maxViewportHeight);
// 如果内容高度超过初始高度,则让容器跟随内容增高
// 最小高度600px,最大不超过内容高度+100px(为头部和边距预留空间)
if (contentHeight > targetHeight - 100) {
targetHeight = Math.min(contentHeight + 100, 2000); // 设置一个最大值2000px,防止过长
}
// 应用高度
containerRef.current.style.minHeight = `${targetHeight}px`;
};
// 初始调整
adjustHeight();
// 设置一个延时调整,确保内容渲染完成后再次计算高度
const timeoutId = setTimeout(adjustHeight, 100);
// 监听窗口大小变化
window.addEventListener('resize', adjustHeight);
return () => {
window.removeEventListener('resize', adjustHeight);
clearTimeout(timeoutId);
};
}, [response, responseTab]);
return (
<div className={`${styles.card}`} ref={containerRef}>
<h2 className="text-lg text-primary font-medium mb-4">{t('tools.http_tester.response_result')}</h2>
{response ? (
<div className="flex flex-col flex-grow w-full">
{/* 响应头部 */}
<div className={styles.responseHeader}>
<div className="flex items-center gap-2">
<span className={styles.statusBadge(response.status)}>
{response.status}
</span>
<span className="text-sm text-secondary">
{response.statusText}
</span>
</div>
<div className="flex items-center gap-3">
<div className={styles.statsText}>
{response.size} bytes | {response.time}ms
</div>
<button
className={styles.copyButton}
onClick={copyResponse}
title={t('tools.http_tester.copy')}
>
<FontAwesomeIcon icon={copiedJson ? faCheck : faCopy} />
{copiedJson ? t('tools.http_tester.copied') : t('tools.http_tester.copy')}
</button>
<button
className={styles.copyButton}
onClick={handleGenerateMarkdown}
title={t('tools.http_tester.generate_doc')}
>
<FontAwesomeIcon icon={faFileAlt} className="mr-1" />
{t('tools.http_tester.generate_doc')}
</button>
</div>
</div>
{/* 响应标签页 */}
<div className="border-b border-purple-glow/30 mb-4">
<div className="flex">
<button
className={styles.tabButton(responseTab === 'body')}
onClick={() => setResponseTab('body')}
>
{t('tools.http_tester.response_body')}
</button>
<button
className={styles.tabButton(responseTab === 'headers')}
onClick={() => setResponseTab('headers')}
>
{t('tools.http_tester.response_headers')}
</button>
<button
className={styles.tabButton(responseTab === 'info')}
onClick={() => setResponseTab('info')}
>
{t('tools.http_tester.request_info')}
</button>
</div>
</div>
{/* 响应内容 */}
<div className={`${styles.responseBox} flex-grow`} ref={contentRef}>
{responseTab === 'body' && (
<>
{isJsonContent(response.data)
? <JsonRenderer data={response.data} />
: (typeof response.data === 'string'
? response.data
: formatJson(JSON.stringify(response.data)))}
</>
)}
{responseTab === 'headers' && (
Object.entries(response.headers).map(([key, value]) => (
<div key={key}>
<span className="text-purple">{key}</span>: {value}
</div>
))
)}
{responseTab === 'info' && (
<div className="space-y-2">
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.network_mode')}:</span> {networkType === 'local' ? t('tools.http_tester.network_mode_local') : t('tools.http_tester.network_mode_public')}
{networkType === 'local' && (
<span className="ml-2 text-amber-400 text-xs">
<FontAwesomeIcon icon={faInfoCircle} className="mr-1" />
{t('tools.http_tester.cors_description')}
</span>
)}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.request_url')}:</span> {url}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.request_method')}:</span> {method}
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.response_result')}:</span> {response.time}ms
</div>
<div className="text-sm">
<span className="text-purple font-medium">{t('tools.http_tester.response_result')}:</span> {response.size} bytes
</div>
{Object.entries(headers).length > 0 && (
<div>
<div className="text-purple font-medium text-sm mt-4 mb-2">{t('tools.http_tester.request_headers')}</div>
<div className="bg-background/40 p-3 rounded text-xs">
{headers.filter(h => h.key && h.value).map((header, index) => (
<div key={header.id || index}>
{header.key}: {header.value}
</div>
))}
</div>
</div>
)}
{['POST', 'PUT', 'PATCH'].includes(method) && (
<div>
<div className="text-purple font-medium text-sm mt-4 mb-2">{t('tools.http_tester.request_body')}</div>
<div className="bg-background/40 p-3 rounded text-xs break-all font-mono">
{bodyFormat === 'json' ? formatJson(body) :
bodyFormat === 'form' ?
formFields.filter(f => f.key && f.value)
.map((field, _index) => `${field.key}=${field.value}`)
.join('&') :
body}
</div>
</div>
)}
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-20 text-tertiary">
<FontAwesomeIcon icon={faGlobe} className="text-4xl mb-4 text-purple-glow" />
<p>{t('tools.http_tester.enter_url')}</p>
</div>
)}
{/* Markdown预览模态窗口 */}
<MarkdownPreview
markdown={markdownContent}
isOpen={showMarkdownPreview}
onClose={handleCloseMarkdownPreview}
/>
</div>
);
};
export default ResponseDisplay;
+384
View File
@@ -0,0 +1,384 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faGlobe, faTrash } from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import styles from './styles';
import { useLanguage } from '@/context/LanguageContext';
// 导入类型
import { HttpMethod, RequestHeader, HttpResponse, HistoryItem, NetworkType } from './types';
// 导入组件
import RequestForm from './components/RequestForm';
import RequestHeaders from './components/RequestHeaders';
import RequestBody from './components/RequestBody';
import ResponseDisplay from './components/ResponseDisplay';
import RequestHistory from './components/RequestHistory';
import ErrorDisplay from './components/ErrorDisplay';
// 导入工具函数
import { sendHttpRequest } from './utils';
// 本地存储的key
const HISTORY_STORAGE_KEY = 'http_tester_history';
export default function HttpTester() {
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'http_tester');
// 请求配置
const [url, setUrl] = useState('https://jsonplaceholder.typicode.com/posts/1');
const [method, setMethod] = useState<HttpMethod>('GET');
const [headers, setHeaders] = useState<RequestHeader[]>([
{ key: 'Content-Type', value: 'application/json', id: Date.now().toString() }
]);
const [body, setBody] = useState('');
const [bodyFormat, setBodyFormat] = useState<'json' | 'text' | 'form'>('json');
// 添加表单字段状态
const [formFields, setFormFields] = useState<RequestHeader[]>([
{ key: '', value: '', id: Date.now().toString() }
]);
// 网络类型(新增)
const [networkType, setNetworkType] = useState<NetworkType>('public');
// 响应状态
const [response, setResponse] = useState<HttpResponse | null>(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'body' | 'headers'>('body');
const [error, setError] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(true); // 默认显示历史记录
// 历史记录
const [history, setHistory] = useState<HistoryItem[]>([]);
// 上一次bodyFormat的引用
const prevBodyFormatRef = useRef(bodyFormat);
// 从本地存储加载历史记录 - 放在顶部优先执行
useEffect(() => {
// 确保在客户端运行
if (typeof window === 'undefined') return;
try {
// 直接从localStorage获取数据
const savedHistory = localStorage.getItem(HISTORY_STORAGE_KEY);
if (savedHistory) {
const parsedHistory = JSON.parse(savedHistory);
// 转换时间戳为Date对象
const processedHistory = parsedHistory.map((item: {url: string, method: HttpMethod, timestamp: string}) => ({
...item,
timestamp: new Date(item.timestamp)
}));
// 设置历史记录
setHistory(processedHistory);
}
} catch (e) {
console.error(t('tools.http_tester.copy_failed'), e);
}
}, [t]);
// 监听bodyFormat变化,自动更新Content-Type
useEffect(() => {
// 如果bodyFormat没有变化,直接返回
if (prevBodyFormatRef.current === bodyFormat) return;
// 更新上一次的bodyFormat
prevBodyFormatRef.current = bodyFormat;
// 获取对应的Content-Type值
let newContentType = '';
if (bodyFormat === 'json') {
newContentType = 'application/json';
} else if (bodyFormat === 'form') {
newContentType = 'application/x-www-form-urlencoded';
} else if (bodyFormat === 'text') {
newContentType = 'text/plain';
}
// 使用函数式更新,避免依赖headers
setHeaders(prevHeaders => {
// 查找现有的Content-Type请求头
const contentTypeIndex = prevHeaders.findIndex(
h => h.key.toLowerCase() === 'content-type'
);
// 如果找到了Content-Type并且需要更新
if (contentTypeIndex !== -1) {
const updatedHeaders = [...prevHeaders];
updatedHeaders[contentTypeIndex] = {
...updatedHeaders[contentTypeIndex],
value: newContentType
};
return updatedHeaders;
} else if (newContentType) {
// 如果没有找到Content-Type但需要添加
return [
...prevHeaders,
{ key: 'Content-Type', value: newContentType, id: Date.now().toString() }
];
}
// 没有变化时返回原来的headers
return prevHeaders;
});
}, [bodyFormat]); // 只依赖bodyFormat
// 在组件挂载时检查是否是从首页导航过来
useEffect(() => {
// 确保在客户端运行
if (typeof window !== 'undefined') {
// 检查是否是从首页导航过来的标记
const fromHomepage = sessionStorage.getItem('from_homepage');
if (fromHomepage) {
// 确保历史记录显示
setShowHistory(true);
// 清除标记
sessionStorage.removeItem('from_homepage');
}
}
}, []);
// 当历史记录更新时保存到本地存储
useEffect(() => {
if (typeof window === 'undefined' || history.length === 0) return;
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(history));
}, [history]);
// 添加请求头
const addHeader = () => {
setHeaders(prevHeaders => [...prevHeaders, { key: '', value: '', id: Date.now().toString() }]);
};
// 更新请求头
const updateHeader = (id: string, key: string, value: string) => {
setHeaders(prevHeaders =>
prevHeaders.map(header =>
header.id === id ? { ...header, key, value } : header
)
);
};
// 删除请求头
const removeHeader = (id: string) => {
setHeaders(prevHeaders => prevHeaders.filter(header => header.id !== id));
};
// 添加表单字段
const addFormField = () => {
setFormFields(prevFields => [...prevFields, { key: '', value: '', id: Date.now().toString() }]);
};
// 更新表单字段
const updateFormField = (id: string, key: string, value: string) => {
setFormFields(prevFields =>
prevFields.map(field =>
field.id === id ? { ...field, key, value } : field
)
);
};
// 删除表单字段
const removeFormField = (id: string) => {
setFormFields(prevFields => prevFields.filter(field => field.id !== id));
};
// 清空所有内容
const clearAll = () => {
// 一次性重置所有状态
setUrl('https://jsonplaceholder.typicode.com/posts/1');
setMethod('GET');
setHeaders([{ key: 'Content-Type', value: 'application/json', id: Date.now().toString() }]);
setBody('');
setFormFields([{ key: '', value: '', id: Date.now().toString() }]);
setResponse(null);
setError(null);
// 确保bodyFormat匹配Content-Type
setBodyFormat('json');
};
// 发送请求
const handleSendRequest = async () => {
setLoading(true);
setResponse(null);
setError(null);
const result = await sendHttpRequest(
url,
method,
headers,
body,
bodyFormat,
formFields,
networkType
);
if (result.error) {
setError(result.error);
} else if (result.response) {
setResponse(result.response);
// 更新历史记录
setHistory(prev => [
{ url, method, timestamp: new Date() },
...prev.slice(0, 9) // 保留最近10条
]);
}
setLoading(false);
};
// 从历史记录加载请求
const loadFromHistory = (historyItem: {url: string, method: HttpMethod}) => {
setUrl(historyItem.url);
setMethod(historyItem.method);
// 不重置其他状态,保留当前的请求头和请求体
};
// 清空历史记录
const clearHistory = () => {
if (confirm(t('tools.http_tester.clear_history_confirm'))) {
setHistory([]);
localStorage.removeItem(HISTORY_STORAGE_KEY);
}
};
// 切换历史记录显示状态
const toggleHistory = () => {
setShowHistory(!showHistory);
};
return (
<div className={styles.container}>
{/* 工具头部 */}
<ToolHeader
icon={toolConfig?.icon || faGlobe}
toolCode="http_tester"
title=""
description=""
/>
{/* 轻量提示信息 */}
<div className="mb-4 text-xs text-tertiary italic text-right">
<a
href="https://www.showdoc.com.cn/runapi"
target="_blank"
rel="noopener noreferrer"
className="text-purple hover:text-purple-hover transition-colors"
>
{t('tools.http_tester.need_advanced')}
</a>
</div>
{/* 错误提示 */}
<ErrorDisplay error={error} />
{/* 主要内容区 */}
<div className={styles.grid}>
{/* 左侧 - 请求配置 */}
<div className="space-y-6 flex flex-col">
{/* 请求表单 */}
<div className={styles.card}>
<div className={styles.formHeader}>
<h2 className="text-lg text-primary font-medium">{t('tools.http_tester.http_request')}</h2>
<button
className="btn-secondary text-xs px-3 py-1"
onClick={clearAll}
>
<FontAwesomeIcon icon={faTrash} className="mr-1" />
{t('tools.http_tester.clear')}
</button>
</div>
{/* 请求URL和方法 */}
<RequestForm
url={url}
method={method}
loading={loading}
networkType={networkType}
onUrlChange={setUrl}
onMethodChange={setMethod}
onNetworkTypeChange={setNetworkType}
onSendRequest={handleSendRequest}
/>
{/* 请求参数标签页 */}
<div className="border-b border-purple-glow/30 mb-4">
<div className="flex">
<button
className={styles.tabButton(activeTab === 'headers')}
onClick={() => setActiveTab('headers')}
>
{t('tools.http_tester.request_headers')}
</button>
<button
className={styles.tabButton(activeTab === 'body')}
onClick={() => setActiveTab('body')}
>
{t('tools.http_tester.request_body')}
</button>
</div>
</div>
{/* 请求头 */}
{activeTab === 'headers' && (
<RequestHeaders
headers={headers}
onAddHeader={addHeader}
onUpdateHeader={updateHeader}
onRemoveHeader={removeHeader}
/>
)}
{/* 请求体 */}
{activeTab === 'body' && (
<RequestBody
bodyFormat={bodyFormat}
body={body}
formFields={formFields}
onBodyChange={setBody}
onBodyFormatChange={setBodyFormat}
onAddFormField={addFormField}
onUpdateFormField={updateFormField}
onRemoveFormField={removeFormField}
/>
)}
</div>
{/* 历史记录 */}
<RequestHistory
history={history}
showHistory={showHistory}
onToggleHistory={toggleHistory}
onClearHistory={clearHistory}
onLoadFromHistory={loadFromHistory}
/>
</div>
{/* 右侧 - 响应结果 */}
<div className="flex w-full">
<ResponseDisplay
response={response}
url={url}
method={method}
headers={headers}
body={body}
bodyFormat={bodyFormat}
formFields={formFields}
networkType={networkType}
/>
</div>
</div>
{/* 回到顶部按钮 */}
<BackToTop position="bottom-right" offset={30} size="medium" />
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
// 定义样式对象
const styles = {
card: "card p-6 h-full flex flex-col transition-all duration-300 w-full",
input: "search-input w-full",
textArea: "w-full h-48 p-3 bg-block border border-purple-glow rounded-lg text-primary focus:border-purple focus:outline-none focus:ring-1 focus:ring-purple resize-y",
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6 pb-16",
grid: "grid grid-cols-1 lg:grid-cols-2 gap-6 flex-grow",
tabButton: (active: boolean) => `px-4 py-2 text-sm font-medium ${active ? 'text-purple border-b-2 border-purple' : 'text-tertiary hover:text-secondary'}`,
methodButton: (active: boolean) => `px-3 py-1 text-xs rounded-md ${active ? 'bg-purple-glow/20 text-purple' : 'bg-block-strong text-secondary'}`,
responseHeader: "flex items-center justify-between bg-block-strong p-3 rounded-t-lg",
statusBadge: (status: number) => {
if (status >= 200 && status < 300) return "px-2 py-1 bg-green-900/20 text-success text-xs rounded-md";
if (status >= 300 && status < 400) return "px-2 py-1 bg-blue-900/20 text-blue-500 text-xs rounded-md";
if (status >= 400 && status < 500) return "px-2 py-1 bg-yellow-900/20 text-warning text-xs rounded-md";
return "px-2 py-1 bg-red-900/20 text-error text-xs rounded-md";
},
responseBox: "bg-block p-3 border border-purple-glow rounded-lg font-mono text-sm text-primary overflow-auto min-h-[400px] flex-grow whitespace-pre-wrap w-full",
historyItem: "flex items-center justify-between p-2 hover:bg-block-hover rounded-md cursor-pointer",
historyMethod: (method: string) => {
const colors: Record<string, string> = {
GET: "bg-green-900/20 text-success",
POST: "bg-blue-900/20 text-blue-500",
PUT: "bg-yellow-900/20 text-warning",
DELETE: "bg-red-900/20 text-error",
PATCH: "bg-purple-900/20 text-purple",
default: "bg-block-strong text-secondary"
};
return `px-2 py-1 text-xs rounded-md ${colors[method] || colors.default}`;
},
formHeader: "mb-4 flex items-center justify-between",
headerRow: "flex items-center gap-2 mb-2",
errorBox: "p-3 bg-red-900/20 border border-red-700/30 text-error rounded-lg mb-4",
iconButton: "p-1 text-secondary hover:text-primary",
copyButton: "flex items-center gap-1 text-xs px-2 py-1 rounded bg-block-strong hover:bg-block-hover text-secondary transition-colors",
statsText: "text-xs text-tertiary",
};
export default styles;
+36
View File
@@ -0,0 +1,36 @@
// 定义HTTP方法类型
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
// 定义请求头类型
export type RequestHeader = {
key: string;
value: string;
id: string;
};
// 定义历史记录项类型
export type HistoryItem = {
url: string;
method: HttpMethod;
timestamp: Date;
};
// 定义响应数据类型
export type ResponseData = string | Record<string, unknown>;
// 定义响应类型
export type HttpResponse = {
status: number;
statusText: string;
headers: Record<string, string>;
data: ResponseData;
time: number;
size: number;
error?: string;
};
// 定义表单字段类型 (与请求头结构相同)
export type FormField = RequestHeader;
// 定义网络类型(本地/公网)
export type NetworkType = 'public' | 'local';
+348
View File
@@ -0,0 +1,348 @@
import { HttpMethod, HttpResponse, RequestHeader, NetworkType, ResponseData } from './types';
import { apiClient } from '@/lib/api-client';
// 代理响应接口
interface ProxyResponse {
status: number;
statusText: string;
headers: Record<string, string>;
data: ResponseData;
time?: number;
size?: number;
}
/**
* 发送HTTP请求
* @param url 请求URL
* @param method 请求方法
* @param headers 请求头
* @param body 请求体
* @param bodyFormat 请求体格式
* @param formFields 表单字段
* @param networkType 网络类型(公网/本地)
*/
export const sendHttpRequest = async (
url: string,
method: HttpMethod,
headers: RequestHeader[],
body: string,
bodyFormat: 'json' | 'text' | 'form',
formFields: RequestHeader[],
networkType: NetworkType = 'public'
): Promise<{ response: HttpResponse | null; error: string | null }> => {
try {
if (!url.trim()) {
return { response: null, error: 'URL不能为空' };
}
// 准备请求头
const headerObj: Record<string, string> = {};
headers.forEach(h => {
if (h.key.trim() && h.value.trim()) {
headerObj[h.key] = h.value;
}
});
// 准备请求体
let requestBody: string | FormData | undefined;
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (bodyFormat === 'json') {
try {
// 尝试验证JSON格式
if (body.trim()) {
JSON.parse(body);
}
requestBody = body;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// 忽略具体错误,只返回格式无效的消息
return { response: null, error: '请求体不是有效的JSON格式' };
}
} else if (bodyFormat === 'form') {
try {
// 使用表单字段构建请求体
const formData = new URLSearchParams();
formFields.forEach(field => {
if (field.key.trim() && field.value.trim()) {
formData.append(field.key, field.value);
}
});
if ([...formData.keys()].length === 0) {
return { response: null, error: '表单至少需要一个有效的字段' };
}
requestBody = formData.toString();
// 设置适当的Content-Type
headerObj['Content-Type'] = 'application/x-www-form-urlencoded';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// 忽略具体错误,只返回处理失败的消息
return { response: null, error: '表单数据处理失败,请检查输入的字段值' };
}
} else {
requestBody = body;
}
}
const startTime = performance.now();
// 根据网络类型决定发送请求的方式
let responseData: ProxyResponse;
let clientTime: number;
// 如果是本地/局域网请求,直接发送请求(不通过代理)
if (networkType === 'local') {
try {
// 直接使用fetch API发送请求到目标地址
const response = await fetch(url, {
method,
headers: headerObj,
body: ['POST', 'PUT', 'PATCH'].includes(method) ? requestBody : undefined,
});
const endTime = performance.now();
clientTime = Math.round(endTime - startTime);
// 获取响应头
const headers: Record<string, string> = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
// 尝试解析响应体
let data: ResponseData;
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
data = await response.json();
} else {
data = await response.text();
}
// 计算响应大小
const bodyText = typeof data === 'string' ? data : JSON.stringify(data);
const size = new Blob([bodyText]).size;
responseData = {
status: response.status,
statusText: response.statusText,
headers,
data,
time: clientTime,
size,
};
} catch (error) {
return {
response: null,
error: `本地请求失败: ${(error as Error).message}。请确保目标服务器已配置CORS,允许跨域请求。`
};
}
} else {
// 通过后端代理发送请求,避免跨域问题
const proxyResponse = await apiClient.post<ProxyResponse>('/api/proxy', {
url,
method,
headers: headerObj,
body: requestBody,
});
const endTime = performance.now();
clientTime = Math.round(endTime - startTime);
responseData = proxyResponse;
}
return {
response: {
status: responseData.status,
statusText: responseData.statusText,
headers: responseData.headers,
data: responseData.data,
// 使用服务器计算的响应时间或客户端时间
time: responseData.time || clientTime,
size: responseData.size || 0,
},
error: null
};
} catch (error) {
console.error('请求错误', error);
return {
response: null,
error: (error as Error).message || '请求失败'
};
}
};
/**
* 生成Markdown格式的接口文档
* @param url 请求URL
* @param method 请求方法
* @param headers 请求头
* @param requestBody 请求体
* @param bodyFormat 请求体格式
* @param formFields 表单字段
* @param response 响应数据
* @param networkType 网络类型(公网/本地)
* @returns Markdown格式的接口文档
*/
export const generateMarkdownDoc = (
url: string,
method: HttpMethod,
headers: RequestHeader[],
requestBody: string,
bodyFormat: 'json' | 'text' | 'form',
formFields: RequestHeader[],
response: HttpResponse | null,
networkType: NetworkType = 'public'
): string => {
// 准备请求体展示
let requestBodyContent = '';
if (['POST', 'PUT', 'PATCH'].includes(method)) {
if (bodyFormat === 'json' && requestBody.trim()) {
try {
// 美化JSON
const parsedJson = JSON.parse(requestBody);
requestBodyContent = JSON.stringify(parsedJson, null, 2);
} catch {
requestBodyContent = requestBody;
}
} else if (bodyFormat === 'form') {
// 构建表单内容,但不需要在这里生成请求体内容
// 因为我们将在下面的请求参数部分直接使用表格展示
const formData = new URLSearchParams();
formFields.forEach(field => {
if (field.key.trim() && field.value.trim()) {
formData.append(field.key, field.value);
}
});
// 在表单模式下,请求体内容为空,因为我们会直接使用表格展示
requestBodyContent = '';
} else {
requestBodyContent = requestBody;
}
}
// 过滤有效的请求头
const validHeaders = headers.filter(h => h.key.trim() && h.value.trim());
// 构建Markdown文档 - 使用ShowDoc风格
let markdown = ``;
// 简要描述部分
markdown += `**简要描述:** \n\n`;
markdown += `- 自动生成的API接口文档\n\n`;
// 请求模式部分(新增)
markdown += `**请求模式:** \n\n`;
markdown += `- ${networkType === 'local' ? '本地/局域网' : '公网代理'} \n\n`;
// 请求URL部分
markdown += `**请求URL** \n\n`;
markdown += `- \`${url}\`\n\n`;
// 请求方式部分
markdown += `**请求方式:**\n\n`;
markdown += `- ${method} \n\n`;
// 请求头部分(如果有)
if (validHeaders.length > 0) {
markdown += `**请求头:** \n\n`;
markdown += `| 参数名 | 必选 | 参数值 | 说明 |\n`;
markdown += `| ------ | ---- | ------ | ---- |\n`;
validHeaders.forEach(header => {
const isContent = header.key.toLowerCase() === 'content-type';
markdown += `| ${header.key} | ${isContent ? '是' : '否'} | ${header.value} | ${isContent ? '请求数据类型' : '-'} |\n`;
});
markdown += `\n`;
}
// 请求参数部分(针对POST、PUT等方法)
if (['POST', 'PUT', 'PATCH'].includes(method) && (requestBodyContent || bodyFormat === 'form')) {
markdown += `**请求参数:** \n\n`;
// 针对表单格式单独处理
if (bodyFormat === 'form') {
// 直接生成表单参数表格
markdown += `| 参数名 | 必选 | 类型 | 说明 |\n`;
markdown += `| ------ | ---- | ---- | ---- |\n`;
const validFormFields = formFields.filter(field => field.key.trim() && field.value.trim());
if (validFormFields.length > 0) {
validFormFields.forEach(field => {
markdown += `| ${field.key} | 是 | string | - |\n`;
});
markdown += `\n`;
// 添加URL编码格式的说明
const formData = new URLSearchParams();
validFormFields.forEach(field => {
formData.append(field.key, field.value);
});
markdown += `**表单URL编码格式:** \n\n`;
markdown += `\`${formData.toString()}\`\n\n`;
} else {
markdown += `| - | - | - | 无参数 |\n\n`;
}
}
// JSON或文本格式的处理
else {
// 尝试解析参数并构建表格
try {
if (bodyFormat === 'json') {
const parsedBody = JSON.parse(requestBodyContent);
if (typeof parsedBody === 'object' && parsedBody !== null) {
markdown += `| 参数名 | 必选 | 类型 | 说明 |\n`;
markdown += `| ------ | ---- | ---- | ---- |\n`;
Object.entries(parsedBody).forEach(([key, value]) => {
const type = Array.isArray(value) ? 'array' : typeof value;
markdown += `| ${key} | 是 | ${type} | - |\n`;
});
markdown += `\n`;
} else {
markdown += `\`\`\`json\n${requestBodyContent}\n\`\`\`\n\n`;
}
} else if (requestBodyContent.trim()) {
markdown += `\`\`\`\n${requestBodyContent}\n\`\`\`\n\n`;
}
} catch {
if (requestBodyContent.trim()) {
markdown += `\`\`\`\n${requestBodyContent}\n\`\`\`\n\n`;
}
}
}
}
// 响应结果部分
if (response) {
// 返回示例
markdown += `**返回示例**\n\n`;
if (typeof response.data === 'object') {
// 直接使用JSON.stringify的缩进参数格式化JSON
markdown += `\`\`\`json\n${JSON.stringify(response.data, null, 2)}\n\`\`\`\n\n`;
} else if (typeof response.data === 'string') {
// 尝试检测是否为JSON字符串
try {
const parsedJson = JSON.parse(response.data);
markdown += `\`\`\`json\n${JSON.stringify(parsedJson, null, 2)}\n\`\`\`\n\n`;
} catch {
// 不是JSON字符串,直接显示
markdown += `\`\`\`\n${response.data}\n\`\`\`\n\n`;
}
} else {
markdown += `\`\`\`\n${response.data}\n\`\`\`\n\n`;
}
}
// 备注
markdown += `**备注** \n\n`;
markdown += `- 此文档由HTTP测试工具自动生成\n`;
markdown += `- 响应时间: ${response?.time || '-'}ms\n`;
markdown += `- 响应大小: ${response?.size || '-'} bytes\n`;
return markdown;
};