diff --git a/src/pages/JsonTools/components/CollapsiblePanel.tsx b/src/pages/JsonTools/components/CollapsiblePanel.tsx new file mode 100644 index 0000000..782866c --- /dev/null +++ b/src/pages/JsonTools/components/CollapsiblePanel.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { ChevronRight } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +export interface CollapsiblePanelProps extends Omit, 'title'> { + /** 面板标题 */ + title: string; + /** 是否处于折叠态 */ + collapsed: boolean; + onToggleCollapse: () => void; + /** 折叠态展示的单行缩略预览文本 */ + preview?: string; + /** 展开态内容 */ + children?: React.ReactNode; +} + +export default function CollapsiblePanel({ + title, + collapsed, + onToggleCollapse, + preview, + children, + className, + ...props +}: CollapsiblePanelProps) { + return ( +
+ + +
+ {children} +
+
+ ); +} diff --git a/src/pages/JsonTools/components/JsonConvertSection.tsx b/src/pages/JsonTools/components/JsonConvertSection.tsx index 8375a5e..86298b1 100644 --- a/src/pages/JsonTools/components/JsonConvertSection.tsx +++ b/src/pages/JsonTools/components/JsonConvertSection.tsx @@ -1,29 +1,24 @@ import React, { useEffect, useMemo, useState } from 'react'; -import EmptyPlaceholder from '@/components/EmptyPlaceholder'; 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 type { ConvertFunction, ConvertResult } from '../types'; -const CONVERT_LABELS: Record< - string, - { inputPlaceholder: string; outputLabel: string; emptyHint: string } -> = { +const CONVERT_LABELS: Record = { yaml: { inputPlaceholder: '输入需要转换的 JSON...', outputLabel: 'YAML 结果', - emptyHint: '输入 JSON 后点击转换', }, toml: { inputPlaceholder: '输入需要转换的 JSON...', outputLabel: 'TOML 结果', - emptyHint: '输入 JSON 后点击转换', }, minify: { inputPlaceholder: '输入需要压缩的 JSON...', outputLabel: '压缩结果', - emptyHint: '输入 JSON 后点击压缩', }, }; @@ -40,6 +35,7 @@ export default function JsonConvertSection({ }: JsonConvertSectionProps) { const [input, setInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState(''); + const [inputCollapsed, setInputCollapsed] = useState(false); const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml; @@ -71,32 +67,40 @@ export default function JsonConvertSection({ } }, [debouncedInput, error, convertFunction]); - return ( -
- setInput('')} - /> + const preview = useMemo(() => buildPreview(input), [input]); - {result?.output ? ( - + setInputCollapsed((v) => !v)} + preview={preview} + > + setInput('')} /> - ) : ( - - {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint} - + + + {result?.output && ( +
+ +
)}
); diff --git a/src/pages/JsonTools/components/JsonDiffPanel.tsx b/src/pages/JsonTools/components/JsonDiffPanel.tsx index 0c627cf..5281e8e 100644 --- a/src/pages/JsonTools/components/JsonDiffPanel.tsx +++ b/src/pages/JsonTools/components/JsonDiffPanel.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { ChevronRight } from 'lucide-react'; import TextInputArea from '@/components/TextInputArea'; +import CollapsiblePanel from './CollapsiblePanel'; import { cn } from '@/lib/utils'; export interface JsonDiffPanelProps extends Omit, 'onChange'> { @@ -30,62 +30,30 @@ export default function JsonDiffPanel({ ...props }: JsonDiffPanelProps) { return ( -
- - -
- -
-
+ + ); } diff --git a/src/pages/JsonTools/components/JsonFormatSection.tsx b/src/pages/JsonTools/components/JsonFormatSection.tsx index 9bd7144..cac1ec0 100644 --- a/src/pages/JsonTools/components/JsonFormatSection.tsx +++ b/src/pages/JsonTools/components/JsonFormatSection.tsx @@ -5,18 +5,20 @@ import { type JsonFormatResult, validateJson, } from '@/utils/jsonFormatter'; -import EmptyPlaceholder from '@/components/EmptyPlaceholder'; import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import TextInputArea from '@/components/TextInputArea'; +import CollapsiblePanel from './CollapsiblePanel'; import JsonResultPanel from './JsonResultPanel'; import { Checkbox } from '@/components/ui/checkbox'; import { Label } from '@/components/ui/label'; +import { buildPreview } from '../useJsonTools'; export default function JsonFormatSection() { const [input, setInput] = useState(''); const [debouncedInput, setDebouncedInput] = useState(''); const [indentSize, setIndentSize] = useState(2); const [sortKeys, setSortKeys] = useState(false); + const [inputCollapsed, setInputCollapsed] = useState(false); useEffect(() => { const handle = setTimeout(() => { @@ -47,9 +49,11 @@ export default function JsonFormatSection() { } }, [debouncedInput, error, indentSize, sortKeys]); + const preview = useMemo(() => buildPreview(input), [input]); + return ( -
-
+
+
@@ -57,7 +61,7 @@ export default function JsonFormatSection() { setIndentSize(Number(v))} + onChange={(v: number) => setIndentSize(Number(v))} options={[2, 4, 6, 8].map((size) => ({ value: size, label: String(size) }))} size="small" /> @@ -86,29 +90,36 @@ export default function JsonFormatSection() {
- setInput('')} - /> - - {result?.formatted ? ( - setInputCollapsed((v) => !v)} + preview={preview} + > + setInput('')} /> - ) : ( - - {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'} - + + + {result?.formatted && ( +
+ +
)}
); diff --git a/src/pages/JsonTools/components/JsonResultPanel.tsx b/src/pages/JsonTools/components/JsonResultPanel.tsx index b0b820f..5098999 100644 --- a/src/pages/JsonTools/components/JsonResultPanel.tsx +++ b/src/pages/JsonTools/components/JsonResultPanel.tsx @@ -1,5 +1,6 @@ import { formatBytes } from '@/utils/format'; import { CopyButton } from '@/components/CopyButton'; +import { cn } from '@/lib/utils'; export interface JsonResultPanelProps { title: string; @@ -8,6 +9,11 @@ export interface JsonResultPanelProps { outputBytes: number; outputSizeLabel?: string; maxHeight?: string; + /** + * 撑满父容器剩余高度并启用内部滚动(而非按 maxHeight 截断)。 + * 用于 popup / sidepanel 等固定高度场景下与折叠输入面板共享垂直空间。 + */ + fill?: boolean; } export default function JsonResultPanel({ @@ -17,9 +23,15 @@ export default function JsonResultPanel({ outputBytes, outputSizeLabel = '格式化后大小', maxHeight = '420px', + fill = false, }: JsonResultPanelProps) { return ( -
+
@@ -43,8 +55,11 @@ export default function JsonResultPanel({
{content}
diff --git a/src/pages/JsonTools/components/__tests__/JsonFormatSection.test.tsx b/src/pages/JsonTools/components/__tests__/JsonFormatSection.test.tsx new file mode 100644 index 0000000..75637eb --- /dev/null +++ b/src/pages/JsonTools/components/__tests__/JsonFormatSection.test.tsx @@ -0,0 +1,56 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect } from 'vitest'; +import JsonFormatSection from '../JsonFormatSection'; + +describe('JsonFormatSection', () => { + it('渲染输入面板标题与缩进/键名排序工具栏', () => { + render(); + expect(screen.getByText('JSON 输入')).toBeInTheDocument(); + expect(screen.getByText('缩进')).toBeInTheDocument(); + expect(screen.getByText('键名排序')).toBeInTheDocument(); + }); + + it('输入无效 JSON 时不渲染结果面板与占位区', async () => { + render(); + const input = screen.getByPlaceholderText('输入需要格式化的 JSON...'); + fireEvent.change(input, { target: { value: '{ invalid json ' } }); + await waitFor(() => { + expect(screen.queryByText('格式化结果')).not.toBeInTheDocument(); + }); + expect( + screen.queryByText('请修正上方 JSON 的语法错误以开启实时流式格式化'), + ).not.toBeInTheDocument(); + expect(screen.queryByText('输入 JSON 后点击格式化')).not.toBeInTheDocument(); + }); + + it('输入有效 JSON 后渲染格式化结果面板', async () => { + render(); + const input = screen.getByPlaceholderText('输入需要格式化的 JSON...'); + fireEvent.change(input, { target: { value: '{"a":1,"b":2}' } }); + await waitFor(() => { + expect(screen.getByText('格式化结果')).toBeInTheDocument(); + }); + expect(screen.getByText(/"a": 1/)).toBeInTheDocument(); + expect(screen.getByText(/"b": 2/)).toBeInTheDocument(); + }); + + it('点击标题可折叠输入面板', async () => { + const user = userEvent.setup(); + render(); + const input = screen.getByPlaceholderText('输入需要格式化的 JSON...'); + fireEvent.change(input, { target: { value: '{"a":1}' } }); + await waitFor(() => { + expect(screen.getByText('格式化结果')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('JSON 输入')); + + // 折叠后输入框内容区隐藏 + await waitFor(() => { + expect(input.closest('.opacity-0')).toBeInTheDocument(); + }); + // 折叠态展示单行预览 + expect(screen.getByText('{"a":1}(点击展开)')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/JsonTools/useJsonTools.ts b/src/pages/JsonTools/useJsonTools.ts index 842079a..c3e7af5 100644 --- a/src/pages/JsonTools/useJsonTools.ts +++ b/src/pages/JsonTools/useJsonTools.ts @@ -48,7 +48,7 @@ export interface UseJsonToolsReturn { minifyConvert: ConvertFunction; } -const buildPreview = (raw: string): string => { +export const buildPreview = (raw: string): string => { const single = raw.replace(/\s+/g, ' ').trim(); const truncated = single.length > 80 ? `${single.slice(0, 80)}…` : single; return truncated ? `${truncated}(点击展开)` : '(点击展开)';