refactor(json-tools): 重构状态管理逻辑并实现输入内容持久化

This commit is contained in:
2026-07-14 20:16:09 +08:00
parent 533ac84e22
commit 10817c3aac
7 changed files with 82 additions and 45 deletions
@@ -64,4 +64,15 @@ describe('useJsonTools 窄屏手动比对', () => {
act(() => result.current.toggleCollapseA()); act(() => result.current.toggleCollapseA());
expect(result.current.collapsedA).toBe(false); expect(result.current.collapsedA).toBe(false);
}); });
it('单输入经防抖后持久化,重挂载可恢复', async () => {
localStorage.clear();
const { result, unmount } = renderHook(() => useJsonTools());
act(() => result.current.setInput('{"x":1}'));
await new Promise((r) => setTimeout(r, 300));
unmount();
const { result: result2 } = renderHook(() => useJsonTools());
expect(result2.current.input).toBe('{"x":1}');
});
}); });
@@ -1,10 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import JsonResultPanel from './JsonResultPanel'; import JsonResultPanel from './JsonResultPanel';
import CollapsiblePanel from './CollapsiblePanel'; import CollapsiblePanel from './CollapsiblePanel';
import { validateJson } from '@/utils/jsonFormatter'; import { validateJson } from '@/utils/jsonFormatter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buildPreview } from '../useJsonTools'; import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
import type { ConvertFunction, ConvertResult } from '../types'; import type { ConvertFunction, ConvertResult } from '../types';
const CONVERT_LABELS: Record<string, { inputPlaceholder: string; outputLabel: string }> = { const CONVERT_LABELS: Record<string, { inputPlaceholder: string; outputLabel: string }> = {
@@ -23,29 +23,23 @@ const CONVERT_LABELS: Record<string, { inputPlaceholder: string; outputLabel: st
}; };
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> { interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
tools: UseJsonToolsReturn;
mode: string; mode: string;
convertFunction: ConvertFunction; convertFunction: ConvertFunction;
} }
export default function JsonConvertSection({ export default function JsonConvertSection({
tools,
mode, mode,
convertFunction, convertFunction,
className, className,
...props ...props
}: JsonConvertSectionProps) { }: JsonConvertSectionProps) {
const [input, setInput] = useState(''); const { input, setInput, debouncedInput } = tools;
const [debouncedInput, setDebouncedInput] = useState('');
const [inputCollapsed, setInputCollapsed] = useState(false); const [inputCollapsed, setInputCollapsed] = useState(false);
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml; const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml;
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 250);
return () => clearTimeout(handle);
}, [input]);
const error = useMemo(() => { const error = useMemo(() => {
return validateJson(debouncedInput); return validateJson(debouncedInput);
}, [debouncedInput]); }, [debouncedInput]);
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { import {
formatJson, formatJson,
type JsonFormatOptions, type JsonFormatOptions,
@@ -11,22 +11,18 @@ import CollapsiblePanel from './CollapsiblePanel';
import JsonResultPanel from './JsonResultPanel'; import JsonResultPanel from './JsonResultPanel';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { buildPreview } from '../useJsonTools'; import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
export default function JsonFormatSection() { interface JsonFormatSectionProps {
const [input, setInput] = useState(''); tools: UseJsonToolsReturn;
const [debouncedInput, setDebouncedInput] = useState(''); }
export default function JsonFormatSection({ tools }: JsonFormatSectionProps) {
const { input, setInput, debouncedInput } = tools;
const [indentSize, setIndentSize] = useState<number>(2); const [indentSize, setIndentSize] = useState<number>(2);
const [sortKeys, setSortKeys] = useState(false); const [sortKeys, setSortKeys] = useState(false);
const [inputCollapsed, setInputCollapsed] = useState(false); const [inputCollapsed, setInputCollapsed] = useState(false);
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 250);
return () => clearTimeout(handle);
}, [input]);
const error = useMemo(() => { const error = useMemo(() => {
return validateJson(debouncedInput); return validateJson(debouncedInput);
}, [debouncedInput]); }, [debouncedInput]);
@@ -1,20 +1,31 @@
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useEffect, useState } from 'react';
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import JsonFormatSection from '../JsonFormatSection'; import JsonFormatSection from '../JsonFormatSection';
import type { UseJsonToolsReturn } from '../../useJsonTools';
function Harness({ initial = '' }: { initial?: string }) {
const [input, setInput] = useState(initial);
const [debouncedInput, setDebouncedInput] = useState(initial);
useEffect(() => {
const t = setTimeout(() => setDebouncedInput(input), 250);
return () => clearTimeout(t);
}, [input]);
const tools = { input, setInput, debouncedInput } as unknown as UseJsonToolsReturn;
return <JsonFormatSection tools={tools} />;
}
describe('JsonFormatSection', () => { describe('JsonFormatSection', () => {
it('渲染输入面板标题与缩进/键名排序工具栏', () => { it('渲染输入面板标题与缩进/键名排序工具栏', () => {
render(<JsonFormatSection />); render(<Harness />);
expect(screen.getByText('JSON 输入')).toBeInTheDocument(); expect(screen.getByText('JSON 输入')).toBeInTheDocument();
expect(screen.getByText('缩进')).toBeInTheDocument(); expect(screen.getByText('缩进')).toBeInTheDocument();
expect(screen.getByText('键名排序')).toBeInTheDocument(); expect(screen.getByText('键名排序')).toBeInTheDocument();
}); });
it('输入无效 JSON 时不渲染结果面板与占位区', async () => { it('输入无效 JSON 时不渲染结果面板与占位区', async () => {
render(<JsonFormatSection />); render(<Harness initial="{ invalid json " />);
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
fireEvent.change(input, { target: { value: '{ invalid json ' } });
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText('格式化结果')).not.toBeInTheDocument(); expect(screen.queryByText('格式化结果')).not.toBeInTheDocument();
}); });
@@ -25,9 +36,7 @@ describe('JsonFormatSection', () => {
}); });
it('输入有效 JSON 后渲染格式化结果面板', async () => { it('输入有效 JSON 后渲染格式化结果面板', async () => {
render(<JsonFormatSection />); render(<Harness initial='{"a":1,"b":2}' />);
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
fireEvent.change(input, { target: { value: '{"a":1,"b":2}' } });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('格式化结果')).toBeInTheDocument(); expect(screen.getByText('格式化结果')).toBeInTheDocument();
}); });
@@ -37,20 +46,17 @@ describe('JsonFormatSection', () => {
it('点击标题可折叠输入面板', async () => { it('点击标题可折叠输入面板', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<JsonFormatSection />); render(<Harness initial='{"a":1}' />);
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
fireEvent.change(input, { target: { value: '{"a":1}' } });
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('格式化结果')).toBeInTheDocument(); expect(screen.getByText('格式化结果')).toBeInTheDocument();
}); });
await user.click(screen.getByText('JSON 输入')); await user.click(screen.getByText('JSON 输入'));
// 折叠后输入框内容区隐藏 const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
await waitFor(() => { await waitFor(() => {
expect(input.closest('.opacity-0')).toBeInTheDocument(); expect(input.closest('.opacity-0')).toBeInTheDocument();
}); });
// 折叠态展示单行预览
expect(screen.getByText('{"a":1}(点击展开)')).toBeInTheDocument(); expect(screen.getByText('{"a":1}(点击展开)')).toBeInTheDocument();
}); });
}); });
+4 -4
View File
@@ -30,13 +30,13 @@ export default function Index() {
{pageMode === 'diff' ? ( {pageMode === 'diff' ? (
<DiffWorkspace tools={tools} /> <DiffWorkspace tools={tools} />
) : pageMode === 'format' ? ( ) : pageMode === 'format' ? (
<JsonFormatSection /> <JsonFormatSection tools={tools} />
) : pageMode === 'yaml' ? ( ) : pageMode === 'yaml' ? (
<JsonConvertSection mode="yaml" convertFunction={yamlConvert} /> <JsonConvertSection tools={tools} mode="yaml" convertFunction={yamlConvert} />
) : pageMode === 'toml' ? ( ) : pageMode === 'toml' ? (
<JsonConvertSection mode="toml" convertFunction={tomlConvert} /> <JsonConvertSection tools={tools} mode="toml" convertFunction={tomlConvert} />
) : ( ) : (
<JsonConvertSection mode="minify" convertFunction={minifyConvert} /> <JsonConvertSection tools={tools} mode="minify" convertFunction={minifyConvert} />
)} )}
</div> </div>
); );
+29 -5
View File
@@ -21,6 +21,11 @@ export interface UseJsonToolsReturn {
setRightInput: (val: string) => void; setRightInput: (val: string) => void;
leftError: string | null; leftError: string | null;
rightError: string | null; rightError: string | null;
/** format/yaml/toml/minify 共享的输入内容(已持久化) */
input: string;
setInput: (val: string) => void;
/** input 防抖后的快照,用于格式化/转换计算与持久化 */
debouncedInput: string;
viewMode: ViewMode; viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void; setViewMode: (mode: ViewMode) => void;
/** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */ /** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */
@@ -59,18 +64,34 @@ export function useJsonTools(): UseJsonToolsReturn {
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
const [leftInput, setLeftInputState] = useState(''); const [persistedLeft, setPersistedLeft] = useStorageState('jsonTools/diffLeft', '');
const [rightInput, setRightInputState] = useState(''); const [persistedRight, setPersistedRight] = useStorageState('jsonTools/diffRight', '');
const [debouncedLeft, setDebouncedLeft] = useState(''); const [persistedInput, setPersistedInput] = useStorageState('jsonTools/input', '');
const [debouncedRight, setDebouncedRight] = useState('');
const [leftInput, setLeftInputState] = useState<string>(persistedLeft);
const [rightInput, setRightInputState] = useState<string>(persistedRight);
const [input, setInput] = useState<string>(persistedInput);
const [debouncedLeft, setDebouncedLeft] = useState<string>(persistedLeft);
const [debouncedRight, setDebouncedRight] = useState<string>(persistedRight);
const [debouncedInput, setDebouncedInput] = useState<string>(persistedInput);
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
setDebouncedLeft(leftInput); setDebouncedLeft(leftInput);
setDebouncedRight(rightInput); setDebouncedRight(rightInput);
setPersistedLeft(leftInput);
setPersistedRight(rightInput);
}, 250); }, 250);
return () => clearTimeout(handle); return () => clearTimeout(handle);
}, [leftInput, rightInput]); }, [leftInput, rightInput, setPersistedLeft, setPersistedRight]);
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
setPersistedInput(input);
}, 250);
return () => clearTimeout(handle);
}, [input, setPersistedInput]);
const parseState = useMemo(() => { const parseState = useMemo(() => {
const invalidMsg = '无效的 JSON 格式'; const invalidMsg = '无效的 JSON 格式';
@@ -207,6 +228,9 @@ export function useJsonTools(): UseJsonToolsReturn {
setRightInput, setRightInput,
leftError, leftError,
rightError, rightError,
input,
setInput,
debouncedInput,
viewMode: activeViewMode, viewMode: activeViewMode,
setViewMode, setViewMode,
activeResult, activeResult,
+6
View File
@@ -89,6 +89,12 @@ export interface StorageSchema {
'qrCode/urlExpanded': boolean; 'qrCode/urlExpanded': boolean;
/** JSON 工具页面当前子模式 */ /** JSON 工具页面当前子模式 */
'jsonTools/pageMode': JsonToolsPageMode; 'jsonTools/pageMode': JsonToolsPageMode;
/** JSON 工具:format/yaml/toml/minify 共享的输入内容 */
'jsonTools/input': string;
/** JSON 工具:diff 模式左侧输入内容 */
'jsonTools/diffLeft': string;
/** JSON 工具:diff 模式右侧输入内容 */
'jsonTools/diffRight': string;
/** Base64 转换器页面当前子模式 */ /** Base64 转换器页面当前子模式 */
'base64Converter/pageMode': Base64ConverterPageMode; 'base64Converter/pageMode': Base64ConverterPageMode;
/** Base64 转换器「文件」子模式当前方向 */ /** Base64 转换器「文件」子模式当前方向 */