diff --git a/ui/package.json b/ui/package.json index 04e28a7..e881843 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 9104959..99b0e9f 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -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 diff --git a/ui/src/components/DebugPage.tsx b/ui/src/components/DebugPage.tsx new file mode 100644 index 0000000..cbe4296 --- /dev/null +++ b/ui/src/components/DebugPage.tsx @@ -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(null); + const bodyEditorRef = useRef(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 = {}; + 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 = {}; + 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 = {}; + 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 ( +
+ {/* 头部 */} +
+
+ +

HTTP 调试器

+
+
+ + +
+
+ + {/* 主要内容 */} +
+ {/* 上部分:请求参数配置 - 上中下布局 */} +
+
+

请求参数配置

+
+ {/* 上:Method、URL和发送请求按钮配置 */} +
+
+ + +
+
+ + setRequestData(prev => ({ ...prev, url: e.target.value }))} + placeholder="https://api.example.com/endpoint" + /> +
+ +
+ + {/* Headers和Body配置 - 使用tab布局 */} +
+ + + Headers + Body + + + +
+
+ + +
+
+ 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, + }} + /> +
+
+
+ + +
+
+ + +
+
+ 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, + }} + /> +
+
+
+
+
+
+
+
+ + {/* 下部分:响应信息查看 */} +
+
+
+

响应信息

+ {responseData.status > 0 && ( +
+ + 状态码: = 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} + + + + 响应时间: {responseData.responseTime}ms + +
+ )} +
+ + {responseData.body ? ( +
+ + + 响应体 + 响应头 + + + +
+
+                        {responseData.body}
+                      
+
+
+ + +
+
+                        {responseData.headers}
+                      
+
+
+
+
+ ) : ( +
+ {isLoading ? '发送请求中...' : '发送请求后将在此显示响应'} +
+ )} +
+
+
+ + {/* 请求历史抽屉 */} + setIsHistoryDrawerOpen(false)} + onSelectRequest={handleSelectRequest} + /> +
+ ); +} diff --git a/ui/src/components/LogViewer.tsx b/ui/src/components/LogViewer.tsx index 5401964..8fbdc51 100644 --- a/ui/src/components/LogViewer.tsx +++ b/ui/src/components/LogViewer.tsx @@ -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([]); const [logFiles, setLogFiles] = useState([]); const [selectedFile, setSelectedFile] = useState(null); @@ -63,6 +66,7 @@ export function LogViewer({ open, onOpenChange, showToast }: LogViewerProps) { const containerRef = useRef(null); const refreshInterval = useRef(null); const workerRef = useRef(null); + const editorRef = useRef(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) { ) : ( // 显示日志内容 - +
+ +
)} ) : ( diff --git a/ui/src/components/RequestHistoryDrawer.tsx b/ui/src/components/RequestHistoryDrawer.tsx new file mode 100644 index 0000000..01eadf2 --- /dev/null +++ b/ui/src/components/RequestHistoryDrawer.tsx @@ -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([]); + 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 ( +
+ {/* 遮罩层 */} +
+ + {/* 抽屉 */} +
+ {/* 头部 */} +
+
+ +

请求历史

+
+
+ + +
+
+ + {/* 内容 */} +
+ {loading ? ( +
+ 加载中... +
+ ) : requests.length > 0 ? ( +
+ {requests.map((item) => ( +
{ + onSelectRequest(item); + onClose(); + }} + > +
+
+ + {item.method} + + + {item.url} + +
+ +
+ +
+
+ = 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} + + {item.responseTime}ms +
+
+ + {formatTime(item.timestamp)} +
+
+
+ ))} +
+ ) : ( +
+ +

暂无请求历史

+

发送请求后会在此显示历史记录

+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/ui/tabs.tsx b/ui/src/components/ui/tabs.tsx new file mode 100644 index 0000000..82badaa --- /dev/null +++ b/ui/src/components/ui/tabs.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } \ No newline at end of file diff --git a/ui/src/index.css b/ui/src/index.css index 2958cb3..a3102b3 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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); } -} \ No newline at end of file +} + +/* Monaco Editor 调试按钮样式 */ +.debug-button-glyph { + background: url('data:image/svg+xml;utf8,') 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; +} diff --git a/ui/src/lib/db.ts b/ui/src/lib/db.ts new file mode 100644 index 0000000..f9f8d7c --- /dev/null +++ b/ui/src/lib/db.ts @@ -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 { + 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): Promise { + 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 { + 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 { + 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 { + 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(); \ No newline at end of file diff --git a/ui/src/routes.tsx b/ui/src/routes.tsx index b4a9f94..f5fcd69 100644 --- a/ui/src/routes.tsx +++ b/ui/src/routes.tsx @@ -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: , }, + { + path: '/debug', + element: , + }, ], { initialEntries: ['/dashboard'] }); \ No newline at end of file diff --git a/ui/tsconfig.tsbuildinfo b/ui/tsconfig.tsbuildinfo index 78adc38..28cd675 100644 --- a/ui/tsconfig.tsbuildinfo +++ b/ui/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file