add debuger

This commit is contained in:
musistudio
2025-09-10 21:22:10 +08:00
parent fe06b57032
commit f72c67d5c1
10 changed files with 1159 additions and 21 deletions

View File

@@ -16,6 +16,7 @@
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1",

102
ui/pnpm-lock.yaml generated
View File

@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-switch':
specifier: ^1.2.5
version: 1.2.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-tabs':
specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-tooltip':
specifier: ^1.2.7
version: 1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -517,6 +520,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@@ -548,6 +564,15 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-direction@1.1.1':
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.10':
resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==}
peerDependencies:
@@ -709,6 +734,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -731,6 +769,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-tabs@1.1.13':
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
@@ -2333,6 +2384,18 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.8)(react@19.1.0)':
dependencies:
react: 19.1.0
@@ -2367,6 +2430,12 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-direction@1.1.1(@types/react@19.1.8)(react@19.1.0)':
dependencies:
react: 19.1.0
optionalDependencies:
'@types/react': 19.1.8
'@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -2524,6 +2593,23 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0)
@@ -2546,6 +2632,22 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-direction': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.8)(react@19.1.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.8)(react@19.1.0)
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.3

View File

@@ -0,0 +1,496 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Send, Copy, Square, History, Maximize } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import MonacoEditor from '@monaco-editor/react';
import { RequestHistoryDrawer } from './RequestHistoryDrawer';
import { requestHistoryDB } from '@/lib/db';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
export function DebugPage() {
const navigate = useNavigate();
const location = useLocation();
const [requestData, setRequestData] = useState({
url: '',
method: 'POST',
headers: '{}',
body: '{}'
});
const [responseData, setResponseData] = useState({
status: 0,
responseTime: 0,
body: '',
headers: '{}'
});
const [isLoading, setIsLoading] = useState(false);
const [isHistoryDrawerOpen, setIsHistoryDrawerOpen] = useState(false);
const [fullscreenEditor, setFullscreenEditor] = useState<'headers' | 'body' | null>(null);
const headersEditorRef = useRef<any>(null);
const bodyEditorRef = useRef<any>(null);
// 切换全屏模式
const toggleFullscreen = (editorType: 'headers' | 'body') => {
const isEnteringFullscreen = fullscreenEditor !== editorType;
setFullscreenEditor(isEnteringFullscreen ? editorType : null);
// 延迟触发Monaco编辑器的重新布局等待DOM更新完成
setTimeout(() => {
if (headersEditorRef.current) {
headersEditorRef.current.layout();
}
if (bodyEditorRef.current) {
bodyEditorRef.current.layout();
}
}, 300);
};
// 从URL参数中解析日志数据
useEffect(() => {
const params = new URLSearchParams(location.search);
const logDataParam = params.get('logData');
if (logDataParam) {
try {
const parsedData = JSON.parse(decodeURIComponent(logDataParam));
// 解析URL - 支持多种字段名
const url = parsedData.url || parsedData.requestUrl || parsedData.endpoint || '';
// 解析Method - 支持多种字段名和大小写
const method = (parsedData.method || parsedData.requestMethod || 'POST').toUpperCase();
// 解析Headers - 支持多种格式
let headers: Record<string, string> = {};
if (parsedData.headers) {
if (typeof parsedData.headers === 'string') {
try {
headers = JSON.parse(parsedData.headers);
} catch {
// 如果是字符串格式,尝试解析为键值对
const headerLines = parsedData.headers.split('\n');
headerLines.forEach((line: string) => {
const [key, ...values] = line.split(':');
if (key && values.length > 0) {
headers[key.trim()] = values.join(':').trim();
}
});
}
} else {
headers = parsedData.headers;
}
}
// 解析Body - 支持多种格式和嵌套结构
let body: Record<string, unknown> = {};
let bodyData = null;
// 支持多种字段名和嵌套结构
if (parsedData.body) {
bodyData = parsedData.body;
} else if (parsedData.request && parsedData.request.body) {
bodyData = parsedData.request.body;
}
if (bodyData) {
if (typeof bodyData === 'string') {
try {
// 尝试解析为JSON对象
const parsed = JSON.parse(bodyData);
body = parsed;
} catch {
// 如果不是JSON检查是否是纯文本
const trimmed = bodyData.trim();
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
// 看起来像JSON但解析失败作为字符串保存
body = { raw: bodyData };
} else {
// 普通文本,直接保存
body = { content: bodyData };
}
}
} else if (typeof bodyData === 'object') {
// 已经是对象,直接使用
body = bodyData;
} else {
// 其他类型,转换为字符串
body = { content: String(bodyData) };
}
}
// 预填充请求表单
setRequestData({
url,
method,
headers: JSON.stringify(headers, null, 2),
body: JSON.stringify(body, null, 2)
});
console.log('Log data parsed successfully:', { url, method, headers, body });
} catch (error) {
console.error('Failed to parse log data:', error);
console.error('Raw log data:', logDataParam);
}
}
}, [location.search]);
// 发送请求
const sendRequest = async () => {
try {
setIsLoading(true);
const headers = JSON.parse(requestData.headers);
const body = JSON.parse(requestData.body);
const startTime = Date.now();
const response = await fetch(requestData.url, {
method: requestData.method,
headers: {
'Content-Type': 'application/json',
...headers
},
body: requestData.method !== 'GET' ? JSON.stringify(body) : undefined
});
const endTime = Date.now();
const responseTime = endTime - startTime;
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
const responseText = await response.text();
let responseBody = responseText;
// 尝试解析JSON响应
try {
const jsonResponse = JSON.parse(responseText);
responseBody = JSON.stringify(jsonResponse, null, 2);
} catch {
// 如果不是JSON保持原样
}
const responseHeadersString = JSON.stringify(responseHeaders, null, 2);
setResponseData({
status: response.status,
responseTime,
body: responseBody,
headers: responseHeadersString
});
// 保存到IndexedDB
await requestHistoryDB.saveRequest({
url: requestData.url,
method: requestData.method,
headers: requestData.headers,
body: requestData.body,
status: response.status,
responseTime,
responseBody,
responseHeaders: responseHeadersString
});
} catch (error) {
console.error('Request failed:', error);
setResponseData({
status: 0,
responseTime: 0,
body: `请求失败: ${error instanceof Error ? error.message : '未知错误'}`,
headers: '{}'
});
} finally {
setIsLoading(false);
}
};
// 从历史记录中选择请求
const handleSelectRequest = (request: import('@/lib/db').RequestHistoryItem) => {
setRequestData({
url: request.url,
method: request.method,
headers: request.headers,
body: request.body
});
setResponseData({
status: request.status,
responseTime: request.responseTime,
body: request.responseBody,
headers: request.responseHeaders
});
};
// 复制cURL命令
const copyCurl = () => {
try {
const headers = JSON.parse(requestData.headers);
const body = JSON.parse(requestData.body);
let curlCommand = `curl -X ${requestData.method} "${requestData.url}"`;
// 添加headers
Object.entries(headers).forEach(([key, value]) => {
curlCommand += ` \\\n -H "${key}: ${value}"`;
});
// 添加body
if (requestData.method !== 'GET' && Object.keys(body).length > 0) {
curlCommand += ` \\\n -d '${JSON.stringify(body)}'`;
}
navigator.clipboard.writeText(curlCommand);
alert('cURL命令已复制到剪贴板');
} catch (error) {
console.error('Failed to copy cURL:', error);
alert('复制cURL命令失败');
}
};
return (
<div className="h-screen bg-gray-50 font-sans">
{/* 头部 */}
<header className="flex h-16 items-center justify-between border-b bg-white px-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => navigate('/dashboard')}>
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
<h1 className="text-xl font-semibold text-gray-800">HTTP </h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setIsHistoryDrawerOpen(true)}>
<History className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={copyCurl}>
<Copy className="h-4 w-4 mr-2" />
cURL
</Button>
</div>
</header>
{/* 主要内容 */}
<main className="flex h-[calc(100vh-4rem)] flex-col gap-4 p-4 overflow-hidden">
{/* 上部分:请求参数配置 - 上中下布局 */}
<div className="h-1/2 flex flex-col gap-4">
<div className="bg-white rounded-lg border p-4 flex-1 flex flex-col">
<h3 className="font-medium mb-4"></h3>
<div className="flex flex-col gap-4 flex-1">
{/* 上Method、URL和发送请求按钮配置 */}
<div className="flex gap-4 items-end">
<div className="w-32">
<label className="block text-sm font-medium mb-1">Method</label>
<select
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
value={requestData.method}
onChange={(e) => setRequestData(prev => ({ ...prev, method: e.target.value }))}
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div className="flex-1">
<label className="block text-sm font-medium mb-1">URL</label>
<input
type="text"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
value={requestData.url}
onChange={(e) => setRequestData(prev => ({ ...prev, url: e.target.value }))}
placeholder="https://api.example.com/endpoint"
/>
</div>
<Button
variant={isLoading ? "destructive" : "default"}
onClick={isLoading ? () => {} : sendRequest}
disabled={isLoading || !requestData.url.trim()}
>
{isLoading ? (
<>
<Square className="h-4 w-4 mr-2" />
...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
</>
)}
</Button>
</div>
{/* Headers和Body配置 - 使用tab布局 */}
<div className="flex-1">
<Tabs defaultValue="headers" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="headers">Headers</TabsTrigger>
<TabsTrigger value="body">Body</TabsTrigger>
</TabsList>
<TabsContent value="headers" className="flex-1 mt-2">
<div
className={`${fullscreenEditor === 'headers' ? '' : 'h-full'} flex flex-col ${
fullscreenEditor === 'headers' ? 'fixed bg-white w-[100vw] h-[100vh] z-[9999] top-0 left-0 p-4' : ''
}`}
>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium">Headers (JSON)</label>
<Button
variant="ghost"
size="sm"
onClick={() => toggleFullscreen('headers')}
>
<Maximize className="h-4 w-4 mr-1" />
{fullscreenEditor === 'headers' ? '退出全屏' : '全屏'}
</Button>
</div>
<div
id="fullscreen-headers"
className={`${fullscreenEditor === 'headers' ? 'h-full' : 'flex-1'} border border-gray-300 rounded-md overflow-hidden relative`}
>
<MonacoEditor
height="100%"
language="json"
value={requestData.headers}
onChange={(value) => setRequestData(prev => ({ ...prev, headers: value || '{}' }))}
onMount={(editor) => {
headersEditorRef.current = editor;
}}
options={{
minimap: { enabled: fullscreenEditor === 'headers' },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
automaticLayout: true,
formatOnPaste: true,
formatOnType: true,
}}
/>
</div>
</div>
</TabsContent>
<TabsContent value="body" className="flex-1 mt-2">
<div
className={`${fullscreenEditor === 'body' ? '' : 'h-full'} flex flex-col ${
fullscreenEditor === 'body' ? 'fixed bg-white w-[100vw] h-[100vh] z-[9999] top-0 left-0 p-4' : ''
}`}
>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium">Body (JSON)</label>
<Button
variant="ghost"
size="sm"
onClick={() => toggleFullscreen('body')}
>
<Maximize className="h-4 w-4 mr-1" />
{fullscreenEditor === 'body' ? '退出全屏' : '全屏'}
</Button>
</div>
<div
id="fullscreen-body"
className={`${fullscreenEditor === 'body' ? 'h-full' : 'flex-1'} border border-gray-300 rounded-md overflow-hidden relative`}
>
<MonacoEditor
height="100%"
language="json"
value={requestData.body}
onChange={(value) => setRequestData(prev => ({ ...prev, body: value || '{}' }))}
onMount={(editor) => {
bodyEditorRef.current = editor;
}}
options={{
minimap: { enabled: fullscreenEditor === 'body' },
scrollBeyondLastLine: false,
fontSize: 14,
lineNumbers: 'on',
wordWrap: 'on',
automaticLayout: true,
formatOnPaste: true,
formatOnType: true,
}}
/>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</div>
{/* 下部分:响应信息查看 */}
<div className="h-1/2 flex flex-col gap-4">
<div className="flex-1 bg-white rounded-lg border p-4 flex flex-col">
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
{responseData.status > 0 && (
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
: <span className={`font-mono px-2 py-1 rounded ${
responseData.status >= 200 && responseData.status < 300
? 'bg-green-100 text-green-800'
: responseData.status >= 400
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{responseData.status}
</span>
</span>
<span>
: <span className="font-mono">{responseData.responseTime}ms</span>
</span>
</div>
)}
</div>
{responseData.body ? (
<div className="flex-1">
<Tabs defaultValue="body" className="h-full flex flex-col">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="body"></TabsTrigger>
<TabsTrigger value="headers"></TabsTrigger>
</TabsList>
<TabsContent value="body" className="flex-1 mt-2">
<div className="bg-gray-50 border rounded-md p-3 h-full overflow-auto">
<pre className="text-sm whitespace-pre-wrap">
{responseData.body}
</pre>
</div>
</TabsContent>
<TabsContent value="headers" className="flex-1 mt-2">
<div className="bg-gray-50 border rounded-md p-3 h-full overflow-auto">
<pre className="text-sm">
{responseData.headers}
</pre>
</div>
</TabsContent>
</Tabs>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-gray-500">
{isLoading ? '发送请求中...' : '发送请求后将在此显示响应'}
</div>
)}
</div>
</div>
</main>
{/* 请求历史抽屉 */}
<RequestHistoryDrawer
isOpen={isHistoryDrawerOpen}
onClose={() => setIsHistoryDrawerOpen(false)}
onSelectRequest={handleSelectRequest}
/>
</div>
);
}

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import Editor from '@monaco-editor/react';
import { Button } from '@/components/ui/button';
import { api } from '@/lib/api';
import { useTranslation } from 'react-i18next';
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers } from 'lucide-react';
import { X, RefreshCw, Download, Trash2, ArrowLeft, File, Layers, Bug } from 'lucide-react';
interface LogViewerProps {
open: boolean;
@@ -17,6 +18,7 @@ interface LogEntry {
message: string; // 现在这个字段直接包含原始JSON字符串
source?: string;
reqId?: string;
[key: string]: any; // 允许动态属性如msg、url、body等
}
interface LogFile {
@@ -50,6 +52,7 @@ interface GroupedLogsResponse {
export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [logs, setLogs] = useState<string[]>([]);
const [logFiles, setLogFiles] = useState<LogFile[]>([]);
const [selectedFile, setSelectedFile] = useState<LogFile | null>(null);
@@ -63,6 +66,7 @@ export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const refreshInterval = useRef<NodeJS.Timeout | null>(null);
const workerRef = useRef<Worker | null>(null);
const editorRef = useRef<any>(null);
useEffect(() => {
if (open) {
@@ -507,6 +511,183 @@ export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
return logs.join('\n');
};
// 解析日志行获取final request的行号
const getFinalRequestLines = () => {
const lines: number[] = [];
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
// 分组模式下,检查选中的请求日志
const requestLogs = groupedLogs.groups[selectedReqId];
requestLogs.forEach((log, index) => {
try {
// @ts-ignore
log = JSON.parse(log)
// 检查日志的msg字段是否等于"final request"
if (log.msg === "final request") {
lines.push(index + 1); // 行号从1开始
}
} catch (e) {
// 解析失败,跳过
}
});
} else {
// 非分组模式下,检查原始日志
logs.forEach((logLine, index) => {
try {
const log = JSON.parse(logLine);
// 检查日志的msg字段是否等于"final request"
if (log.msg === "final request") {
lines.push(index + 1); // 行号从1开始
}
} catch (e) {
// 解析失败,跳过
}
});
}
return lines;
};
// 处理调试按钮点击
const handleDebugClick = (lineNumber: number) => {
console.log('handleDebugClick called with lineNumber:', lineNumber);
console.log('Current state:', { groupByReqId, selectedReqId, logsLength: logs.length });
let logData = null;
if (groupByReqId && groupedLogs && selectedReqId && groupedLogs.groups[selectedReqId]) {
// 分组模式下获取日志数据
const requestLogs = groupedLogs.groups[selectedReqId];
console.log('Group mode - requestLogs length:', requestLogs.length);
logData = requestLogs[lineNumber - 1]; // 行号转换为数组索引
console.log('Group mode - logData:', logData);
} else {
// 非分组模式下获取日志数据
console.log('Non-group mode - logs length:', logs.length);
try {
const logLine = logs[lineNumber - 1];
console.log('Log line:', logLine);
logData = JSON.parse(logLine);
console.log('Parsed logData:', logData);
} catch (e) {
console.error('Failed to parse log data:', e);
}
}
if (logData) {
console.log('Navigating to debug page with logData:', logData);
// 导航到调试页面并传递日志数据作为URL参数
const logDataParam = encodeURIComponent(JSON.stringify(logData));
console.log('Encoded logDataParam length:', logDataParam.length);
navigate(`/debug?logData=${logDataParam}`);
} else {
console.error('No log data found for line:', lineNumber);
}
};
// 配置Monaco Editor
const configureEditor = (editor: any) => {
editorRef.current = editor;
// 启用glyph margin
editor.updateOptions({
glyphMargin: true,
});
// 存储当前的装饰ID
let currentDecorations: string[] = [];
// 添加glyph margin装饰
const updateDecorations = () => {
const finalRequestLines = getFinalRequestLines();
const decorations = finalRequestLines.map(lineNumber => ({
range: {
startLineNumber: lineNumber,
startColumn: 1,
endLineNumber: lineNumber,
endColumn: 1
},
options: {
glyphMarginClassName: 'debug-button-glyph',
glyphMarginHoverMessage: { value: '点击调试此请求' }
}
}));
// 使用deltaDecorations正确更新装饰清理旧的装饰
currentDecorations = editor.deltaDecorations(currentDecorations, decorations);
};
// 初始更新装饰
updateDecorations();
// 监听glyph margin点击 - 使用正确的事件监听方式
editor.onMouseDown((e: any) => {
console.log('Mouse down event:', e.target);
console.log('Event details:', {
type: e.target.type,
hasDetail: !!e.target.detail,
glyphMarginLane: e.target.detail?.glyphMarginLane,
offsetX: e.target.detail?.offsetX,
glyphMarginLeft: e.target.detail?.glyphMarginLeft,
glyphMarginWidth: e.target.detail?.glyphMarginWidth
});
// 检查是否点击在glyph margin区域
const isGlyphMarginClick = e.target.detail &&
e.target.detail.glyphMarginLane !== undefined &&
e.target.detail.offsetX !== undefined &&
e.target.detail.offsetX <= e.target.detail.glyphMarginLeft + e.target.detail.glyphMarginWidth;
console.log('Is glyph margin click:', isGlyphMarginClick);
if (e.target.position && isGlyphMarginClick) {
const finalRequestLines = getFinalRequestLines();
console.log('Final request lines:', finalRequestLines);
console.log('Clicked line number:', e.target.position.lineNumber);
if (finalRequestLines.includes(e.target.position.lineNumber)) {
console.log('Opening debug page for line:', e.target.position.lineNumber);
handleDebugClick(e.target.position.lineNumber);
}
}
});
// 尝试使用 onGlyphMarginClick 如果可用
if (typeof editor.onGlyphMarginClick === 'function') {
editor.onGlyphMarginClick((e: any) => {
console.log('Glyph margin click event:', e);
const finalRequestLines = getFinalRequestLines();
if (finalRequestLines.includes(e.target.position.lineNumber)) {
console.log('Opening debug page for line (glyph):', e.target.position.lineNumber);
handleDebugClick(e.target.position.lineNumber);
}
});
}
// 添加鼠标移动事件来检测悬停在调试按钮上
editor.onMouseMove((e: any) => {
if (e.target.position && (e.target.type === 4 || e.target.type === 'glyph-margin')) {
const finalRequestLines = getFinalRequestLines();
if (finalRequestLines.includes(e.target.position.lineNumber)) {
// 可以在这里添加悬停效果
editor.updateOptions({
glyphMargin: true,
});
}
}
});
// 当日志变化时更新装饰
const interval = setInterval(updateDecorations, 1000);
return () => {
clearInterval(interval);
// 清理装饰
if (editorRef.current) {
editorRef.current.deltaDecorations(currentDecorations, []);
}
};
};
if (!isVisible && !open) {
return null;
}
@@ -668,23 +849,27 @@ export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) {
</div>
) : (
// 显示日志内容
<Editor
height="100%"
defaultLanguage="json"
value={formatLogsForEditor()}
theme="vs"
options={{
minimap: { enabled: true },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
readOnly: true,
lineNumbers: 'on',
folding: true,
renderWhitespace: 'all',
}}
/>
<div className="relative h-full">
<Editor
height="100%"
defaultLanguage="json"
value={formatLogsForEditor()}
theme="vs"
options={{
minimap: { enabled: true },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
wordWrap: 'on',
readOnly: true,
lineNumbers: 'on',
folding: true,
renderWhitespace: 'all',
glyphMargin: true,
}}
onMount={configureEditor}
/>
</div>
)}
</>
) : (

View File

@@ -0,0 +1,169 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { History, Trash2, Clock, X } from 'lucide-react';
import { requestHistoryDB, type RequestHistoryItem } from '@/lib/db';
interface RequestHistoryDrawerProps {
isOpen: boolean;
onClose: () => void;
onSelectRequest: (request: RequestHistoryItem) => void;
}
export function RequestHistoryDrawer({ isOpen, onClose, onSelectRequest }: RequestHistoryDrawerProps) {
const [requests, setRequests] = useState<RequestHistoryItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (isOpen) {
loadRequests();
}
}, [isOpen]);
const loadRequests = async () => {
try {
setLoading(true);
const history = await requestHistoryDB.getRequests();
setRequests(history);
} catch (error) {
console.error('Failed to load request history:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string, event: React.MouseEvent) => {
event.stopPropagation();
try {
await requestHistoryDB.deleteRequest(id);
setRequests(prev => prev.filter(req => req.id !== id));
} catch (error) {
console.error('Failed to delete request:', error);
}
};
const handleClearAll = async () => {
if (window.confirm('确定要清空所有请求历史吗?')) {
try {
await requestHistoryDB.clearAllRequests();
setRequests([]);
} catch (error) {
console.error('Failed to clear request history:', error);
}
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}小时前`;
return date.toLocaleDateString();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50">
{/* 遮罩层 */}
<div
className="absolute inset-0 bg-black bg-opacity-50"
onClick={onClose}
/>
{/* 抽屉 */}
<div className="absolute right-0 top-0 h-full w-96 bg-white shadow-xl flex flex-col">
{/* 头部 */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-2">
<History className="h-5 w-5" />
<h2 className="text-lg font-semibold"></h2>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
disabled={requests.length === 0}
>
<Trash2 className="h-4 w-4 mr-1" />
</Button>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* 内容 */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="flex items-center justify-center h-32 text-gray-500">
...
</div>
) : requests.length > 0 ? (
<div className="space-y-2">
{requests.map((item) => (
<div
key={item.id}
className="p-3 bg-gray-50 rounded-lg border cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => {
onSelectRequest(item);
onClose();
}}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="font-mono text-xs bg-gray-200 px-2 py-1 rounded">
{item.method}
</span>
<span className="text-sm font-medium truncate flex-1">
{item.url}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => handleDelete(item.id, e)}
className="h-6 w-6 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-2">
<span className={`font-mono px-1 rounded ${
item.status >= 200 && item.status < 300
? 'bg-green-100 text-green-800'
: item.status >= 400
? 'bg-red-100 text-red-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{item.status}
</span>
<span>{item.responseTime}ms</span>
</div>
<div className="flex items-center gap-1">
<Clock className="h-3 w-3" />
<span>{formatTime(item.timestamp)}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-gray-500 py-8">
<History className="h-12 w-12 mx-auto mb-4 text-gray-300" />
<p></p>
<p className="text-sm mt-2"></p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -119,7 +119,7 @@
body {
@apply bg-background text-foreground;
}
/* 美化滚动条 - WebKit浏览器 (Chrome, Safari, Edge) */
::-webkit-scrollbar {
width: 8px;
@@ -153,4 +153,25 @@
.dark * {
scrollbar-color: oklch(0.708 0 0) oklch(0.269 0 0);
}
}
}
/* Monaco Editor 调试按钮样式 */
.debug-button-glyph {
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="%23056bfe" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20v-9"/><path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z"/><path d="M14.12 3.88 16 2"/><path d="M21 21a4 4 0 0 0-3.81-4"/><path d="M21 5a4 4 0 0 1-3.55 3.97"/><path d="M22 13h-4"/><path d="M3 21a4 4 0 0 1 3.81-4"/><path d="M3 5a4 4 0 0 0 3.55 3.97"/><path d="M6 13H2"/><path d="m8 2 1.88 1.88"/><path d="M9 7.13V6a3 3 0 1 1 6 0v1.13"/></svg>') center center no-repeat;
background-size: 14px 14px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.debug-button-glyph:hover {
opacity: 1;
}
/* 确保调试按钮在glyph margin中可见 */
.monaco-editor .margin-view-overlays .debug-button-glyph {
display: block !important;
width: 16px !important;
height: 16px !important;
margin: 2px 0;
}

106
ui/src/lib/db.ts Normal file
View File

@@ -0,0 +1,106 @@
export interface RequestHistoryItem {
id: string;
url: string;
method: string;
headers: string;
body: string;
timestamp: string;
status: number;
responseTime: number;
responseBody: string;
responseHeaders: string;
}
class RequestHistoryDB {
private readonly DB_NAME = 'RequestHistoryDB';
private readonly STORE_NAME = 'requests';
private readonly VERSION = 1;
async openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DB_NAME, this.VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.STORE_NAME)) {
const store = db.createObjectStore(this.STORE_NAME, { keyPath: 'id' });
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('url', 'url', { unique: false });
store.createIndex('method', 'method', { unique: false });
}
};
});
}
async saveRequest(request: Omit<RequestHistoryItem, 'id' | 'timestamp'>): Promise<void> {
const db = await this.openDB();
const item: RequestHistoryItem = {
...request,
id: Date.now().toString(),
timestamp: new Date().toISOString(),
};
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.add(item);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async getRequests(limit: number = 50): Promise<RequestHistoryItem[]> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.STORE_NAME], 'readonly');
const store = transaction.objectStore(this.STORE_NAME);
const index = store.index('timestamp');
const request = index.openCursor(null, 'prev');
const results: RequestHistoryItem[] = [];
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result;
if (cursor && results.length < limit) {
results.push(cursor.value);
cursor.continue();
} else {
resolve(results);
}
};
request.onerror = () => reject(request.error);
});
}
async deleteRequest(id: string): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async clearAllRequests(): Promise<void> {
const db = await this.openDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.STORE_NAME], 'readwrite');
const store = transaction.objectStore(this.STORE_NAME);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
export const requestHistoryDB = new RequestHistoryDB();

View File

@@ -1,6 +1,7 @@
import { createMemoryRouter, Navigate } from 'react-router-dom';
import App from './App';
import { Login } from '@/components/Login';
import { DebugPage } from '@/components/DebugPage';
import ProtectedRoute from '@/components/ProtectedRoute';
import PublicRoute from '@/components/PublicRoute';
@@ -17,6 +18,10 @@ export const router = createMemoryRouter([
path: '/dashboard',
element: <ProtectedRoute><App /></ProtectedRoute>,
},
{
path: '/debug',
element: <ProtectedRoute><DebugPage /></ProtectedRoute>,
},
], {
initialEntries: ['/dashboard']
});

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}
{"root":["./src/app.tsx","./src/i18n.ts","./src/main.tsx","./src/routes.tsx","./src/types.ts","./src/vite-env.d.ts","./src/components/configprovider.tsx","./src/components/debugpage.tsx","./src/components/jsoneditor.tsx","./src/components/logviewer.tsx","./src/components/login.tsx","./src/components/protectedroute.tsx","./src/components/providerlist.tsx","./src/components/providers.tsx","./src/components/publicroute.tsx","./src/components/requesthistorydrawer.tsx","./src/components/router.tsx","./src/components/settingsdialog.tsx","./src/components/statuslineconfigdialog.tsx","./src/components/statuslineimportexport.tsx","./src/components/transformerlist.tsx","./src/components/transformers.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/color-picker.tsx","./src/components/ui/combo-input.tsx","./src/components/ui/combobox.tsx","./src/components/ui/command.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-combobox.tsx","./src/components/ui/popover.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/lib/api.ts","./src/lib/db.ts","./src/lib/utils.ts","./src/utils/statusline.ts"],"version":"5.8.3"}