refactor: 全面重构 JsonTools 页面,采用声明式响应架构并统一 shadcn 样式
This commit is contained in:
+137
-154
@@ -1,22 +1,21 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { GitCompareArrows, Braces, ArrowRightLeft, Minimize2 } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ArrowRightLeft, Braces, GitCompareArrows, Minimize2 } from 'lucide-react';
|
||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import JsonDiffInput from './JsonDiffInput';
|
||||
import DiffResult from './DiffResult';
|
||||
import DiffNavigator from './DiffNavigator';
|
||||
import JsonFormatSection from './JsonFormatSection';
|
||||
import JsonConvertSection from './JsonConvertSection';
|
||||
import type { ConvertFunction } from './JsonConvertSection';
|
||||
import JsonConvertSection from './JsonConvertSection';
|
||||
import { diffJson } from './diffEngine';
|
||||
import type { DiffResult as DiffResultType, ViewMode } from './types';
|
||||
import { jsonToYaml } from '@/utils/jsonToYaml';
|
||||
import { jsonToToml } from '@/utils/jsonToToml';
|
||||
import { minifyJson } from '@/utils/jsonFormatter';
|
||||
import { useStorageState } from '@/utils/useStorageState';
|
||||
import type { JsonToolsPageMode } from '@/types/storage';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import type { ViewMode } from './types';
|
||||
|
||||
interface ParseState {
|
||||
value: unknown;
|
||||
@@ -33,9 +32,7 @@ const tryParse = (raw: string, invalidMsg: string): ParseState => {
|
||||
}
|
||||
};
|
||||
|
||||
/** 页面模式 */
|
||||
const VALID_PAGE_MODES: readonly JsonToolsPageMode[] = ['diff', 'format', 'yaml', 'toml', 'minify'];
|
||||
|
||||
const isValidPageMode = (val: unknown): val is JsonToolsPageMode =>
|
||||
typeof val === 'string' && (VALID_PAGE_MODES as readonly string[]).includes(val);
|
||||
|
||||
@@ -44,67 +41,64 @@ type PageMode = JsonToolsPageMode;
|
||||
export default function Index() {
|
||||
const { t } = useLazyTranslation(['jsonDiff', 'jsonFormat']);
|
||||
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
|
||||
|
||||
// 1. 受控原始输入源
|
||||
const [leftInput, setLeftInput] = useState('');
|
||||
const [rightInput, setRightInput] = useState('');
|
||||
const [leftError, setLeftError] = useState<string | null>(null);
|
||||
const [rightError, setRightError] = useState<string | null>(null);
|
||||
const [diffResult, setDiffResult] = useState<DiffResultType | null>(null);
|
||||
|
||||
// 2. 纯净的异步防抖管道:仅负责切断高频打字开销
|
||||
const [debouncedLeft, setDebouncedLeft] = useState('');
|
||||
const [debouncedRight, setDebouncedRight] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
setDebouncedLeft(leftInput);
|
||||
setDebouncedRight(rightInput);
|
||||
}, 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, [leftInput, rightInput]);
|
||||
|
||||
// 3. 贯彻方案A:利用 useMemo 将防抖文本同步转化为解析树和错误提示
|
||||
const parseState = useMemo(() => {
|
||||
const invalidMsg = t('jsonDiff:invalidJson');
|
||||
return {
|
||||
left: tryParse(debouncedLeft, invalidMsg),
|
||||
right: tryParse(debouncedRight, invalidMsg),
|
||||
};
|
||||
}, [debouncedLeft, debouncedRight, t]);
|
||||
|
||||
const leftError = parseState.left.error;
|
||||
const rightError = parseState.right.error;
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('sideBySide');
|
||||
const [currentDiffIndex, setCurrentDiffIndex] = useState(0);
|
||||
|
||||
// 防抖校验输入
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => {
|
||||
const invalid = t('jsonDiff:invalidJson');
|
||||
setLeftError(tryParse(leftInput, invalid).error);
|
||||
setRightError(tryParse(rightInput, invalid).error);
|
||||
}, 300);
|
||||
return () => clearTimeout(handle);
|
||||
}, [leftInput, rightInput, t]);
|
||||
|
||||
const canCompare = useMemo(() => {
|
||||
return leftInput.trim() !== '' && rightInput.trim() !== '' && !leftError && !rightError;
|
||||
}, [leftInput, rightInput, leftError, rightError]);
|
||||
|
||||
const handleCompare = () => {
|
||||
const invalid = t('jsonDiff:invalidJson');
|
||||
const left = tryParse(leftInput, invalid);
|
||||
const right = tryParse(rightInput, invalid);
|
||||
setLeftError(left.error);
|
||||
setRightError(right.error);
|
||||
if (left.error || right.error) {
|
||||
setDiffResult(null);
|
||||
return;
|
||||
// 4. 实时比对流式计算
|
||||
const diffResult = useMemo(() => {
|
||||
const { left, right } = parseState;
|
||||
if (left.error || right.error || debouncedLeft.trim() === '' || debouncedRight.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const result = diffJson(left.value, right.value);
|
||||
setDiffResult(result);
|
||||
setCurrentDiffIndex(0);
|
||||
};
|
||||
return diffJson(left.value, right.value);
|
||||
}, [parseState, debouncedLeft, debouncedRight]);
|
||||
|
||||
const handleClear = () => {
|
||||
setLeftInput('');
|
||||
setRightInput('');
|
||||
setLeftError(null);
|
||||
setRightError(null);
|
||||
setDiffResult(null);
|
||||
setCurrentDiffIndex(0);
|
||||
};
|
||||
// 💡 彻底删除了原本在此处的侦听 [diffResult] 的 useEffect。
|
||||
// 状态重置已完全委托给事件源头,级联更新警告从根源上永久自愈!
|
||||
|
||||
const total = diffResult?.diffPaths.length ?? 0;
|
||||
|
||||
const handlePrev = () => {
|
||||
const handlePrev = useCallback(() => {
|
||||
if (total === 0) return;
|
||||
setCurrentDiffIndex((idx) => (idx - 1 + total) % total);
|
||||
};
|
||||
}, [total]);
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = useCallback(() => {
|
||||
if (total === 0) return;
|
||||
setCurrentDiffIndex((idx) => (idx + 1) % total);
|
||||
};
|
||||
}, [total]);
|
||||
|
||||
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
|
||||
|
||||
/** 页面模式对应的标题和副标题翻译键 */
|
||||
const modeTitles: Record<PageMode, { title: string; subtitle: string }> = {
|
||||
diff: { title: 'jsonDiff:pageTitle', subtitle: 'jsonDiff:pageSubtitle' },
|
||||
format: { title: 'jsonFormat:formatTitle', subtitle: 'jsonFormat:formatSubtitle' },
|
||||
@@ -114,11 +108,11 @@ export default function Index() {
|
||||
};
|
||||
|
||||
const modeIcon: Record<PageMode, React.ReactNode> = {
|
||||
diff: <GitCompareArrows className="h-5 w-5" />,
|
||||
format: <Braces className="h-5 w-5" />,
|
||||
yaml: <ArrowRightLeft className="h-5 w-5" />,
|
||||
toml: <ArrowRightLeft className="h-5 w-5" />,
|
||||
minify: <Minimize2 className="h-5 w-5" />,
|
||||
diff: <GitCompareArrows className="h-4 w-4" />,
|
||||
format: <Braces className="h-4 w-4" />,
|
||||
yaml: <ArrowRightLeft className="h-4 w-4" />,
|
||||
toml: <ArrowRightLeft className="h-4 w-4" />,
|
||||
minify: <Minimize2 className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
const yamlConvert: ConvertFunction = useCallback((text: string) => {
|
||||
@@ -137,110 +131,99 @@ export default function Index() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="p-2">
|
||||
<PageHeader
|
||||
title={t(modeTitles[pageMode].title)}
|
||||
subtitle={t(modeTitles[pageMode].subtitle)}
|
||||
icon={modeIcon[pageMode]}
|
||||
iconColor="#3b82f6"
|
||||
/>
|
||||
<div className="p-4 w-full flex flex-col space-y-4 min-h-[500px] select-none animate-in fade-in duration-300">
|
||||
<PageHeader
|
||||
title={t(modeTitles[pageMode].title)}
|
||||
subtitle={t(modeTitles[pageMode].subtitle)}
|
||||
icon={modeIcon[pageMode]}
|
||||
iconColor="#3b82f6"
|
||||
className="pb-1"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* 页面模式切换器 */}
|
||||
<SwitchButtonGroup
|
||||
value={pageMode}
|
||||
onChange={(v: PageMode) => setPageMode(v)}
|
||||
options={[
|
||||
{ value: 'diff', label: t('jsonFormat:diffMode') },
|
||||
{ value: 'format', label: t('jsonFormat:formatMode') },
|
||||
{ value: 'yaml', label: t('jsonFormat:yamlMode') },
|
||||
{ value: 'toml', label: t('jsonFormat:tomlMode') },
|
||||
{ value: 'minify', label: t('jsonFormat:minifyMode') },
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
<SwitchButtonGroup
|
||||
value={pageMode}
|
||||
onChange={(v: PageMode) => setPageMode(v)}
|
||||
options={[
|
||||
{ value: 'diff', label: t('jsonFormat:diffMode') },
|
||||
{ value: 'format', label: t('jsonFormat:formatMode') },
|
||||
{ value: 'yaml', label: t('jsonFormat:yamlMode') },
|
||||
{ value: 'toml', label: t('jsonFormat:tomlMode') },
|
||||
{ value: 'minify', label: t('jsonFormat:minifyMode') },
|
||||
]}
|
||||
size="small"
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
|
||||
{pageMode === 'diff' ? (
|
||||
<>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center">
|
||||
<SwitchButtonGroup
|
||||
value={viewMode}
|
||||
onChange={(v: ViewMode) => setViewMode(v)}
|
||||
options={[
|
||||
{ value: 'sideBySide', label: t('jsonDiff:sideBySideMode') },
|
||||
{ value: 'unified', label: t('jsonDiff:unifiedMode') },
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleClear} className="rounded-lg">
|
||||
{t('jsonDiff:clearButton')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={!canCompare}
|
||||
onClick={handleCompare}
|
||||
className="rounded-lg font-bold px-4 whitespace-nowrap"
|
||||
>
|
||||
{t('jsonDiff:compareButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 输入区 */}
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<JsonDiffInput
|
||||
label={t('jsonDiff:leftLabel')}
|
||||
placeholder={t('jsonDiff:leftPlaceholder')}
|
||||
value={leftInput}
|
||||
onChange={setLeftInput}
|
||||
error={leftError}
|
||||
/>
|
||||
<JsonDiffInput
|
||||
label={t('jsonDiff:rightLabel')}
|
||||
placeholder={t('jsonDiff:rightPlaceholder')}
|
||||
value={rightInput}
|
||||
onChange={setRightInput}
|
||||
error={rightError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 差异展示 */}
|
||||
{diffResult ? (
|
||||
<>
|
||||
<DiffNavigator
|
||||
total={total}
|
||||
currentIndex={currentDiffIndex}
|
||||
onPrev={handlePrev}
|
||||
onNext={handleNext}
|
||||
/>
|
||||
<DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} />
|
||||
</>
|
||||
) : (
|
||||
<div className="p-4 rounded-lg bg-muted border border-dashed border-input text-center">
|
||||
<p className="text-sm font-semibold text-muted-foreground">
|
||||
{t('jsonDiff:emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : pageMode === 'format' ? (
|
||||
<JsonFormatSection />
|
||||
) : pageMode === 'yaml' ? (
|
||||
<JsonConvertSection translationPrefix="yamlMode" convertFunction={yamlConvert} />
|
||||
) : pageMode === 'toml' ? (
|
||||
<JsonConvertSection translationPrefix="tomlMode" convertFunction={tomlConvert} />
|
||||
) : (
|
||||
<JsonConvertSection
|
||||
translationPrefix="minifyMode"
|
||||
convertFunction={minifyConvert}
|
||||
convertButtonKey="minifyButton"
|
||||
{pageMode === 'diff' ? (
|
||||
<div className="flex flex-col space-y-4 animate-in fade-in duration-200">
|
||||
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
|
||||
<SwitchButtonGroup
|
||||
value={viewMode}
|
||||
onChange={(v: ViewMode) => setViewMode(v)}
|
||||
options={[
|
||||
{ value: 'sideBySide', label: t('jsonDiff:sideBySideMode') },
|
||||
{ value: 'unified', label: t('jsonDiff:unifiedMode') },
|
||||
]}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 w-full items-stretch">
|
||||
<JsonDiffInput
|
||||
label={t('jsonDiff:leftLabel')}
|
||||
placeholder={t('jsonDiff:leftPlaceholder')}
|
||||
value={leftInput}
|
||||
onChange={(val) => {
|
||||
setLeftInput(val);
|
||||
setCurrentDiffIndex(0); // 💡 在同一个用户键盘事件中打包批处理,0 副作用开销
|
||||
}}
|
||||
error={leftError}
|
||||
minRows={9}
|
||||
/>
|
||||
<JsonDiffInput
|
||||
label={t('jsonDiff:rightLabel')}
|
||||
placeholder={t('jsonDiff:rightPlaceholder')}
|
||||
value={rightInput}
|
||||
onChange={(val) => {
|
||||
setRightInput(val);
|
||||
setCurrentDiffIndex(0); // 💡 在同一个用户键盘事件中打包批处理,0 副作用开销
|
||||
}}
|
||||
error={rightError}
|
||||
minRows={9}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{diffResult ? (
|
||||
<div className="flex flex-col space-y-3.5 w-full pt-1">
|
||||
<div className="flex justify-center w-full">
|
||||
<DiffNavigator
|
||||
total={total}
|
||||
currentIndex={currentDiffIndex}
|
||||
onPrev={handlePrev}
|
||||
onNext={handleNext}
|
||||
/>
|
||||
</div>
|
||||
<DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed">
|
||||
{leftError || rightError
|
||||
? '请修正上方 JSON 的语法错误以开启实时流式比对'
|
||||
: t('jsonDiff:emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : pageMode === 'format' ? (
|
||||
<JsonFormatSection />
|
||||
) : pageMode === 'yaml' ? (
|
||||
<JsonConvertSection translationPrefix="yaml" convertFunction={yamlConvert} />
|
||||
) : pageMode === 'toml' ? (
|
||||
<JsonConvertSection translationPrefix="toml" convertFunction={tomlConvert} />
|
||||
) : (
|
||||
<JsonConvertSection translationPrefix="minify" convertFunction={minifyConvert} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user