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
This commit is contained in:
Supra4E8C
2025-12-15 17:37:09 +08:00
parent f17329b0ff
commit 4d898b3e20
10 changed files with 656 additions and 92 deletions

View File

@@ -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;
}

View File

@@ -117,6 +117,15 @@ export function IconInfo({ size = 20, ...props }: IconProps) {
);
}
export function IconRefreshCw({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
);
}
export function IconDownload({ size = 20, ...props }: IconProps) {
return (
<svg {...baseSvgProps} width={size} height={size} {...props}>
@@ -257,4 +266,3 @@ export function IconDollarSign({ size = 20, ...props }: IconProps) {
</svg>
);
}

View File

@@ -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.",

View File

@@ -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": "尝试更换关键字或清空搜索条件。",

View File

@@ -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 || []);

View File

@@ -50,7 +50,7 @@ export function ApiKeysPage() {
);
useEffect(() => {
loadApiKeys(true);
loadApiKeys();
}, [loadApiKeys]);
useEffect(() => {

View File

@@ -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;
}

View File

@@ -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<number>();
// 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<string[]>([]);
const [logState, setLogState] = useState<LogState>({ buffer: [], visibleFrom: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [autoRefresh, setAutoRefresh] = useState(false);
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
const [loadingErrors, setLoadingErrors] = useState(false);
const logViewerRef = useRef<HTMLPreElement | null>(null);
const logViewerRef = useRef<HTMLDivElement | null>(null);
const pendingScrollToBottomRef = useRef(false);
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
// 保存最新时间戳用于增量获取
const latestTimestampRef = useRef<number>(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 (
<div className={styles.container}>
@@ -204,18 +437,53 @@ export function LogsPage() {
<Card
title={t('logs.log_content')}
extra={
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Button variant="secondary" size="sm" onClick={() => loadLogs(false)} disabled={loading}>
{t('logs.refresh_button')}
<div className={styles.toolbar}>
<Button
variant="secondary"
size="sm"
onClick={() => loadLogs(false)}
disabled={disableControls || loading}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconRefreshCw size={16} />
{t('logs.refresh_button')}
</span>
</Button>
<Button variant="secondary" size="sm" onClick={() => setAutoRefresh((v) => !v)}>
{t('logs.auto_refresh')}: {autoRefresh ? t('common.yes') : t('common.no')}
<ToggleSwitch
checked={autoRefresh}
onChange={(value) => setAutoRefresh(value)}
disabled={disableControls}
label={
<span className={styles.switchLabel}>
<IconTimer size={16} />
{t('logs.auto_refresh')}
</span>
}
/>
<Button
variant="secondary"
size="sm"
onClick={downloadLogs}
disabled={logState.buffer.length === 0}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconDownload size={16} />
{t('logs.download_button')}
</span>
</Button>
<Button variant="secondary" size="sm" onClick={downloadLogs} disabled={logLines.length === 0}>
{t('logs.download_button')}
</Button>
<Button variant="danger" size="sm" onClick={clearLogs} disabled={disableControls}>
{t('logs.clear_button')}
<Button
variant="danger"
size="sm"
onClick={clearLogs}
disabled={disableControls}
className={styles.actionButton}
>
<span className={styles.buttonContent}>
<IconTrash2 size={16} />
{t('logs.clear_button')}
</span>
</Button>
</div>
}
@@ -223,14 +491,88 @@ export function LogsPage() {
{error && <div className="error-box">{error}</div>}
{loading ? (
<div className="hint">{t('logs.loading')}</div>
) : logsText ? (
<pre ref={logViewerRef} className="log-viewer log-viewer-lines">
{logLines.map((line, index) => (
<span key={index} className={`log-line${isWarningLine(line) ? ' log-line-warning' : ''}`}>
{line}
</span>
))}
</pre>
) : logState.buffer.length > 0 ? (
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
{canLoadMore && (
<div className={styles.loadMoreBanner}>
<span>{t('logs.load_more_hint')}</span>
<span className={styles.loadMoreCount}>
{t('logs.hidden_lines', { count: logState.visibleFrom })}
</span>
</div>
)}
<div className={styles.logList}>
{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 (
<div key={`${logState.visibleFrom + index}-${line.raw}`} className={rowClassNames.join(' ')}>
<div className={styles.timestamp}>{line.timestamp || ''}</div>
<div className={styles.rowMain}>
<div className={styles.rowMeta}>
{line.level && (
<span
className={[
styles.badge,
line.level === 'info' ? styles.levelInfo : '',
line.level === 'warn' ? styles.levelWarn : '',
line.level === 'error' || line.level === 'fatal' ? styles.levelError : '',
line.level === 'debug' ? styles.levelDebug : '',
line.level === 'trace' ? styles.levelTrace : ''
]
.filter(Boolean)
.join(' ')}
>
{line.level.toUpperCase()}
</span>
)}
{line.source && (
<span className={styles.source} title={line.source}>
{line.source}
</span>
)}
{typeof line.statusCode === 'number' && (
<span
className={[
styles.badge,
styles.statusBadge,
line.statusCode >= 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}
</span>
)}
{line.latency && <span className={styles.pill}>{line.latency}</span>}
{line.ip && <span className={styles.pill}>{line.ip}</span>}
{line.method && (
<span className={[styles.badge, styles.methodBadge].join(' ')}>
{line.method}
</span>
)}
{line.path && (
<span className={styles.path} title={line.path}>
{line.path}
</span>
)}
</div>
{line.message && <div className={styles.message}>{line.message}</div>}
</div>
</div>
);
})}
</div>
</div>
) : (
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
)}

View File

@@ -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) {

View File

@@ -27,6 +27,9 @@ interface ConfigState {
isCacheValid: (section?: RawConfigSection) => boolean;
}
let configRequestToken = 0;
let inFlightConfigRequest: { id: number; promise: Promise<Config> } | null = null;
const SECTION_KEYS: RawConfigSection[] = [
'debug',
'proxy-url',
@@ -102,13 +105,35 @@ export const useConfigStore = create<ConfigState>((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<ConfigState>((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<ConfigState>((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) => {