diff --git a/src/App.jsx b/src/App.jsx
index 950e072..980cf91 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
import { FileUpload } from './components/FileUpload';
import { RegexControls } from './components/RegexControls';
import { FileList } from './components/FileList';
@@ -9,27 +9,36 @@ import { FileConfigModal } from './components/FileConfigModal';
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { mergeFilesWithReplacement } from './utils/mergeFiles.js';
+// 默认全局解析配置
+export const DEFAULT_GLOBAL_PARSING_CONFIG = {
+ metrics: [
+ {
+ name: 'Loss',
+ mode: 'keyword',
+ keyword: 'loss:',
+ regex: 'loss:\\s*([\\d.eE+-]+)'
+ },
+ {
+ name: 'Grad Norm',
+ mode: 'keyword',
+ keyword: 'norm:',
+ regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)'
+ }
+ ],
+ useStepKeyword: false,
+ stepKeyword: 'step:'
+};
+
function App() {
- const [uploadedFiles, setUploadedFiles] = useState([]);
-
+ const [uploadedFiles, setUploadedFiles] = useState(() => {
+ const stored = localStorage.getItem('uploadedFiles');
+ return stored ? JSON.parse(stored) : [];
+ });
+
// 全局解析配置状态
- const [globalParsingConfig, setGlobalParsingConfig] = useState({
- metrics: [
- {
- name: 'Loss',
- mode: 'keyword', // 'keyword' | 'regex'
- keyword: 'loss:',
- regex: 'loss:\\s*([\\d.eE+-]+)'
- },
- {
- name: 'Grad Norm',
- mode: 'keyword',
- keyword: 'norm:',
- regex: 'grad[\\s_]norm:\\s*([\\d.eE+-]+)'
- }
- ],
- useStepKeyword: false,
- stepKeyword: 'step:'
+ const [globalParsingConfig, setGlobalParsingConfig] = useState(() => {
+ const stored = localStorage.getItem('globalParsingConfig');
+ return stored ? JSON.parse(stored) : JSON.parse(JSON.stringify(DEFAULT_GLOBAL_PARSING_CONFIG));
});
const [compareMode, setCompareMode] = useState('normal');
@@ -42,6 +51,29 @@ function App() {
const [xRange, setXRange] = useState({ min: undefined, max: undefined });
const [maxStep, setMaxStep] = useState(0);
const [sidebarVisible, setSidebarVisible] = useState(true);
+ const savingDisabledRef = useRef(false);
+
+ // 持久化配置到 localStorage
+ useEffect(() => {
+ if (savingDisabledRef.current) return;
+ localStorage.setItem('globalParsingConfig', JSON.stringify(globalParsingConfig));
+ }, [globalParsingConfig]);
+
+ useEffect(() => {
+ if (savingDisabledRef.current) return;
+ const serialized = uploadedFiles.map(({ id, name, enabled, content, config }) => ({
+ id,
+ name,
+ enabled,
+ content,
+ config
+ }));
+ if (serialized.length > 0) {
+ localStorage.setItem('uploadedFiles', JSON.stringify(serialized));
+ } else {
+ localStorage.removeItem('uploadedFiles');
+ }
+ }, [uploadedFiles]);
const handleFilesUploaded = useCallback((files) => {
const filesWithDefaults = files.map(file => ({
@@ -132,7 +164,19 @@ function App() {
useStepKeyword: newConfig.useStepKeyword,
stepKeyword: newConfig.stepKeyword
}
- })));
+ })));
+ }, []);
+
+ // 重置配置
+ const handleResetConfig = useCallback(() => {
+ savingDisabledRef.current = true;
+ localStorage.removeItem('globalParsingConfig');
+ localStorage.removeItem('uploadedFiles');
+ setGlobalParsingConfig(JSON.parse(JSON.stringify(DEFAULT_GLOBAL_PARSING_CONFIG)));
+ setUploadedFiles([]);
+ setTimeout(() => {
+ savingDisabledRef.current = false;
+ }, 0);
}, []);
// 全局拖拽事件处理
@@ -306,9 +350,16 @@ function App() {
GitHub
+
-
+
{
+ const Chart = {
+ register: vi.fn(),
+ defaults: { plugins: { legend: { labels: { generateLabels: vi.fn(() => []) } } } }
+ };
+ return {
+ Chart,
+ ChartJS: Chart,
+ CategoryScale: {},
+ LinearScale: {},
+ PointElement: {},
+ LineElement: {},
+ Title: {},
+ Tooltip: {},
+ Legend: {},
+ };
+});
+
+vi.mock('react-chartjs-2', async () => {
+ const React = await import('react');
+ return {
+ Line: React.forwardRef(() => )
+ };
+});
+
+vi.mock('chartjs-plugin-zoom', () => ({ default: {} }));
+
+function stubFileReader(result) {
+ class FileReaderMock {
+ constructor() {
+ this.onload = null;
+ }
+ readAsText() {
+ this.onload({ target: { result } });
+ }
+ }
+ global.FileReader = FileReaderMock;
+}
+
+describe('App configuration persistence', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('saves and restores config from localStorage', async () => {
+ stubFileReader('loss: 1');
+ const user = userEvent.setup();
+
+ const { unmount } = render();
+
+ const input = screen.getByLabelText('选择日志文件,支持所有文本格式');
+ const file = new File(['hello'], 'test.log', { type: 'text/plain' });
+ await user.upload(input, file);
+
+ const stepToggle = screen.getAllByLabelText('使用 Step 关键字')[0];
+ await user.click(stepToggle);
+ const stepInput = screen.getByPlaceholderText('step:');
+ fireEvent.change(stepInput, { target: { value: 'iter:' } });
+
+ await waitFor(() => expect(JSON.parse(localStorage.getItem('uploadedFiles'))).toHaveLength(1));
+ await waitFor(() => {
+ const cfg = JSON.parse(localStorage.getItem('globalParsingConfig'));
+ expect(cfg.stepKeyword).toBe('iter:');
+ expect(cfg.useStepKeyword).toBe(true);
+ });
+
+ unmount();
+
+ render();
+
+ expect(await screen.findByText('test.log')).toBeInTheDocument();
+ const restoredToggle = screen.getAllByLabelText('使用 Step 关键字')[0];
+ expect(restoredToggle).toBeChecked();
+ const restoredInput = screen.getByPlaceholderText('step:');
+ expect(restoredInput.value).toBe('iter:');
+ });
+
+ it('resets config and clears localStorage', async () => {
+ localStorage.setItem('globalParsingConfig', JSON.stringify({ metrics: [], useStepKeyword: true, stepKeyword: 'foo:' }));
+ localStorage.setItem('uploadedFiles', JSON.stringify([
+ {
+ id: '1',
+ name: 'saved.log',
+ enabled: true,
+ content: 'loss:1',
+ config: { metrics: [], dataRange: { start: 0, end: undefined, useRange: false }, useStepKeyword: true, stepKeyword: 'foo:' }
+ }
+ ]));
+
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByText('saved.log')).toBeInTheDocument();
+ expect(screen.getAllByLabelText('使用 Step 关键字')[0]).toBeChecked();
+
+ const resetButtons = screen.getAllByRole('button', { name: '重置配置' });
+ for (const btn of resetButtons) {
+ await user.click(btn);
+ }
+
+ await waitFor(() => {
+ expect(localStorage.getItem('uploadedFiles')).toBeNull();
+ expect(localStorage.getItem('globalParsingConfig')).toBeNull();
+ expect(screen.queryByText('saved.log')).not.toBeInTheDocument();
+ });
+
+ expect(screen.getAllByLabelText('使用 Step 关键字')[0]).not.toBeChecked();
+ });
+});
+