用 Tailwind CSS 重写 JsonTools 页面

This commit is contained in:
雨霖铃
2026-05-22 00:07:56 +08:00
parent d2334c5e95
commit 37618eb71f
7 changed files with 210 additions and 443 deletions
+23 -21
View File
@@ -1,8 +1,5 @@
import { Box, IconButton, Typography } from '@mui/material'; import { ChevronLeft, ChevronRight } from 'lucide-react';
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { jsonDiffPageStyles } from '@/config/pageTheme';
interface DiffNavigatorProps { interface DiffNavigatorProps {
total: number; total: number;
@@ -17,29 +14,34 @@ export default function DiffNavigator({ total, currentIndex, onPrev, onNext }: D
if (total === 0) { if (total === 0) {
return ( return (
<Box sx={jsonDiffPageStyles.NAVIGATOR}> <div className="flex items-center justify-center gap-3 p-2.5 rounded-lg bg-blue-50 border border-blue-200">
<Typography variant="body2" sx={{ fontWeight: 700, color: 'text.secondary' }}> <span className="text-sm font-bold text-gray-500">{t('jsonDiff:noDiffs')}</span>
{t('jsonDiff:noDiffs')} </div>
</Typography>
</Box>
); );
} }
return ( return (
<Box sx={jsonDiffPageStyles.NAVIGATOR}> <div className="flex items-center justify-center gap-3 p-2.5 rounded-lg bg-blue-50 border border-blue-200">
<IconButton size="small" aria-label={t('jsonDiff:previousDiff')} onClick={onPrev}> <button
<NavigateBeforeIcon /> type="button"
</IconButton> aria-label={t('jsonDiff:previousDiff')}
<Typography onClick={onPrev}
variant="body2" className="p-1 rounded-md hover:bg-blue-100 transition-colors"
sx={{ fontWeight: 800, fontFamily: 'monospace', minWidth: 60, textAlign: 'center' }}
> >
<ChevronLeft className="h-4 w-4" />
</button>
<span className="text-sm font-extrabold font-mono min-w-[60px] text-center">
{currentIndex + 1} / {total} {currentIndex + 1} / {total}
</Typography> </span>
<IconButton size="small" aria-label={t('jsonDiff:nextDiff')} onClick={onNext}> <button
<NavigateNextIcon /> type="button"
</IconButton> aria-label={t('jsonDiff:nextDiff')}
</Box> onClick={onNext}
className="p-1 rounded-md hover:bg-blue-100 transition-colors"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
); );
} }
+28 -53
View File
@@ -1,7 +1,4 @@
import { Box, Stack, Typography, useTheme } from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { jsonDiffPageStyles, surfaceTint } from '@/config/pageTheme';
import JsonTree from './JsonTree'; import JsonTree from './JsonTree';
import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from './types'; import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from './types';
@@ -16,41 +13,30 @@ export default function DiffResult({ result, viewMode, activePath }: DiffResultP
if (viewMode === 'sideBySide') { if (viewMode === 'sideBySide') {
return ( return (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch"> <div className="flex flex-col md:flex-row gap-4 items-stretch">
<Box sx={{ flex: 1, minWidth: 0 }}> <div className="flex-1 min-w-0">
<SectionLabel text={t('jsonDiff:leftLabel')} /> <SectionLabel text={t('jsonDiff:leftLabel')} />
<JsonTree node={result.root} side="left" activePath={activePath} /> <JsonTree node={result.root} side="left" activePath={activePath} />
</Box> </div>
<Box sx={{ flex: 1, minWidth: 0 }}> <div className="flex-1 min-w-0">
<SectionLabel text={t('jsonDiff:rightLabel')} /> <SectionLabel text={t('jsonDiff:rightLabel')} />
<JsonTree node={result.root} side="right" activePath={activePath} /> <JsonTree node={result.root} side="right" activePath={activePath} />
</Box> </div>
</Stack> </div>
); );
} }
return ( return (
<Box sx={jsonDiffPageStyles.TREE_CONTAINER}> <div className="p-3 rounded-lg bg-white border border-gray-200 font-mono text-sm overflow-x-auto min-h-[200px] max-h-[480px] overflow-y-auto">
<UnifiedView node={result.root} depth={0} activePath={activePath} /> <UnifiedView node={result.root} depth={0} activePath={activePath} />
</Box> </div>
); );
} }
const SectionLabel = ({ text }: { text: string }) => ( const SectionLabel = ({ text }: { text: string }) => (
<Typography <span className="block mb-1.5 text-[11px] font-extrabold tracking-wider text-gray-500 uppercase">
variant="caption"
sx={{
display: 'block',
mb: 0.6,
fontWeight: 800,
fontSize: '0.7rem',
letterSpacing: 0.4,
color: 'text.secondary',
textTransform: 'uppercase',
}}
>
{text} {text}
</Typography> </span>
); );
const formatPrimitive = (v: unknown): string => { const formatPrimitive = (v: unknown): string => {
@@ -71,17 +57,17 @@ const prefixForType = (type: DiffType): string => {
return ' '; return ' ';
}; };
const colorForType = (type: DiffType): string | undefined => { const colorForType = (type: DiffType): string => {
if (type === 'added') return jsonDiffPageStyles.addedText; if (type === 'added') return 'text-green-600';
if (type === 'removed') return jsonDiffPageStyles.removedText; if (type === 'removed') return 'text-red-600';
if (type === 'modified') return jsonDiffPageStyles.modifiedText; if (type === 'modified') return 'text-amber-600';
return undefined; return 'text-gray-900';
}; };
const bgForType = (type: DiffType, theme: Theme): string | undefined => { const bgForType = (type: DiffType): string | undefined => {
if (type === 'added') return surfaceTint(theme, theme.palette.success.main, 0.15); if (type === 'added') return 'bg-green-50';
if (type === 'removed') return surfaceTint(theme, theme.palette.error.main, 0.15); if (type === 'removed') return 'bg-red-50';
if (type === 'modified') return surfaceTint(theme, theme.palette.warning.main, 0.15); if (type === 'modified') return 'bg-amber-50';
return undefined; return undefined;
}; };
@@ -177,29 +163,18 @@ interface UnifiedRowProps {
} }
const UnifiedRow = ({ depth, type, text, active, multiline }: UnifiedRowProps) => { const UnifiedRow = ({ depth, type, text, active, multiline }: UnifiedRowProps) => {
const theme = useTheme();
const color = colorForType(type); const color = colorForType(type);
const bg = bgForType(type, theme); const bg = bgForType(type);
return ( return (
<Box <div
sx={{ className={`${bg ?? ''} ${color} ${active ? 'ring-2 ring-blue-500 rounded' : ''} ${
pl: depth * 1.5, multiline ? 'whitespace-pre' : 'whitespace-nowrap'
pr: 1, } font-mono`}
py: 0.2, style={{ paddingLeft: `${depth * 1.5}rem`, paddingRight: '0.25rem', paddingBlock: '0.2rem' }}
bgcolor: bg,
color: color ?? 'text.primary',
outline: active ? '2px solid' : 'none',
outlineColor: 'primary.main',
borderRadius: 0.5,
whiteSpace: multiline ? 'pre' : 'nowrap',
fontFamily: 'monospace',
}}
> >
<Box component="span" sx={{ fontWeight: 800 }}> <span className="font-extrabold">{prefixForType(type)}</span>
{prefixForType(type)} <span>{text}</span>
</Box> </div>
<Box component="span">{text}</Box>
</Box>
); );
}; };
+28 -81
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Box, Button, Stack, Typography } from '@mui/material'; import { Button } from '@/components/ui/button';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { formatByteSize } from '@/utils/textStatistics'; import { formatByteSize } from '@/utils/textStatistics';
import { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar } from '@/components/GlobalSnackbar';
@@ -88,29 +88,24 @@ export default function JsonConvertSection({
}; };
return ( return (
<Stack spacing={2.5}> <div className="flex flex-col gap-6">
{/* 工具栏 */} {/* 工具栏 */}
<Stack <div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center">
direction={{ xs: 'column', sm: 'row' }} <div />
spacing={1.5} <div className="flex gap-2">
justifyContent="space-between" <Button variant="outline" onClick={handleClear} className="rounded-lg">
alignItems={{ xs: 'stretch', sm: 'center' }}
>
<Box />
<Stack direction="row" spacing={1}>
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}>
{t('jsonFormat:clearButton')} {t('jsonFormat:clearButton')}
</Button> </Button>
<Button <Button
variant="contained" variant="default"
disabled={!canConvert} disabled={!canConvert}
onClick={handleConvert} onClick={handleConvert}
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }} className="rounded-lg font-bold px-4"
> >
{t(`jsonFormat:${convertButtonKey}`)} {t(`jsonFormat:${convertButtonKey}`)}
</Button> </Button>
</Stack> </div>
</Stack> </div>
{/* 输入区 */} {/* 输入区 */}
<TextInputArea <TextInputArea
@@ -125,81 +120,33 @@ export default function JsonConvertSection({
{/* 转换结果 */} {/* 转换结果 */}
{result && result.output ? ( {result && result.output ? (
<Box <div className="relative rounded-lg bg-white border border-gray-200 overflow-hidden">
sx={{
position: 'relative',
borderRadius: 3,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
{/* 结果头部 */} {/* 结果头部 */}
<Stack <div className="flex justify-between items-center px-4 py-2 border-b border-gray-200 bg-gray-50">
direction="row" <div className="flex gap-4 items-center">
justifyContent="space-between" <span className="text-[11px] font-extrabold text-gray-500">
alignItems="center"
sx={{
px: 2,
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
}}
>
<Stack direction="row" spacing={2} alignItems="center">
<Typography
variant="caption"
sx={{ fontWeight: 800, color: 'text.secondary', fontSize: '0.7rem' }}
>
{t(`jsonFormat:${pk}OutputLabel`)} {t(`jsonFormat:${pk}OutputLabel`)}
</Typography> </span>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}> <span className="text-[10px] text-gray-400">
{t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)} {t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)}
</Typography> </span>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}> <span className="text-[10px] text-gray-400">
{t('jsonFormat:formattedSize')}: {formatByteSize(result.outputBytes)} {t('jsonFormat:formattedSize')}: {formatByteSize(result.outputBytes)}
</Typography> </span>
</Stack> </div>
<CopyButton text={result.output} showMessage={showMessage} /> <CopyButton text={result.output} showMessage={showMessage} />
</Stack> </div>
{/* 转换内容 */} {/* 转换内容 */}
<Box <div className="p-4 font-mono text-sm whitespace-pre-wrap break-all max-h-[400px] overflow-y-auto leading-relaxed">
sx={{
p: 2,
fontFamily: 'monospace',
fontSize: '0.8rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: 400,
overflowY: 'auto',
lineHeight: 1.6,
}}
>
{result.output} {result.output}
</Box> </div>
</Box> </div>
) : ( ) : (
<Box <div className="p-4 rounded-lg bg-gray-50 border border-dashed border-gray-300 text-center">
sx={{ <p className="text-sm font-semibold text-gray-500">{t(`jsonFormat:${pk}EmptyHint`)}</p>
p: 3, </div>
borderRadius: 3,
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
border: '1px dashed',
borderColor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'grey.300',
textAlign: 'center',
}}
>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
{t(`jsonFormat:${pk}EmptyHint`)}
</Typography>
</Box>
)} )}
</Stack> </div>
); );
} }
+4 -16
View File
@@ -1,4 +1,3 @@
import { Box, Typography } from '@mui/material';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
interface JsonDiffInputProps { interface JsonDiffInputProps {
@@ -17,21 +16,10 @@ export default function JsonDiffInput({
error, error,
}: JsonDiffInputProps) { }: JsonDiffInputProps) {
return ( return (
<Box sx={{ flex: 1, minWidth: 0 }}> <div className="flex-1 min-w-0">
<Typography <span className="block mb-1.5 text-[11px] font-extrabold tracking-wider text-gray-500 uppercase">
variant="caption"
sx={{
display: 'block',
mb: 0.6,
fontWeight: 800,
fontSize: '0.7rem',
letterSpacing: 0.4,
color: 'text.secondary',
textTransform: 'uppercase',
}}
>
{label} {label}
</Typography> </span>
<TextInputArea <TextInputArea
value={value} value={value}
onChange={onChange} onChange={onChange}
@@ -41,7 +29,7 @@ export default function JsonDiffInput({
externalError={error ?? undefined} externalError={error ?? undefined}
showClear={true} showClear={true}
/> />
</Box> </div>
); );
} }
+46 -119
View File
@@ -1,14 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import { Button } from '@/components/ui/button';
Box,
Button,
FormControlLabel,
FormHelperText,
Stack,
Switch,
TextField,
Typography,
} from '@mui/material';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import { import {
formatJson, formatJson,
@@ -18,7 +9,6 @@ import {
} from '@/utils/jsonFormatter'; } from '@/utils/jsonFormatter';
import { formatByteSize } from '@/utils/textStatistics'; import { formatByteSize } from '@/utils/textStatistics';
import { useSnackbar } from '@/components/GlobalSnackbar'; import { useSnackbar } from '@/components/GlobalSnackbar';
import { jsonDiffPageStyles } from '@/config/pageTheme';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
@@ -78,22 +68,14 @@ export default function JsonFormatSection() {
}; };
return ( return (
<Stack spacing={2.5}> <div className="flex flex-col gap-6">
{/* 工具栏 */} {/* 工具栏 */}
<Stack <div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center">
direction={{ xs: 'column', sm: 'row' }} <div className="flex gap-3 items-center">
spacing={1.5}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'center' }}
>
<Stack direction="row" spacing={1.5} alignItems="center">
{/* 缩进选择 */} {/* 缩进选择 */}
<Typography <span className="text-[11px] font-extrabold text-gray-500">
variant="caption"
sx={{ fontWeight: 800, color: 'text.secondary', fontSize: '0.7rem' }}
>
{t('jsonFormat:indentSize')} {t('jsonFormat:indentSize')}
</Typography> </span>
<SwitchButtonGroup <SwitchButtonGroup
value={indentSize} value={indentSize}
onChange={(v) => setIndentSize(v)} onChange={(v) => setIndentSize(v)}
@@ -103,134 +85,79 @@ export default function JsonFormatSection() {
/> />
{/* 键名排序开关 */} {/* 键名排序开关 */}
<FormControlLabel <label className="flex items-center gap-2 ml-2">
control={ <input
<Switch type="checkbox"
size="small"
checked={sortKeys} checked={sortKeys}
onChange={(e) => setSortKeys(e.target.checked)} onChange={(e) => setSortKeys(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-500 focus:ring-blue-500"
/> />
} <span className="text-xs font-bold">{t('jsonFormat:sortKeys')}</span>
label={ </label>
<Typography variant="caption" sx={{ fontWeight: 700, fontSize: '0.7rem' }}> </div>
{t('jsonFormat:sortKeys')}
</Typography>
}
sx={{ ml: 1 }}
/>
</Stack>
<Stack direction="row" spacing={1}> <div className="flex gap-2">
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}> <Button variant="outline" onClick={handleClear} className="rounded-lg">
{t('jsonFormat:clearButton')} {t('jsonFormat:clearButton')}
</Button> </Button>
<Button <Button
variant="contained" variant="default"
disabled={!canFormat} disabled={!canFormat}
onClick={handleFormat} onClick={handleFormat}
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }} className="rounded-lg font-bold px-4"
> >
{t('jsonFormat:formatButton')} {t('jsonFormat:formatButton')}
</Button> </Button>
</Stack> </div>
</Stack> </div>
{/* 输入区 */} {/* 输入区 */}
<Box> <div>
<TextField <textarea
multiline
rows={6}
fullWidth
placeholder={t('jsonFormat:inputPlaceholder')} placeholder={t('jsonFormat:inputPlaceholder')}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
error={Boolean(error)} rows={6}
sx={jsonDiffPageStyles.INPUT_STYLE} className={`w-full rounded-lg border ${
error ? 'border-red-300' : 'border-gray-200'
} p-3 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y`}
/> />
{error && ( {error && (
<FormHelperText error sx={{ mx: 1.5, mt: 0.5, fontWeight: 600 }}> <p className="mx-3 mt-1 text-xs font-semibold text-red-500">
{t('jsonFormat:invalidJson')} {t('jsonFormat:invalidJson')}
</FormHelperText> </p>
)} )}
</Box> </div>
{/* 格式化结果 */} {/* 格式化结果 */}
{result && result.formatted ? ( {result && result.formatted ? (
<Box <div className="relative rounded-lg bg-white border border-gray-200 overflow-hidden">
sx={{
position: 'relative',
borderRadius: 3,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
{/* 结果头部 */} {/* 结果头部 */}
<Stack <div className="flex justify-between items-center px-4 py-2 border-b border-gray-200 bg-gray-50">
direction="row" <div className="flex gap-4 items-center">
justifyContent="space-between" <span className="text-[11px] font-extrabold text-gray-500">
alignItems="center"
sx={{
px: 2,
py: 1,
borderBottom: '1px solid',
borderColor: 'divider',
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
}}
>
<Stack direction="row" spacing={2} alignItems="center">
<Typography
variant="caption"
sx={{ fontWeight: 800, color: 'text.secondary', fontSize: '0.7rem' }}
>
{t('jsonFormat:outputLabel')} {t('jsonFormat:outputLabel')}
</Typography> </span>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}> <span className="text-[10px] text-gray-400">
{t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)} {t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)}
</Typography> </span>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}> <span className="text-[10px] text-gray-400">
{t('jsonFormat:formattedSize')}: {formatByteSize(result.formattedBytes)} {t('jsonFormat:formattedSize')}: {formatByteSize(result.formattedBytes)}
</Typography> </span>
</Stack> </div>
<CopyButton text={result.formatted} showMessage={showMessage} /> <CopyButton text={result.formatted} showMessage={showMessage} />
</Stack> </div>
{/* 格式化内容 */} {/* 格式化内容 */}
<Box <div className="p-4 font-mono text-sm whitespace-pre-wrap break-all max-h-[400px] overflow-y-auto leading-relaxed">
sx={{
p: 2,
fontFamily: 'monospace',
fontSize: '0.8rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: 400,
overflowY: 'auto',
lineHeight: 1.6,
}}
>
{result.formatted} {result.formatted}
</Box> </div>
</Box> </div>
) : ( ) : (
<Box <div className="p-4 rounded-lg bg-gray-50 border border-dashed border-gray-300 text-center">
sx={{ <p className="text-sm font-semibold text-gray-500">{t('jsonFormat:emptyHint')}</p>
p: 3, </div>
borderRadius: 3,
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
border: '1px dashed',
borderColor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'grey.300',
textAlign: 'center',
}}
>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
{t('jsonFormat:emptyHint')}
</Typography>
</Box>
)} )}
</Stack> </div>
); );
} }
+49 -99
View File
@@ -1,7 +1,4 @@
import { Box, Collapse, useTheme } from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { surfaceTint } from '@/config/pageTheme';
import type { DiffNode, DiffType } from './types'; import type { DiffNode, DiffType } from './types';
export type TreeSide = 'left' | 'right'; export type TreeSide = 'left' | 'right';
@@ -43,19 +40,19 @@ const getValueForSide = (node: DiffNode, side: TreeSide): unknown => {
return side === 'left' ? node.oldValue : node.newValue; return side === 'left' ? node.oldValue : node.newValue;
}; };
const getRowBg = (type: DiffType, side: TreeSide, theme: Theme): string | undefined => { const getRowBg = (type: DiffType, side: TreeSide): string | undefined => {
if (!shouldRenderOnSide(type, side)) return undefined; if (!shouldRenderOnSide(type, side)) return undefined;
if (type === 'added') return surfaceTint(theme, theme.palette.success.main, 0.15); if (type === 'added') return 'bg-green-50';
if (type === 'removed') return surfaceTint(theme, theme.palette.error.main, 0.15); if (type === 'removed') return 'bg-red-50';
if (type === 'modified') return surfaceTint(theme, theme.palette.warning.main, 0.15); if (type === 'modified') return 'bg-amber-50';
return undefined; return undefined;
}; };
const getValueColor = (type: DiffType, side: TreeSide): string | undefined => { const getValueColor = (type: DiffType, side: TreeSide): string | undefined => {
if (!shouldRenderOnSide(type, side)) return undefined; if (!shouldRenderOnSide(type, side)) return undefined;
if (type === 'added') return 'success.main'; if (type === 'added') return 'text-green-600';
if (type === 'removed') return 'error.main'; if (type === 'removed') return 'text-red-600';
if (type === 'modified') return 'warning.main'; if (type === 'modified') return 'text-amber-600';
return undefined; return undefined;
}; };
@@ -74,7 +71,6 @@ const NodeRow = ({
// 'auto' = follow defaults + activePath; otherwise user explicitly toggled // 'auto' = follow defaults + activePath; otherwise user explicitly toggled
const [override, setOverride] = useState<'auto' | 'open' | 'closed'>('auto'); const [override, setOverride] = useState<'auto' | 'open' | 'closed'>('auto');
const rowRef = useRef<HTMLDivElement | null>(null); const rowRef = useRef<HTMLDivElement | null>(null);
const theme = useTheme();
const onActivePath = Boolean( const onActivePath = Boolean(
activePath && activePath &&
@@ -99,13 +95,17 @@ const NodeRow = ({
if (!shouldRenderOnSide(node.type, side)) { if (!shouldRenderOnSide(node.type, side)) {
// 渲染占位空行以保持左右两侧高度一致 // 渲染占位空行以保持左右两侧高度一致
return <Box sx={{ pl: depth * 1.5, color: 'transparent', userSelect: 'none' }}>·</Box>; return (
<div className="text-transparent select-none" style={{ paddingLeft: `${depth * 1.5}rem` }}>
·
</div>
);
} }
const value = getValueForSide(node, side); const value = getValueForSide(node, side);
const isContainer = isContainerValue(value) && Array.isArray(node.children); const isContainer = isContainerValue(value) && Array.isArray(node.children);
const isArray = Array.isArray(value); const isArray = Array.isArray(value);
const bg = getRowBg(node.type, side, theme); const bg = getRowBg(node.type, side);
const valueColor = getValueColor(node.type, side); const valueColor = getValueColor(node.type, side);
const isActive = activePath === node.path; const isActive = activePath === node.path;
@@ -116,50 +116,29 @@ const NodeRow = ({
const open = isArray ? '[' : '{'; const open = isArray ? '[' : '{';
const close = isArray ? ']' : '}'; const close = isArray ? ']' : '}';
return ( return (
<Box ref={rowRef}> <div ref={rowRef}>
<Box <div
onClick={() => setOverride(expanded ? 'closed' : 'open')} onClick={() => setOverride(expanded ? 'closed' : 'open')}
sx={{ className={`cursor-pointer pr-1 py-0.5 ${bg ?? ''} ${
cursor: 'pointer', isActive ? 'ring-2 ring-blue-500 rounded' : ''
pl: depth * 1.5, } flex items-center gap-1 whitespace-nowrap hover:${bg ? 'bg-opacity-80' : 'bg-gray-50'}`}
pr: 1, style={{ paddingLeft: `${depth * 1.5}rem` }}
py: 0.2,
bgcolor: bg,
outline: isActive ? '2px solid' : 'none',
outlineColor: 'primary.main',
borderRadius: 0.5,
display: 'flex',
alignItems: 'center',
gap: 0.5,
whiteSpace: 'nowrap',
'&:hover': { bgcolor: bg ?? 'action.hover' },
}}
> >
<Box component="span" sx={{ width: 12, color: 'text.secondary', fontSize: '0.7rem' }}> <span className="w-3 text-gray-500 text-[11px]">{expanded ? '▾' : '▸'}</span>
{expanded ? '▾' : '▸'}
</Box>
{!isRoot && ( {!isRoot && (
<Box component="span" sx={{ color: 'text.primary', fontWeight: 700 }}> <span className="text-gray-900 font-bold">{isArrayKeyDisplay(node.key)}:</span>
{isArrayKeyDisplay(node.key)}:
</Box>
)} )}
<Box component="span" sx={{ color: 'text.secondary' }}> <span className="text-gray-500">{open}</span>
{open} {!expanded && <span className="text-gray-400 italic">{summarize(value)}</span>}
</Box>
{!expanded && ( {!expanded && (
<Box component="span" sx={{ color: 'text.disabled', fontStyle: 'italic' }}> <span className="text-gray-500">
{summarize(value)}
</Box>
)}
{!expanded && (
<Box component="span" sx={{ color: 'text.secondary' }}>
{close} {close}
{isLastChild ? '' : ','} {isLastChild ? '' : ','}
</Box> </span>
)} )}
</Box> </div>
<Collapse in={expanded} unmountOnExit> {expanded && (
<Box> <div>
{node.children.map((child, idx) => ( {node.children.map((child, idx) => (
<NodeRow <NodeRow
key={child.path} key={child.path}
@@ -171,52 +150,37 @@ const NodeRow = ({
isLastChild={idx === node.children!.length - 1} isLastChild={idx === node.children!.length - 1}
/> />
))} ))}
</Box> </div>
<Box )}
sx={{ {expanded && (
pl: depth * 1.5, <div
color: 'text.secondary', className="text-gray-500 whitespace-nowrap"
whiteSpace: 'nowrap', style={{ paddingLeft: `${depth * 1.5 + 1.0625}rem` }}
ml: '17px',
}}
> >
{close} {close}
{isLastChild ? '' : ','} {isLastChild ? '' : ','}
</Box> </div>
</Collapse> )}
</Box> </div>
); );
} }
// 叶子节点 // 叶子节点
return ( return (
<Box <div
ref={rowRef} ref={rowRef}
sx={{ className={`pr-1 py-0.5 ${bg ?? ''} ${
pl: depth * 1.5, isActive ? 'ring-2 ring-blue-500 rounded' : ''
pr: 1, } flex items-center gap-1 whitespace-nowrap`}
py: 0.2, style={{ paddingLeft: `${depth * 1.5}rem` }}
bgcolor: bg,
outline: isActive ? '2px solid' : 'none',
outlineColor: 'primary.main',
borderRadius: 0.5,
display: 'flex',
alignItems: 'center',
gap: 0.5,
whiteSpace: 'nowrap',
}}
> >
<Box component="span" sx={{ width: 12 }} /> <span className="w-3" />
{!isRoot && ( {!isRoot && <span className="text-gray-900 font-bold">{isArrayKeyDisplay(node.key)}:</span>}
<Box component="span" sx={{ color: 'text.primary', fontWeight: 700 }}> <span className={valueColor ?? 'text-gray-900'}>
{isArrayKeyDisplay(node.key)}:
</Box>
)}
<Box component="span" sx={{ color: valueColor ?? 'text.primary' }}>
{formatPrimitive(value)} {formatPrimitive(value)}
{isLastChild ? '' : ','} {isLastChild ? '' : ','}
</Box> </span>
</Box> </div>
); );
}; };
@@ -242,21 +206,7 @@ export default function JsonTree({
}: JsonTreeProps) { }: JsonTreeProps) {
const sideKey = useMemo(() => side, [side]); const sideKey = useMemo(() => side, [side]);
return ( return (
<Box <div className="p-3 rounded-lg bg-white border border-gray-200 font-mono text-sm overflow-x-auto min-h-[200px] max-h-[480px] overflow-y-auto">
sx={{
p: 1.5,
borderRadius: 3,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
fontFamily: 'monospace',
fontSize: '0.8rem',
overflowX: 'auto',
minHeight: 200,
maxHeight: 480,
overflowY: 'auto',
}}
>
<NodeRow <NodeRow
node={node} node={node}
side={sideKey} side={sideKey}
@@ -265,7 +215,7 @@ export default function JsonTree({
activePath={activePath} activePath={activePath}
isLastChild isLastChild
/> />
</Box> </div>
); );
} }
+29 -51
View File
@@ -1,12 +1,8 @@
import { useEffect, useMemo, useState, useCallback } from 'react'; import { useEffect, useMemo, useState, useCallback } from 'react';
import { Box, Button, Container, Stack, Typography } from '@mui/material'; import { Button } from '@/components/ui/button';
import CompareArrowsIcon from '@mui/icons-material/CompareArrows'; import { GitCompareArrows, Braces, ArrowRightLeft, Minimize2 } from 'lucide-react';
import DataObjectIcon from '@mui/icons-material/DataObject';
import TransformIcon from '@mui/icons-material/Transform';
import CompressIcon from '@mui/icons-material/Compress';
import { useLazyTranslation } from '@/utils/useLazyTranslation'; import { useLazyTranslation } from '@/utils/useLazyTranslation';
import PageHeader from '@/components/PageHeader'; import PageHeader from '@/components/PageHeader';
import { jsonDiffPageStyles } from '@/config/pageTheme';
import JsonDiffInput from './JsonDiffInput'; import JsonDiffInput from './JsonDiffInput';
import DiffResult from './DiffResult'; import DiffResult from './DiffResult';
import DiffNavigator from './DiffNavigator'; import DiffNavigator from './DiffNavigator';
@@ -20,7 +16,7 @@ import { jsonToToml } from '@/utils/jsonToToml';
import { minifyJson } from '@/utils/jsonFormatter'; import { minifyJson } from '@/utils/jsonFormatter';
import { useStorageState } from '@/utils/useStorageState'; import { useStorageState } from '@/utils/useStorageState';
import type { JsonToolsPageMode } from '@/types/storage'; import type { JsonToolsPageMode } from '@/types/storage';
import SwtichButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
interface ParseState { interface ParseState {
value: unknown; value: unknown;
@@ -118,11 +114,11 @@ export default function Index() {
}; };
const modeIcon: Record<PageMode, React.ReactNode> = { const modeIcon: Record<PageMode, React.ReactNode> = {
diff: <CompareArrowsIcon />, diff: <GitCompareArrows className="h-5 w-5" />,
format: <DataObjectIcon />, format: <Braces className="h-5 w-5" />,
yaml: <TransformIcon />, yaml: <ArrowRightLeft className="h-5 w-5" />,
toml: <TransformIcon />, toml: <ArrowRightLeft className="h-5 w-5" />,
minify: <CompressIcon />, minify: <Minimize2 className="h-5 w-5" />,
}; };
const yamlConvert: ConvertFunction = useCallback((text: string) => { const yamlConvert: ConvertFunction = useCallback((text: string) => {
@@ -141,18 +137,18 @@ export default function Index() {
}, []); }, []);
return ( return (
<Box> <div>
<Container sx={{ p: 2 }}> <div className="p-2">
<PageHeader <PageHeader
title={t(modeTitles[pageMode].title)} title={t(modeTitles[pageMode].title)}
subtitle={t(modeTitles[pageMode].subtitle)} subtitle={t(modeTitles[pageMode].subtitle)}
icon={modeIcon[pageMode]} icon={modeIcon[pageMode]}
iconColor={jsonDiffPageStyles.primaryColor} iconColor="#3b82f6"
/> />
<Stack spacing={2.5}> <div className="flex flex-col gap-6">
{/* 页面模式切换器 */} {/* 页面模式切换器 */}
<SwtichButtonGroup <SwitchButtonGroup
value={pageMode} value={pageMode}
onChange={(v: PageMode) => setPageMode(v)} onChange={(v: PageMode) => setPageMode(v)}
options={[ options={[
@@ -168,13 +164,8 @@ export default function Index() {
{pageMode === 'diff' ? ( {pageMode === 'diff' ? (
<> <>
{/* 工具栏 */} {/* 工具栏 */}
<Stack <div className="flex flex-col sm:flex-row gap-3 justify-between items-stretch sm:items-center">
direction={{ xs: 'column', sm: 'row' }} <SwitchButtonGroup
spacing={1.5}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'center' }}
>
<SwtichButtonGroup
value={viewMode} value={viewMode}
onChange={(v: ViewMode) => setViewMode(v)} onChange={(v: ViewMode) => setViewMode(v)}
options={[ options={[
@@ -183,23 +174,23 @@ export default function Index() {
]} ]}
size="small" size="small"
/> />
<Stack direction="row" spacing={1}> <div className="flex gap-2">
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}> <Button variant="outline" onClick={handleClear} className="rounded-lg">
{t('jsonDiff:clearButton')} {t('jsonDiff:clearButton')}
</Button> </Button>
<Button <Button
variant="contained" variant="default"
disabled={!canCompare} disabled={!canCompare}
onClick={handleCompare} onClick={handleCompare}
sx={{ borderRadius: 3, fontWeight: 700, px: 3, whiteSpace: 'nowrap' }} className="rounded-lg font-bold px-4 whitespace-nowrap"
> >
{t('jsonDiff:compareButton')} {t('jsonDiff:compareButton')}
</Button> </Button>
</Stack> </div>
</Stack> </div>
{/* 输入区 */} {/* 输入区 */}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}> <div className="flex flex-col md:flex-row gap-4">
<JsonDiffInput <JsonDiffInput
label={t('jsonDiff:leftLabel')} label={t('jsonDiff:leftLabel')}
placeholder={t('jsonDiff:leftPlaceholder')} placeholder={t('jsonDiff:leftPlaceholder')}
@@ -214,7 +205,7 @@ export default function Index() {
onChange={setRightInput} onChange={setRightInput}
error={rightError} error={rightError}
/> />
</Stack> </div>
{/* 差异展示 */} {/* 差异展示 */}
{diffResult ? ( {diffResult ? (
@@ -228,22 +219,9 @@ export default function Index() {
<DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} /> <DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} />
</> </>
) : ( ) : (
<Box <div className="p-4 rounded-lg bg-gray-50 border border-dashed border-gray-300 text-center">
sx={{ <p className="text-sm font-semibold text-gray-500">{t('jsonDiff:emptyHint')}</p>
p: 3, </div>
borderRadius: 3,
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
border: '1px dashed',
borderColor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.15)' : 'grey.300',
textAlign: 'center',
}}
>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
{t('jsonDiff:emptyHint')}
</Typography>
</Box>
)} )}
</> </>
) : pageMode === 'format' ? ( ) : pageMode === 'format' ? (
@@ -259,8 +237,8 @@ export default function Index() {
convertButtonKey="minifyButton" convertButtonKey="minifyButton"
/> />
)} )}
</Stack> </div>
</Container> </div>
</Box> </div>
); );
} }