add
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user