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());
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 JsonResultPanel from './JsonResultPanel';
import CollapsiblePanel from './CollapsiblePanel';
import { validateJson } from '@/utils/jsonFormatter';
import { cn } from '@/lib/utils';
import { buildPreview } from '../useJsonTools';
import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
import type { ConvertFunction, ConvertResult } from '../types';
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> {
tools: UseJsonToolsReturn;
mode: string;
convertFunction: ConvertFunction;
}
export default function JsonConvertSection({
tools,
mode,
convertFunction,
className,
...props
}: JsonConvertSectionProps) {
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
const { input, setInput, debouncedInput } = tools;
const [inputCollapsed, setInputCollapsed] = useState(false);
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml;
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 250);
return () => clearTimeout(handle);
}, [input]);
const error = useMemo(() => {
return validateJson(debouncedInput);
}, [debouncedInput]);
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import {
formatJson,
type JsonFormatOptions,
@@ -11,22 +11,18 @@ import CollapsiblePanel from './CollapsiblePanel';
import JsonResultPanel from './JsonResultPanel';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { buildPreview } from '../useJsonTools';
import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
export default function JsonFormatSection() {
const [input, setInput] = useState('');
const [debouncedInput, setDebouncedInput] = useState('');
interface JsonFormatSectionProps {
tools: UseJsonToolsReturn;
}
export default function JsonFormatSection({ tools }: JsonFormatSectionProps) {
const { input, setInput, debouncedInput } = tools;
const [indentSize, setIndentSize] = useState<number>(2);
const [sortKeys, setSortKeys] = useState(false);
const [inputCollapsed, setInputCollapsed] = useState(false);
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 250);
return () => clearTimeout(handle);
}, [input]);
const error = useMemo(() => {
return validateJson(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 { useEffect, useState } from 'react';
import { describe, it, expect } from 'vitest';
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', () => {
it('渲染输入面板标题与缩进/键名排序工具栏', () => {
render(<JsonFormatSection />);
render(<Harness />);
expect(screen.getByText('JSON 输入')).toBeInTheDocument();
expect(screen.getByText('缩进')).toBeInTheDocument();
expect(screen.getByText('键名排序')).toBeInTheDocument();
});
it('输入无效 JSON 时不渲染结果面板与占位区', async () => {
render(<JsonFormatSection />);
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
fireEvent.change(input, { target: { value: '{ invalid json ' } });
render(<Harness initial="{ invalid json " />);
await waitFor(() => {
expect(screen.queryByText('格式化结果')).not.toBeInTheDocument();
});
@@ -25,9 +36,7 @@ describe('JsonFormatSection', () => {
});
it('输入有效 JSON 后渲染格式化结果面板', async () => {
render(<JsonFormatSection />);
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
fireEvent.change(input, { target: { value: '{"a":1,"b":2}' } });
render(<Harness initial='{"a":1,"b":2}' />);
await waitFor(() => {
expect(screen.getByText('格式化结果')).toBeInTheDocument();
});
@@ -37,20 +46,17 @@ describe('JsonFormatSection', () => {
it('点击标题可折叠输入面板', async () => {
const user = userEvent.setup();
render(<JsonFormatSection />);
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
fireEvent.change(input, { target: { value: '{"a":1}' } });
render(<Harness initial='{"a":1}' />);
await waitFor(() => {
expect(screen.getByText('格式化结果')).toBeInTheDocument();
});
await user.click(screen.getByText('JSON 输入'));
// 折叠后输入框内容区隐藏
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
await waitFor(() => {
expect(input.closest('.opacity-0')).toBeInTheDocument();
});
// 折叠态展示单行预览
expect(screen.getByText('{"a":1}(点击展开)')).toBeInTheDocument();
});
});
+4 -4
View File
@@ -30,13 +30,13 @@ export default function Index() {
{pageMode === 'diff' ? (
<DiffWorkspace tools={tools} />
) : pageMode === 'format' ? (
<JsonFormatSection />
<JsonFormatSection tools={tools} />
) : pageMode === 'yaml' ? (
<JsonConvertSection mode="yaml" convertFunction={yamlConvert} />
<JsonConvertSection tools={tools} mode="yaml" convertFunction={yamlConvert} />
) : 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>
);
+29 -5
View File
@@ -21,6 +21,11 @@ export interface UseJsonToolsReturn {
setRightInput: (val: string) => void;
leftError: string | null;
rightError: string | null;
/** format/yaml/toml/minify 共享的输入内容(已持久化) */
input: string;
setInput: (val: string) => void;
/** input 防抖后的快照,用于格式化/转换计算与持久化 */
debouncedInput: string;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
/** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */
@@ -59,18 +64,34 @@ export function useJsonTools(): UseJsonToolsReturn {
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
const [leftInput, setLeftInputState] = useState('');
const [rightInput, setRightInputState] = useState('');
const [debouncedLeft, setDebouncedLeft] = useState('');
const [debouncedRight, setDebouncedRight] = useState('');
const [persistedLeft, setPersistedLeft] = useStorageState('jsonTools/diffLeft', '');
const [persistedRight, setPersistedRight] = useStorageState('jsonTools/diffRight', '');
const [persistedInput, setPersistedInput] = useStorageState('jsonTools/input', '');
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(() => {
const handle = setTimeout(() => {
setDebouncedLeft(leftInput);
setDebouncedRight(rightInput);
setPersistedLeft(leftInput);
setPersistedRight(rightInput);
}, 250);
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 invalidMsg = '无效的 JSON 格式';
@@ -207,6 +228,9 @@ export function useJsonTools(): UseJsonToolsReturn {
setRightInput,
leftError,
rightError,
input,
setInput,
debouncedInput,
viewMode: activeViewMode,
setViewMode,
activeResult,
+6
View File
@@ -89,6 +89,12 @@ export interface StorageSchema {
'qrCode/urlExpanded': boolean;
/** JSON 工具页面当前子模式 */
'jsonTools/pageMode': JsonToolsPageMode;
/** JSON 工具:format/yaml/toml/minify 共享的输入内容 */
'jsonTools/input': string;
/** JSON 工具:diff 模式左侧输入内容 */
'jsonTools/diffLeft': string;
/** JSON 工具:diff 模式右侧输入内容 */
'jsonTools/diffRight': string;
/** Base64 转换器页面当前子模式 */
'base64Converter/pageMode': Base64ConverterPageMode;
/** Base64 转换器「文件」子模式当前方向 */