diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7b44d44..91fab85 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -596,6 +596,11 @@ "error_logs_modified": "Last modified", "error_logs_download": "Download", "error_log_download_success": "Error log downloaded successfully", + "request_log_download_title": "Download Request Log", + "request_log_download_confirm": "Download request log for ID {{id}}?", + "request_log_download_success": "Request log downloaded successfully", + "action_hint": "Double-click a log line to copy the raw text. Long-press a line with a request ID to download the request log.", + "action_hint_disabled": "Double-click a log line to copy the raw text. Enable request logging to long-press a line with a request ID and download the request log.", "empty_title": "No Logs Available", "empty_desc": "When \"Enable logging to file\" is enabled, logs will be displayed here", "log_content": "Log Content", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 85ecaf8..65c088b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -596,6 +596,11 @@ "error_logs_modified": "最后修改", "error_logs_download": "下载", "error_log_download_success": "错误日志下载成功", + "request_log_download_title": "下载报文", + "request_log_download_confirm": "是否要下载id为{{id}}的报文?", + "request_log_download_success": "报文下载成功", + "action_hint": "双击日志行可复制原文,长按带有请求 ID 的日志可下载报文。", + "action_hint_disabled": "双击日志行可复制原文,启用请求日志后可长按带请求 ID 的日志下载报文。", "empty_title": "暂无日志记录", "empty_desc": "当启用\"日志记录到文件\"功能后,日志将显示在这里", "log_content": "日志内容", diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index 51df64b..3d0b85c 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -1,9 +1,11 @@ import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { PointerEvent as ReactPointerEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { EmptyState } from '@/components/ui/EmptyState'; import { Input } from '@/components/ui/Input'; +import { Modal } from '@/components/ui/Modal'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconDownload, @@ -38,6 +40,8 @@ const INITIAL_DISPLAY_LINES = 100; const LOAD_MORE_LINES = 200; const MAX_BUFFER_LINES = 10000; const LOAD_MORE_THRESHOLD_PX = 72; +const LONG_PRESS_MS = 650; +const LONG_PRESS_MOVE_THRESHOLD = 10; const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const; type HttpMethod = (typeof HTTP_METHODS)[number]; @@ -370,14 +374,22 @@ export function LogsPage() { const [autoRefresh, setAutoRefresh] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const deferredSearchQuery = useDeferredValue(searchQuery); - const [hideManagementLogs, setHideManagementLogs] = useState(false); + const [hideManagementLogs, setHideManagementLogs] = useState(true); const [errorLogs, setErrorLogs] = useState([]); const [loadingErrors, setLoadingErrors] = useState(false); const [errorLogsError, setErrorLogsError] = useState(''); + const [requestLogId, setRequestLogId] = useState(null); + const [requestLogDownloading, setRequestLogDownloading] = useState(false); const logViewerRef = useRef(null); const pendingScrollToBottomRef = useRef(false); const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); + const longPressRef = useRef<{ + timer: number | null; + startX: number; + startY: number; + fired: boolean; + } | null>(null); // 保存最新时间戳用于增量获取 const latestTimestampRef = useRef(0); @@ -647,6 +659,85 @@ export function LogsPage() { } }; + const clearLongPressTimer = () => { + if (longPressRef.current?.timer) { + window.clearTimeout(longPressRef.current.timer); + longPressRef.current.timer = null; + } + }; + + const startLongPress = (event: ReactPointerEvent, id?: string) => { + if (!requestLogEnabled) return; + if (!id) return; + if (requestLogId) return; + clearLongPressTimer(); + longPressRef.current = { + timer: window.setTimeout(() => { + setRequestLogId(id); + if (longPressRef.current) { + longPressRef.current.fired = true; + longPressRef.current.timer = null; + } + }, LONG_PRESS_MS), + startX: event.clientX, + startY: event.clientY, + fired: false, + }; + }; + + const cancelLongPress = () => { + clearLongPressTimer(); + longPressRef.current = null; + }; + + const handleLongPressMove = (event: ReactPointerEvent) => { + const current = longPressRef.current; + if (!current || current.timer === null || current.fired) return; + const deltaX = Math.abs(event.clientX - current.startX); + const deltaY = Math.abs(event.clientY - current.startY); + if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) { + cancelLongPress(); + } + }; + + const closeRequestLogModal = () => { + if (requestLogDownloading) return; + setRequestLogId(null); + }; + + const downloadRequestLog = async (id: string) => { + setRequestLogDownloading(true); + try { + const response = await logsApi.downloadRequestLogById(id); + const blob = new Blob([response.data], { type: 'text/plain' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `request-${id}.log`; + a.click(); + window.URL.revokeObjectURL(url); + showNotification(t('logs.request_log_download_success'), 'success'); + setRequestLogId(null); + } catch (err: unknown) { + const message = getErrorMessage(err); + showNotification( + `${t('notification.download_failed')}${message ? `: ${message}` : ''}`, + 'error' + ); + } finally { + setRequestLogDownloading(false); + } + }; + + useEffect(() => { + return () => { + if (longPressRef.current?.timer) { + window.clearTimeout(longPressRef.current.timer); + longPressRef.current.timer = null; + } + }; + }, []); + return (

{t('logs.title')}

@@ -760,6 +851,10 @@ export function LogsPage() {
+
+ {requestLogEnabled ? t('logs.action_hint') : t('logs.action_hint_disabled')} +
+ {loading ? (
{t('logs.loading')}
) : logState.buffer.length > 0 && parsedVisibleLines.length > 0 ? ( @@ -795,6 +890,11 @@ export function LogsPage() { onDoubleClick={() => { void copyLogLine(line.raw); }} + onPointerDown={(event) => startLongPress(event, line.requestId)} + onPointerUp={cancelLongPress} + onPointerLeave={cancelLongPress} + onPointerCancel={cancelLongPress} + onPointerMove={handleLongPressMove} title={t('logs.double_click_copy_hint', { defaultValue: 'Double-click to copy', })} @@ -946,6 +1046,32 @@ export function LogsPage() { )} + + + + + + } + > + {requestLogId ? t('logs.request_log_download_confirm', { id: requestLogId }) : null} + ); } diff --git a/src/services/api/logs.ts b/src/services/api/logs.ts index dc28183..3791844 100644 --- a/src/services/api/logs.ts +++ b/src/services/api/logs.ts @@ -39,4 +39,10 @@ export const logsApi = { responseType: 'blob', timeout: LOGS_TIMEOUT_MS }), + + downloadRequestLogById: (id: string) => + apiClient.getRaw(`/request-log-by-id/${encodeURIComponent(id)}`, { + responseType: 'blob', + timeout: LOGS_TIMEOUT_MS + }), };