mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-02-19 19:20:49 +08:00
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:
@@ -1,9 +1,9 @@
|
|||||||
import type { ChangeEvent } from 'react';
|
import type { ChangeEvent, ReactNode } from 'react';
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
interface ToggleSwitchProps {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (value: boolean) => void;
|
onChange: (value: boolean) => void;
|
||||||
label?: string;
|
label?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
export function IconDownload({ size = 20, ...props }: IconProps) {
|
||||||
return (
|
return (
|
||||||
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
<svg {...baseSvgProps} width={size} height={size} {...props}>
|
||||||
@@ -257,4 +266,3 @@ export function IconDollarSign({ size = 20, ...props }: IconProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -568,6 +568,8 @@
|
|||||||
"auto_refresh": "Auto Refresh",
|
"auto_refresh": "Auto Refresh",
|
||||||
"auto_refresh_enabled": "Auto refresh enabled",
|
"auto_refresh_enabled": "Auto refresh enabled",
|
||||||
"auto_refresh_disabled": "Auto refresh disabled",
|
"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_placeholder": "Search logs by content or keyword",
|
||||||
"search_empty_title": "No matching logs found",
|
"search_empty_title": "No matching logs found",
|
||||||
"search_empty_desc": "Try a different keyword or clear the search filter.",
|
"search_empty_desc": "Try a different keyword or clear the search filter.",
|
||||||
|
|||||||
@@ -568,6 +568,8 @@
|
|||||||
"auto_refresh": "自动刷新",
|
"auto_refresh": "自动刷新",
|
||||||
"auto_refresh_enabled": "自动刷新已开启",
|
"auto_refresh_enabled": "自动刷新已开启",
|
||||||
"auto_refresh_disabled": "自动刷新已关闭",
|
"auto_refresh_disabled": "自动刷新已关闭",
|
||||||
|
"load_more_hint": "向上滚动加载更多",
|
||||||
|
"hidden_lines": "已隐藏 {{count}} 行",
|
||||||
"search_placeholder": "搜索日志内容或关键字",
|
"search_placeholder": "搜索日志内容或关键字",
|
||||||
"search_empty_title": "未找到匹配的日志",
|
"search_empty_title": "未找到匹配的日志",
|
||||||
"search_empty_desc": "尝试更换关键字或清空搜索条件。",
|
"search_empty_desc": "尝试更换关键字或清空搜索条件。",
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export function AiProvidersPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = await fetchConfig(undefined, true);
|
const data = await fetchConfig();
|
||||||
setGeminiKeys(data?.geminiApiKeys || []);
|
setGeminiKeys(data?.geminiApiKeys || []);
|
||||||
setCodexConfigs(data?.codexApiKeys || []);
|
setCodexConfigs(data?.codexApiKeys || []);
|
||||||
setClaudeConfigs(data?.claudeApiKeys || []);
|
setClaudeConfigs(data?.claudeApiKeys || []);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function ApiKeysPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadApiKeys(true);
|
loadApiKeys();
|
||||||
}, [loadApiKeys]);
|
}, [loadApiKeys]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -17,67 +17,239 @@
|
|||||||
gap: $spacing-lg;
|
gap: $spacing-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: $spacing-md;
|
|
||||||
|
|
||||||
@include mobile {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
@include mobile {
|
@include mobile {
|
||||||
flex-wrap: wrap;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.logViewer {
|
.actionButton {
|
||||||
background-color: #1e1e1e;
|
white-space: nowrap;
|
||||||
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;
|
|
||||||
|
|
||||||
pre {
|
.buttonContent {
|
||||||
margin: 0;
|
display: inline-flex;
|
||||||
white-space: pre-wrap;
|
align-items: center;
|
||||||
word-wrap: break-word;
|
gap: 6px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchBox {
|
.switchLabel {
|
||||||
margin-bottom: $spacing-md;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyState {
|
.logPanel {
|
||||||
text-align: center;
|
background: var(--bg-secondary);
|
||||||
padding: $spacing-2xl;
|
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);
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
i {
|
|
||||||
font-size: 48px;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
.loadMoreCount {
|
||||||
margin: 0 0 $spacing-sm 0;
|
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);
|
color: var(--text-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
@include mobile {
|
||||||
margin: 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
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 { useNotificationStore, useAuthStore } from '@/stores';
|
||||||
import { logsApi } from '@/services/api/logs';
|
import { logsApi } from '@/services/api/logs';
|
||||||
import styles from './LogsPage.module.scss';
|
import styles from './LogsPage.module.scss';
|
||||||
@@ -13,30 +15,219 @@ interface ErrorLogItem {
|
|||||||
modified?: number;
|
modified?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 限制显示的最大日志行数,防止渲染过多导致卡死
|
type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';
|
||||||
const MAX_DISPLAY_LINES = 500;
|
|
||||||
|
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() {
|
export function LogsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showNotification } = useNotificationStore();
|
const { showNotification } = useNotificationStore();
|
||||||
const connectionStatus = useAuthStore((state) => state.connectionStatus);
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||||
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
const [errorLogs, setErrorLogs] = useState<ErrorLogItem[]>([]);
|
||||||
const [loadingErrors, setLoadingErrors] = useState(false);
|
const [loadingErrors, setLoadingErrors] = useState(false);
|
||||||
|
|
||||||
const logViewerRef = useRef<HTMLPreElement | null>(null);
|
const logViewerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const pendingScrollToBottomRef = useRef(false);
|
const pendingScrollToBottomRef = useRef(false);
|
||||||
|
const pendingPrependScrollRef = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
|
||||||
|
|
||||||
// 保存最新时间戳用于增量获取
|
// 保存最新时间戳用于增量获取
|
||||||
const latestTimestampRef = useRef<number>(0);
|
const latestTimestampRef = useRef<number>(0);
|
||||||
|
|
||||||
const disableControls = connectionStatus !== 'connected';
|
const disableControls = connectionStatus !== 'connected';
|
||||||
|
|
||||||
const isNearBottom = (node: HTMLPreElement | null) => {
|
const isNearBottom = (node: HTMLDivElement | null) => {
|
||||||
if (!node) return true;
|
if (!node) return true;
|
||||||
const threshold = 24;
|
const threshold = 24;
|
||||||
return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
|
return node.scrollHeight - node.scrollTop - node.clientHeight <= threshold;
|
||||||
@@ -48,8 +239,6 @@ export function LogsPage() {
|
|||||||
node.scrollTop = node.scrollHeight;
|
node.scrollTop = node.scrollHeight;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWarningLine = (line: string) => /\bwarn(?:ing)?\b/i.test(line) || line.includes('警告');
|
|
||||||
|
|
||||||
const loadLogs = async (incremental = false) => {
|
const loadLogs = async (incremental = false) => {
|
||||||
if (connectionStatus !== 'connected') {
|
if (connectionStatus !== 'connected') {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -77,14 +266,26 @@ export function LogsPage() {
|
|||||||
const newLines = Array.isArray(data.lines) ? data.lines : [];
|
const newLines = Array.isArray(data.lines) ? data.lines : [];
|
||||||
|
|
||||||
if (incremental && newLines.length > 0) {
|
if (incremental && newLines.length > 0) {
|
||||||
// 增量更新:追加新日志并限制总行数
|
// 增量更新:追加新日志并限制缓冲区大小(避免内存与渲染膨胀)
|
||||||
setLogLines(prev => {
|
setLogState((prev) => {
|
||||||
const combined = [...prev, ...newLines];
|
const prevRenderedCount = prev.buffer.length - prev.visibleFrom;
|
||||||
return combined.slice(-MAX_DISPLAY_LINES);
|
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) {
|
} else if (!incremental) {
|
||||||
// 全量加载:只取最后 MAX_DISPLAY_LINES 行
|
// 全量加载:默认只渲染最后 100 行,向上滚动再展开更多
|
||||||
setLogLines(newLines.slice(-MAX_DISPLAY_LINES));
|
const buffer = newLines.slice(-MAX_BUFFER_LINES);
|
||||||
|
const visibleFrom = Math.max(buffer.length - INITIAL_DISPLAY_LINES, 0);
|
||||||
|
setLogState({ buffer, visibleFrom });
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load logs:', err);
|
console.error('Failed to load logs:', err);
|
||||||
@@ -102,7 +303,7 @@ export function LogsPage() {
|
|||||||
if (!window.confirm(t('logs.clear_confirm'))) return;
|
if (!window.confirm(t('logs.clear_confirm'))) return;
|
||||||
try {
|
try {
|
||||||
await logsApi.clearLogs();
|
await logsApi.clearLogs();
|
||||||
setLogLines([]);
|
setLogState({ buffer: [], visibleFrom: 0 });
|
||||||
latestTimestampRef.current = 0;
|
latestTimestampRef.current = 0;
|
||||||
showNotification(t('logs.clear_success'), 'success');
|
showNotification(t('logs.clear_success'), 'success');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -111,7 +312,7 @@ export function LogsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadLogs = () => {
|
const downloadLogs = () => {
|
||||||
const text = logLines.join('\n');
|
const text = logState.buffer.join('\n');
|
||||||
const blob = new Blob([text], { type: 'text/plain' });
|
const blob = new Blob([text], { type: 'text/plain' });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -193,9 +394,41 @@ export function LogsPage() {
|
|||||||
|
|
||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
pendingScrollToBottomRef.current = false;
|
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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
@@ -204,18 +437,53 @@ export function LogsPage() {
|
|||||||
<Card
|
<Card
|
||||||
title={t('logs.log_content')}
|
title={t('logs.log_content')}
|
||||||
extra={
|
extra={
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
<div className={styles.toolbar}>
|
||||||
<Button variant="secondary" size="sm" onClick={() => loadLogs(false)} disabled={loading}>
|
<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')}
|
{t('logs.refresh_button')}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="sm" onClick={() => setAutoRefresh((v) => !v)}>
|
<ToggleSwitch
|
||||||
{t('logs.auto_refresh')}: {autoRefresh ? t('common.yes') : t('common.no')}
|
checked={autoRefresh}
|
||||||
</Button>
|
onChange={(value) => setAutoRefresh(value)}
|
||||||
<Button variant="secondary" size="sm" onClick={downloadLogs} disabled={logLines.length === 0}>
|
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')}
|
{t('logs.download_button')}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="danger" size="sm" onClick={clearLogs} disabled={disableControls}>
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearLogs}
|
||||||
|
disabled={disableControls}
|
||||||
|
className={styles.actionButton}
|
||||||
|
>
|
||||||
|
<span className={styles.buttonContent}>
|
||||||
|
<IconTrash2 size={16} />
|
||||||
{t('logs.clear_button')}
|
{t('logs.clear_button')}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -223,14 +491,88 @@ export function LogsPage() {
|
|||||||
{error && <div className="error-box">{error}</div>}
|
{error && <div className="error-box">{error}</div>}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="hint">{t('logs.loading')}</div>
|
<div className="hint">{t('logs.loading')}</div>
|
||||||
) : logsText ? (
|
) : logState.buffer.length > 0 ? (
|
||||||
<pre ref={logViewerRef} className="log-viewer log-viewer-lines">
|
<div ref={logViewerRef} className={styles.logPanel} onScroll={handleLogScroll}>
|
||||||
{logLines.map((line, index) => (
|
{canLoadMore && (
|
||||||
<span key={index} className={`log-line${isWarningLine(line) ? ' log-line-warning' : ''}`}>
|
<div className={styles.loadMoreBanner}>
|
||||||
{line}
|
<span>{t('logs.load_more_hint')}</span>
|
||||||
|
<span className={styles.loadMoreCount}>
|
||||||
|
{t('logs.hidden_lines', { count: logState.visibleFrom })}
|
||||||
</span>
|
</span>
|
||||||
))}
|
</div>
|
||||||
</pre>
|
)}
|
||||||
|
<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')} />
|
<EmptyState title={t('logs.empty_title')} description={t('logs.empty_desc')} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function SettingsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const data = (await fetchConfig(undefined, true)) as Config;
|
const data = (await fetchConfig()) as Config;
|
||||||
setProxyValue(data?.proxyUrl ?? '');
|
setProxyValue(data?.proxyUrl ?? '');
|
||||||
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ interface ConfigState {
|
|||||||
isCacheValid: (section?: RawConfigSection) => boolean;
|
isCacheValid: (section?: RawConfigSection) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let configRequestToken = 0;
|
||||||
|
let inFlightConfigRequest: { id: number; promise: Promise<Config> } | null = null;
|
||||||
|
|
||||||
const SECTION_KEYS: RawConfigSection[] = [
|
const SECTION_KEYS: RawConfigSection[] = [
|
||||||
'debug',
|
'debug',
|
||||||
'proxy-url',
|
'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 });
|
set({ loading: true, error: null });
|
||||||
|
|
||||||
|
const requestId = (configRequestToken += 1);
|
||||||
try {
|
try {
|
||||||
const data = await configApi.getConfig();
|
const requestPromise = configApi.getConfig();
|
||||||
|
inFlightConfigRequest = { id: requestId, promise: requestPromise };
|
||||||
|
const data = await requestPromise;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
|
// 如果在请求过程中连接已被切换/登出,则忽略旧请求的结果,避免覆盖新会话的状态
|
||||||
|
if (requestId !== configRequestToken) {
|
||||||
|
return section ? extractSectionValue(data, section) : data;
|
||||||
|
}
|
||||||
|
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
const newCache = new Map(cache);
|
const newCache = new Map(cache);
|
||||||
newCache.set('__full__', { data, timestamp: now });
|
newCache.set('__full__', { data, timestamp: now });
|
||||||
@@ -127,11 +152,17 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
|
|||||||
|
|
||||||
return section ? extractSectionValue(data, section) : data;
|
return section ? extractSectionValue(data, section) : data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (requestId === configRequestToken) {
|
||||||
set({
|
set({
|
||||||
error: error.message || 'Failed to fetch config',
|
error: error.message || 'Failed to fetch config',
|
||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
|
}
|
||||||
throw error;
|
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(section);
|
||||||
// 同时清除完整配置缓存
|
// 同时清除完整配置缓存
|
||||||
newCache.delete('__full__');
|
newCache.delete('__full__');
|
||||||
|
|
||||||
|
set({ cache: newCache });
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
newCache.clear();
|
newCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ cache: newCache });
|
// 清除全部缓存一般代表“切换连接/登出/全量刷新”,需要让 in-flight 的旧请求失效
|
||||||
|
configRequestToken += 1;
|
||||||
|
inFlightConfigRequest = null;
|
||||||
|
|
||||||
|
set({ config: null, cache: newCache, loading: false, error: null });
|
||||||
},
|
},
|
||||||
|
|
||||||
isCacheValid: (section) => {
|
isCacheValid: (section) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user