import { Box, Collapse, useTheme } from '@mui/material'; import type { Theme } from '@mui/material/styles'; import { useEffect, useMemo, useRef, useState } from 'react'; import { surfaceTint } from '@/config/pageTheme'; import type { DiffNode, DiffType } from './types'; export type TreeSide = 'left' | 'right'; interface JsonTreeProps { node: DiffNode; side: TreeSide; defaultExpandDepth?: number; activePath?: string; } interface NodeRowProps { node: DiffNode; side: TreeSide; depth: number; defaultExpandDepth: number; activePath?: string; isLastChild: boolean; } const formatPrimitive = (v: unknown): string => { if (v === null) return 'null'; if (typeof v === 'string') return JSON.stringify(v); if (typeof v === 'number' || typeof v === 'boolean') return String(v); return JSON.stringify(v); }; /** * 决定当前节点在指定一侧是否需要渲染。 * 例如:'added' 节点只在 right 侧出现,'removed' 节点只在 left 侧出现。 */ const shouldRenderOnSide = (type: DiffType, side: TreeSide): boolean => { if (type === 'added') return side === 'right'; if (type === 'removed') return side === 'left'; return true; }; const getValueForSide = (node: DiffNode, side: TreeSide): unknown => { return side === 'left' ? node.oldValue : node.newValue; }; const getRowBg = (type: DiffType, side: TreeSide, theme: Theme): string | undefined => { if (!shouldRenderOnSide(type, side)) return undefined; if (type === 'added') return surfaceTint(theme, theme.palette.success.main, 0.15); if (type === 'removed') return surfaceTint(theme, theme.palette.error.main, 0.15); if (type === 'modified') return surfaceTint(theme, theme.palette.warning.main, 0.15); return undefined; }; const getValueColor = (type: DiffType, side: TreeSide): string | undefined => { if (!shouldRenderOnSide(type, side)) return undefined; if (type === 'added') return 'success.main'; if (type === 'removed') return 'error.main'; if (type === 'modified') return 'warning.main'; return undefined; }; const isContainerValue = (v: unknown): boolean => { return (typeof v === 'object' && v !== null) || Array.isArray(v); }; const NodeRow = ({ node, side, depth, defaultExpandDepth, activePath, isLastChild, }: NodeRowProps) => { // 'auto' = follow defaults + activePath; otherwise user explicitly toggled const [override, setOverride] = useState<'auto' | 'open' | 'closed'>('auto'); const rowRef = useRef(null); const theme = useTheme(); const onActivePath = Boolean( activePath && (activePath === node.path || activePath.startsWith(`${node.path}.`) || activePath.startsWith(`${node.path}[`)), ); const expanded = override === 'open' ? true : override === 'closed' ? false : onActivePath || depth < defaultExpandDepth; // 当激活路径定位到本节点时滚动到视图中心(仅 DOM 副作用,不更新 state) useEffect(() => { if (activePath === node.path) { rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, [activePath, node.path]); if (!shouldRenderOnSide(node.type, side)) { // 渲染占位空行以保持左右两侧高度一致 return ·; } const value = getValueForSide(node, side); const isContainer = isContainerValue(value) && Array.isArray(node.children); const isArray = Array.isArray(value); const bg = getRowBg(node.type, side, theme); const valueColor = getValueColor(node.type, side); const isActive = activePath === node.path; // 根节点渲染 const isRoot = depth === 0; if (isContainer && node.children) { const open = isArray ? '[' : '{'; const close = isArray ? ']' : '}'; return ( setOverride(expanded ? 'closed' : 'open')} sx={{ cursor: 'pointer', pl: depth * 1.5, pr: 1, 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' }, }} > {expanded ? '▾' : '▸'} {!isRoot && ( {isArrayKeyDisplay(node.key)}: )} {open} {!expanded && ( {summarize(value)} )} {!expanded && ( {close} {isLastChild ? '' : ','} )} {node.children.map((child, idx) => ( ))} {close} {isLastChild ? '' : ','} ); } // 叶子节点 return ( {!isRoot && ( {isArrayKeyDisplay(node.key)}: )} {formatPrimitive(value)} {isLastChild ? '' : ','} ); }; const isArrayKeyDisplay = (key: string): string => { // 数组索引在父级渲染中已加方括号;这里仅显示对象键名 return key; }; const summarize = (v: unknown): string => { if (Array.isArray(v)) return ` ${v.length} ${v.length === 1 ? 'item' : 'items'} `; if (v && typeof v === 'object') { const n = Object.keys(v).length; return ` ${n} ${n === 1 ? 'key' : 'keys'} `; } return ''; }; export default function JsonTree({ node, side, defaultExpandDepth = 2, activePath, }: JsonTreeProps) { const sideKey = useMemo(() => side, [side]); return ( ); } export type { JsonTreeProps };