From 4d898b3e2011c9bc5baf7c953e0a7c23bc4ad8e0 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Mon, 15 Dec 2025 17:37:09 +0800 Subject: [PATCH] feat(logs): redesign LogsPage with structured log parsing and virtual scrolling - Add log line parser to extract timestamp, level, status code, latency, IP, HTTP method, and path - Implement virtual scrolling with load-more on scroll-up to handle large log files efficiently - Replace monolithic pre block with structured grid layout for better readability - Add visual badges for log levels and HTTP status codes with color-coded severity - Add IconRefreshCw icon component - Update ToggleSwitch to accept ReactNode as label - Fix fetchConfig calls to use default parameters consistently - Add request deduplication in useConfigStore to prevent duplicate /config API calls - Add i18n keys for load_more_hint and hidden_lines --- src/components/ui/ToggleSwitch.tsx | 4 +- src/components/ui/icons.tsx | 10 +- src/i18n/locales/en.json | 2 + src/i18n/locales/zh-CN.json | 2 + src/pages/AiProvidersPage.tsx | 2 +- src/pages/ApiKeysPage.tsx | 2 +- src/pages/LogsPage.module.scss | 260 +++++++++++++++--- src/pages/LogsPage.tsx | 414 ++++++++++++++++++++++++++--- src/pages/SettingsPage.tsx | 2 +- src/stores/useConfigStore.ts | 50 +++- 10 files changed, 656 insertions(+), 92 deletions(-) diff --git a/src/components/ui/ToggleSwitch.tsx b/src/components/ui/ToggleSwitch.tsx index d463a92..f0b5332 100644 --- a/src/components/ui/ToggleSwitch.tsx +++ b/src/components/ui/ToggleSwitch.tsx @@ -1,9 +1,9 @@ -import type { ChangeEvent } from 'react'; +import type { ChangeEvent, ReactNode } from 'react'; interface ToggleSwitchProps { checked: boolean; onChange: (value: boolean) => void; - label?: string; + label?: ReactNode; disabled?: boolean; } diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index 7423128..90e81f2 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -117,6 +117,15 @@ export function IconInfo({ size = 20, ...props }: IconProps) { ); } +export function IconRefreshCw({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + export function IconDownload({ size = 20, ...props }: IconProps) { return ( @@ -257,4 +266,3 @@ export function IconDollarSign({ size = 20, ...props }: IconProps) { ); } - diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8b5d91d..2fd8273 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -568,6 +568,8 @@ "auto_refresh": "Auto Refresh", "auto_refresh_enabled": "Auto refresh enabled", "auto_refresh_disabled": "Auto refresh disabled", + "load_more_hint": "Scroll up to load more", + "hidden_lines": "Hidden: {{count}} lines", "search_placeholder": "Search logs by content or keyword", "search_empty_title": "No matching logs found", "search_empty_desc": "Try a different keyword or clear the search filter.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index cf2cb11..aab9457 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -568,6 +568,8 @@ "auto_refresh": "自动刷新", "auto_refresh_enabled": "自动刷新已开启", "auto_refresh_disabled": "自动刷新已关闭", + "load_more_hint": "向上滚动加载更多", + "hidden_lines": "已隐藏 {{count}} 行", "search_placeholder": "搜索日志内容或关键字", "search_empty_title": "未找到匹配的日志", "search_empty_desc": "尝试更换关键字或清空搜索条件。", diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index d186bf4..4f464f7 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -268,7 +268,7 @@ export function AiProvidersPage() { setLoading(true); setError(''); try { - const data = await fetchConfig(undefined, true); + const data = await fetchConfig(); setGeminiKeys(data?.geminiApiKeys || []); setCodexConfigs(data?.codexApiKeys || []); setClaudeConfigs(data?.claudeApiKeys || []); diff --git a/src/pages/ApiKeysPage.tsx b/src/pages/ApiKeysPage.tsx index 348ebd1..fb9eb63 100644 --- a/src/pages/ApiKeysPage.tsx +++ b/src/pages/ApiKeysPage.tsx @@ -50,7 +50,7 @@ export function ApiKeysPage() { ); useEffect(() => { - loadApiKeys(true); + loadApiKeys(); }, [loadApiKeys]); useEffect(() => { diff --git a/src/pages/LogsPage.module.scss b/src/pages/LogsPage.module.scss index d7bebee..32b5511 100644 --- a/src/pages/LogsPage.module.scss +++ b/src/pages/LogsPage.module.scss @@ -17,67 +17,239 @@ gap: $spacing-lg; } -.controls { +.toolbar { display: flex; - justify-content: space-between; align-items: center; - gap: $spacing-md; - - @include mobile { - flex-direction: column; - align-items: stretch; - } -} - -.actions { - display: flex; gap: $spacing-sm; + flex-wrap: wrap; @include mobile { - flex-wrap: wrap; + align-items: flex-start; } } -.logViewer { - background-color: #1e1e1e; - color: #d4d4d4; - padding: $spacing-lg; - border-radius: $radius-lg; - font-family: 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 13px; - line-height: 1.6; - overflow-x: auto; - max-height: 600px; - overflow-y: auto; +.actionButton { + white-space: nowrap; +} - pre { - margin: 0; - white-space: pre-wrap; - word-wrap: break-word; +.buttonContent { + display: inline-flex; + align-items: center; + gap: 6px; + + svg { + flex: 0 0 auto; } } -.searchBox { - margin-bottom: $spacing-md; +.switchLabel { + display: inline-flex; + align-items: center; + gap: 6px; + + svg { + flex: 0 0 auto; + } } -.emptyState { - text-align: center; - padding: $spacing-2xl; +.logPanel { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; + max-height: 620px; + overflow: auto; + position: relative; +} + +.loadMoreBanner { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + padding: 8px 12px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); color: var(--text-secondary); + font-size: 12px; +} - i { - font-size: 48px; - margin-bottom: $spacing-md; - opacity: 0.5; +.loadMoreCount { + color: var(--text-tertiary); + white-space: nowrap; +} + +.logList { + display: flex; + flex-direction: column; +} + +.logRow { + display: grid; + grid-template-columns: 170px 1fr; + gap: $spacing-md; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + border-left: 3px solid transparent; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + font-size: 12.5px; + line-height: 1.45; + color: var(--text-primary); + + &:hover { + background: rgba(59, 130, 246, 0.06); } - h3 { - margin: 0 0 $spacing-sm 0; - color: var(--text-primary); - } - - p { - margin: 0; + @include mobile { + grid-template-columns: 1fr; + gap: $spacing-xs; } } + +.rowWarn { + border-left-color: var(--warning-color); +} + +.rowError { + border-left-color: var(--error-color); +} + +.timestamp { + color: var(--text-tertiary); + white-space: nowrap; + padding-top: 2px; + + @include mobile { + white-space: normal; + } +} + +.rowMain { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.rowMeta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: $radius-full; + font-size: 12px; + font-weight: 800; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + white-space: nowrap; +} + +.pill { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: $radius-full; + font-size: 12px; + font-weight: 600; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-secondary); + white-space: nowrap; +} + +.source { + color: var(--text-secondary); + max-width: 240px; + @include text-ellipsis; + + @include mobile { + max-width: 100%; + } +} + +.statusBadge { + font-variant-numeric: tabular-nums; +} + +.statusSuccess { + color: var(--success-badge-text); + background: var(--success-badge-bg); + border-color: var(--success-badge-border); +} + +.statusInfo { + color: var(--info-color); + background: rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.25); +} + +.statusWarn { + color: var(--warning-color); + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.25); +} + +.statusError { + color: var(--failure-badge-text); + background: var(--failure-badge-bg); + border-color: var(--failure-badge-border); +} + +.levelInfo { + color: var(--info-color); + background: rgba(59, 130, 246, 0.12); + border-color: rgba(59, 130, 246, 0.25); +} + +.levelWarn { + color: var(--warning-color); + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.25); +} + +.levelError { + color: var(--error-color); + background: rgba(239, 68, 68, 0.12); + border-color: rgba(239, 68, 68, 0.25); +} + +.levelDebug, +.levelTrace { + color: var(--text-secondary); + background: rgba(107, 114, 128, 0.12); + border-color: rgba(107, 114, 128, 0.25); +} + +.methodBadge { + color: var(--text-primary); + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.22); +} + +.path { + color: var(--text-primary); + font-weight: 700; + max-width: 520px; + @include text-ellipsis; + + @include mobile { + max-width: 100%; + } +} + +.message { + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; +} diff --git a/src/pages/LogsPage.tsx b/src/pages/LogsPage.tsx index 3ba8c8a..25c0799 100644 --- a/src/pages/LogsPage.tsx +++ b/src/pages/LogsPage.tsx @@ -1,8 +1,10 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } 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 { ToggleSwitch } from '@/components/ui/ToggleSwitch'; +import { IconDownload, IconRefreshCw, IconTimer, IconTrash2 } from '@/components/ui/icons'; import { useNotificationStore, useAuthStore } from '@/stores'; import { logsApi } from '@/services/api/logs'; import styles from './LogsPage.module.scss'; @@ -13,30 +15,219 @@ interface ErrorLogItem { modified?: number; } -// 限制显示的最大日志行数,防止渲染过多导致卡死 -const MAX_DISPLAY_LINES = 500; +type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +type LogState = { + buffer: string[]; + visibleFrom: number; +}; + +// 初始只渲染最近 100 行,滚动到顶部再逐步加载更多(避免一次性渲染过多导致卡顿) +const INITIAL_DISPLAY_LINES = 100; +const LOAD_MORE_LINES = 200; +const MAX_BUFFER_LINES = 10000; +const LOAD_MORE_THRESHOLD_PX = 72; + +const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD'] as const; +type HttpMethod = (typeof HTTP_METHODS)[number]; +const HTTP_METHOD_REGEX = new RegExp(`\\b(${HTTP_METHODS.join('|')})\\b`); + +const LOG_TIMESTAMP_REGEX = /^\[?(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?)\]?/; +const LOG_LEVEL_REGEX = /^\[?(trace|debug|info|warn|warning|error|fatal)\]?\b/i; +const LOG_SOURCE_REGEX = /^\[([^\]]+)\]/; +const LOG_LATENCY_REGEX = /\b(\d+(?:\.\d+)?)(?:\s*)(µs|us|ms|s)\b/i; +const LOG_IPV4_REGEX = /\b(?:\d{1,3}\.){3}\d{1,3}\b/; +const LOG_IPV6_REGEX = /\b(?:[a-f0-9]{0,4}:){2,7}[a-f0-9]{0,4}\b/i; + +type ParsedLogLine = { + raw: string; + timestamp?: string; + level?: LogLevel; + source?: string; + statusCode?: number; + latency?: string; + ip?: string; + method?: HttpMethod; + path?: string; + message: string; +}; + +const extractLogLevel = (value: string): LogLevel | undefined => { + const normalized = value.trim().toLowerCase(); + if (normalized === 'warning') return 'warn'; + if (normalized === 'warn') return 'warn'; + if (normalized === 'info') return 'info'; + if (normalized === 'error') return 'error'; + if (normalized === 'fatal') return 'fatal'; + if (normalized === 'debug') return 'debug'; + if (normalized === 'trace') return 'trace'; + return undefined; +}; + +const inferLogLevel = (line: string): LogLevel | undefined => { + const lowered = line.toLowerCase(); + if (/\bfatal\b/.test(lowered)) return 'fatal'; + if (/\berror\b/.test(lowered)) return 'error'; + if (/\bwarn(?:ing)?\b/.test(lowered) || line.includes('警告')) return 'warn'; + if (/\binfo\b/.test(lowered)) return 'info'; + if (/\bdebug\b/.test(lowered)) return 'debug'; + if (/\btrace\b/.test(lowered)) return 'trace'; + return undefined; +}; + +const extractHttpMethodAndPath = (text: string): { method?: HttpMethod; path?: string } => { + const match = text.match(HTTP_METHOD_REGEX); + if (!match) return {}; + + const method = match[1] as HttpMethod; + const index = match.index ?? 0; + const after = text.slice(index + match[0].length).trim(); + const path = after ? after.split(/\s+/)[0] : undefined; + return { method, path }; +}; + +const parseLogLine = (raw: string): ParsedLogLine => { + let remaining = raw.trim(); + + let timestamp: string | undefined; + const tsMatch = remaining.match(LOG_TIMESTAMP_REGEX); + if (tsMatch) { + timestamp = tsMatch[1]; + remaining = remaining.slice(tsMatch[0].length).trim(); + } + + let level: LogLevel | undefined; + const lvlMatch = remaining.match(LOG_LEVEL_REGEX); + if (lvlMatch) { + level = extractLogLevel(lvlMatch[1]); + remaining = remaining.slice(lvlMatch[0].length).trim(); + } + + let source: string | undefined; + const sourceMatch = remaining.match(LOG_SOURCE_REGEX); + if (sourceMatch) { + source = sourceMatch[1]; + remaining = remaining.slice(sourceMatch[0].length).trim(); + } + + let statusCode: number | undefined; + let latency: string | undefined; + let ip: string | undefined; + let method: HttpMethod | undefined; + let path: string | undefined; + let message = remaining; + + if (remaining.includes('|')) { + const segments = remaining + .split('|') + .map((segment) => segment.trim()) + .filter(Boolean); + const consumed = new Set(); + + // status code + const statusIndex = segments.findIndex((segment) => /^\d{3}\b/.test(segment)); + if (statusIndex >= 0) { + const match = segments[statusIndex].match(/^(\d{3})\b/); + if (match) { + const code = Number.parseInt(match[1], 10); + if (code >= 100 && code <= 599) { + statusCode = code; + consumed.add(statusIndex); + } + } + } + + // latency + const latencyIndex = segments.findIndex((segment) => LOG_LATENCY_REGEX.test(segment)); + if (latencyIndex >= 0) { + const match = segments[latencyIndex].match(LOG_LATENCY_REGEX); + if (match) { + latency = `${match[1]}${match[2]}`; + consumed.add(latencyIndex); + } + } + + // ip + const ipIndex = segments.findIndex( + (segment) => LOG_IPV4_REGEX.test(segment) || LOG_IPV6_REGEX.test(segment) + ); + if (ipIndex >= 0) { + const match = segments[ipIndex].match(LOG_IPV4_REGEX) ?? segments[ipIndex].match(LOG_IPV6_REGEX); + if (match) { + ip = match[0]; + consumed.add(ipIndex); + } + } + + // method + path + const methodIndex = segments.findIndex((segment) => { + const { method: parsedMethod } = extractHttpMethodAndPath(segment); + return Boolean(parsedMethod); + }); + if (methodIndex >= 0) { + const parsed = extractHttpMethodAndPath(segments[methodIndex]); + method = parsed.method; + path = parsed.path; + consumed.add(methodIndex); + } + + message = segments.filter((_, index) => !consumed.has(index)).join(' | '); + } else { + const statusMatch = remaining.match(/\b([1-5]\d{2})\b/); + if (statusMatch) { + const code = Number.parseInt(statusMatch[1], 10); + if (code >= 100 && code <= 599) statusCode = code; + } + + const latencyMatch = remaining.match(LOG_LATENCY_REGEX); + if (latencyMatch) latency = `${latencyMatch[1]}${latencyMatch[2]}`; + + const ipMatch = remaining.match(LOG_IPV4_REGEX) ?? remaining.match(LOG_IPV6_REGEX); + if (ipMatch) ip = ipMatch[0]; + + const parsed = extractHttpMethodAndPath(remaining); + method = parsed.method; + path = parsed.path; + } + + if (!level) level = inferLogLevel(raw); + + return { + raw, + timestamp, + level, + source, + statusCode, + latency, + ip, + method, + path, + message + }; +}; export function LogsPage() { const { t } = useTranslation(); const { showNotification } = useNotificationStore(); const connectionStatus = useAuthStore((state) => state.connectionStatus); - const [logLines, setLogLines] = useState([]); + const [logState, setLogState] = useState({ buffer: [], visibleFrom: 0 }); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [autoRefresh, setAutoRefresh] = useState(false); const [errorLogs, setErrorLogs] = useState([]); const [loadingErrors, setLoadingErrors] = useState(false); - const logViewerRef = useRef(null); + const logViewerRef = useRef(null); const pendingScrollToBottomRef = useRef(false); + const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); // 保存最新时间戳用于增量获取 const latestTimestampRef = useRef(0); const disableControls = connectionStatus !== 'connected'; - const isNearBottom = (node: HTMLPreElement | null) => { + const isNearBottom = (node: HTMLDivElement | null) => { if (!node) return true; const threshold = 24; return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold; @@ -48,8 +239,6 @@ export function LogsPage() { node.scrollTop = node.scrollHeight; }; - const isWarningLine = (line: string) => /\bwarn(?:ing)?\b/i.test(line) || line.includes('警告'); - const loadLogs = async (incremental = false) => { if (connectionStatus !== 'connected') { setLoading(false); @@ -77,14 +266,26 @@ export function LogsPage() { const newLines = Array.isArray(data.lines) ? data.lines : []; if (incremental && newLines.length > 0) { - // 增量更新:追加新日志并限制总行数 - setLogLines(prev => { - const combined = [...prev, ...newLines]; - return combined.slice(-MAX_DISPLAY_LINES); + // 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀) + setLogState((prev) => { + const prevRenderedCount = prev.buffer.length - prev.visibleFrom; + const combined = [...prev.buffer, ...newLines]; + const dropCount = Math.max(combined.length - MAX_BUFFER_LINES, 0); + const buffer = dropCount > 0 ? combined.slice(dropCount) : combined; + let visibleFrom = Math.max(prev.visibleFrom - dropCount, 0); + + // 若用户停留在底部(跟随最新日志),则保持“渲染窗口”大小不变,避免无限增长 + if (pendingScrollToBottomRef.current) { + visibleFrom = Math.max(buffer.length - prevRenderedCount, 0); + } + + return { buffer, visibleFrom }; }); } else if (!incremental) { - // 全量加载:只取最后 MAX_DISPLAY_LINES 行 - setLogLines(newLines.slice(-MAX_DISPLAY_LINES)); + // 全量加载:默认只渲染最后 100 行,向上滚动再展开更多 + const buffer = newLines.slice(-MAX_BUFFER_LINES); + const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0); + setLogState({ buffer, visibleFrom }); } } catch (err: any) { console.error('Failed to load logs:', err); @@ -102,7 +303,7 @@ export function LogsPage() { if (!window.confirm(t('logs.clear_confirm'))) return; try { await logsApi.clearLogs(); - setLogLines([]); + setLogState({ buffer: [], visibleFrom: 0 }); latestTimestampRef.current = 0; showNotification(t('logs.clear_success'), 'success'); } catch (err: any) { @@ -111,7 +312,7 @@ export function LogsPage() { }; const downloadLogs = () => { - const text = logLines.join('\n'); + const text = logState.buffer.join('\n'); const blob = new Blob([text], { type: 'text/plain' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); @@ -193,9 +394,41 @@ export function LogsPage() { scrollToBottom(); pendingScrollToBottomRef.current = false; - }, [loading, logLines]); + }, [loading, logState.buffer, logState.visibleFrom]); - const logsText = logLines.join('\n'); + const visibleLines = useMemo( + () => logState.buffer.slice(logState.visibleFrom), + [logState.buffer, logState.visibleFrom] + ); + const parsedVisibleLines = useMemo( + () => visibleLines.map((line) => parseLogLine(line)), + [visibleLines] + ); + const canLoadMore = logState.visibleFrom > 0; + + const handleLogScroll = () => { + const node = logViewerRef.current; + if (!node) return; + if (!canLoadMore) return; + if (pendingPrependScrollRef.current) return; + if (node.scrollTop > LOAD_MORE_THRESHOLD_PX) return; + + pendingPrependScrollRef.current = { scrollHeight: node.scrollHeight, scrollTop: node.scrollTop }; + setLogState((prev) => ({ + ...prev, + visibleFrom: Math.max(prev.visibleFrom - LOAD_MORE_LINES, 0) + })); + }; + + useLayoutEffect(() => { + const node = logViewerRef.current; + const pending = pendingPrependScrollRef.current; + if (!node || !pending) return; + + const delta = node.scrollHeight - pending.scrollHeight; + node.scrollTop = pending.scrollTop + delta; + pendingPrependScrollRef.current = null; + }, [logState.visibleFrom]); return (
@@ -204,18 +437,53 @@ export function LogsPage() { - - - -
} @@ -223,14 +491,88 @@ export function LogsPage() { {error &&
{error}
} {loading ? (
{t('logs.loading')}
- ) : logsText ? ( -
-            {logLines.map((line, index) => (
-              
-                {line}
-              
-            ))}
-          
+ ) : logState.buffer.length > 0 ? ( +
+ {canLoadMore && ( +
+ {t('logs.load_more_hint')} + + {t('logs.hidden_lines', { count: logState.visibleFrom })} + +
+ )} +
+ {parsedVisibleLines.map((line, index) => { + const rowClassNames = [styles.logRow]; + if (line.level === 'warn') rowClassNames.push(styles.rowWarn); + if (line.level === 'error' || line.level === 'fatal') rowClassNames.push(styles.rowError); + return ( +
+
{line.timestamp || ''}
+
+
+ {line.level && ( + + {line.level.toUpperCase()} + + )} + + {line.source && ( + + {line.source} + + )} + + {typeof line.statusCode === 'number' && ( + = 200 && line.statusCode < 300 + ? styles.statusSuccess + : line.statusCode >= 300 && line.statusCode < 400 + ? styles.statusInfo + : line.statusCode >= 400 && line.statusCode < 500 + ? styles.statusWarn + : styles.statusError + ].join(' ')} + > + {line.statusCode} + + )} + + {line.latency && {line.latency}} + {line.ip && {line.ip}} + + {line.method && ( + + {line.method} + + )} + {line.path && ( + + {line.path} + + )} +
+ {line.message &&
{line.message}
} +
+
+ ); + })} +
+
) : ( )} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 5af860a..1deb6d1 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -42,7 +42,7 @@ export function SettingsPage() { setLoading(true); setError(''); try { - const data = (await fetchConfig(undefined, true)) as Config; + const data = (await fetchConfig()) as Config; setProxyValue(data?.proxyUrl ?? ''); setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0); } catch (err: any) { diff --git a/src/stores/useConfigStore.ts b/src/stores/useConfigStore.ts index da6732e..9e6c25a 100644 --- a/src/stores/useConfigStore.ts +++ b/src/stores/useConfigStore.ts @@ -27,6 +27,9 @@ interface ConfigState { isCacheValid: (section?: RawConfigSection) => boolean; } +let configRequestToken = 0; +let inFlightConfigRequest: { id: number; promise: Promise } | null = null; + const SECTION_KEYS: RawConfigSection[] = [ 'debug', 'proxy-url', @@ -102,13 +105,35 @@ export const useConfigStore = create((set, get) => ({ } } + // section 缓存未命中但 full 缓存可用时,直接复用已获取到的配置,避免重复 /config 请求 + if (!forceRefresh && section && isCacheValid()) { + const fullCached = cache.get('__full__'); + if (fullCached?.data) { + return extractSectionValue(fullCached.data as Config, section); + } + } + + // 同一时刻合并多个 /config 请求(如 StrictMode 或多个页面同时触发) + if (inFlightConfigRequest) { + const data = await inFlightConfigRequest.promise; + return section ? extractSectionValue(data, section) : data; + } + // 获取新数据 set({ loading: true, error: null }); + const requestId = (configRequestToken += 1); try { - const data = await configApi.getConfig(); + const requestPromise = configApi.getConfig(); + inFlightConfigRequest = { id: requestId, promise: requestPromise }; + const data = await requestPromise; const now = Date.now(); + // 如果在请求过程中连接已被切换/登出,则忽略旧请求的结果,避免覆盖新会话的状态 + if (requestId !== configRequestToken) { + return section ? extractSectionValue(data, section) : data; + } + // 更新缓存 const newCache = new Map(cache); newCache.set('__full__', { data, timestamp: now }); @@ -127,11 +152,17 @@ export const useConfigStore = create((set, get) => ({ return section ? extractSectionValue(data, section) : data; } catch (error: any) { - set({ - error: error.message || 'Failed to fetch config', - loading: false - }); + if (requestId === configRequestToken) { + set({ + error: error.message || 'Failed to fetch config', + loading: false + }); + } throw error; + } finally { + if (inFlightConfigRequest?.id === requestId) { + inFlightConfigRequest = null; + } } }, @@ -206,11 +237,18 @@ export const useConfigStore = create((set, get) => ({ newCache.delete(section); // 同时清除完整配置缓存 newCache.delete('__full__'); + + set({ cache: newCache }); + return; } else { newCache.clear(); } - set({ cache: newCache }); + // 清除全部缓存一般代表“切换连接/登出/全量刷新”,需要让 in-flight 的旧请求失效 + configRequestToken += 1; + inFlightConfigRequest = null; + + set({ config: null, cache: newCache, loading: false, error: null }); }, isCacheValid: (section) => {