add
This commit is contained in:
@@ -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">"{value}"</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">"{key}"</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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user