docs: 添加组件文档注释和类型导入
refactor: 统一使用 SnackbarOptions 类型
style: 优化导入语句顺序和格式
refactor: 简化假数据生成器中的faker导入和使用
feat(form-recognizer): 增强表单识别功能并优化UI交互
refactor: 移除未使用的组件文件
refactor(页面头部): 提取通用 PageHeader 组件
style(组件): 调整自动刷新开关和存储选项网格的样式
style(ui): 调整时间戳页面和结果视图的样式
ci(workflow): 移除Firefox测试以简化CI流程
refactor(qrcode): 重构二维码解析功能并提取为独立模块
style(页面样式): 统一页面头部图标颜色并优化表单样式
feat(formMapping): 添加表单映射功能
feat(表单映射): 添加配置导出功能及状态提示
feat(表单填充): 新增智能表单填充功能
ci: 精简触发CI的工作流分支
test: 更新路由测试以匹配新增的路由数量
test: 将测试文件中的描述和断言翻译为中文
test(PageHeader): 添加组件测试用例
refactor(theme): 重构主题样式并优化仪表盘卡片组件
refactor(Button): 统一按钮样式并移除重复样式定义
docs: 更新 README 文件中的目录结构说明
refactor: 优化代码类型声明和UI布局
refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑
fix: 修复依赖项缺失导致的潜在问题
refactor: 重构表单识别和消息处理逻辑
feat(侧边栏): 添加侧边栏状态变化通知功能
refactor: 优化高亮组件渲染逻辑并添加防抖处理
refactor: 统一组件导出方式为默认导出
refactor: 重构样式系统并迁移至 MUI 主题
docs: 更新项目文档以反映新增功能和技术栈细节
feat: 添加错误边界组件以捕获子组件错误
refactor(ui): 使用全局 snackbar 替换本地通知组件
refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件
refactor(Snackbar): 重构消息提示组件并优化样式
refactor: 重构路由和功能配置系统
refactor(GlobalSnackbar): 合并SnackbarProvider到GlobalSnackbar组件
feat(表单映射): 增强高亮器功能并优化元素定位
refactor: 移除表单映射相关功能
feat: 实现页面组件懒加载并优化加载状态显示
refactor: 移除未使用的数据生成和验证工具类
chore: 更新应用图标文件
refactor(qrcode): 替换二维码生成和解析库以减小体积
feat: 添加在标签页打开功能并优化错误处理
feat: 支持独立标签页模式并重构路由配置
feat(ui): 优化响应式布局和样式设计
refactor: 移除URL工具相关代码
refactor: 简化页面标题和存储选项网格的响应式布局
refactor: 优化样式和错误处理
refactor: 重构页面组件结构
style(DashboardPage): 优化仪表盘布局为响应式网格
refactor(ToolCard): 重构工具卡片组件并添加详细注释
feat: 新增文本统计功能
feat(jwt): 添加 JWT 解析工具功能
docs: 更新 README 以更清晰地描述项目功能和结构
docs: 更新AGENTS.md文档以反映项目最新状态
test: 更新特征计数测试以匹配新增工具
style: 统一导入语句顺序
refactor(pages): 重构工具页面组件结构并优化功能实现
feat(顶部栏): 添加搜索功能和历史记录
feat: 添加多语言支持(i18n)
feat(顶部栏): 添加语言切换功能并优化国际化支持
feat(i18n): 增加语言归一化和同步功能
fix(剪贴板): 使用国际化文本替换剪贴板消息
test(顶部栏): 更新测试以匹配国际化标题
refactor: 重构组件目录结构
refactor(Dashboard): 优化卡片组件结构并移除无用属性
refactor(timestamp): 合并输入处理函数并优化样式结构
refactor(dashboard): 重构仪表盘卡片组件并优化布局
refactor(storageCleaner): 重构样式代码并优化国际化处理
refactor(StorageCleaner): 优化条件判断和清理未使用的代码
refactor(QrCode): 重构二维码页面样式和组件结构
refactor(ErrorBoundary): 移除冗余状态更新并调整错误背景色
feat(TopBar): 优化顶部导航栏功能并添加样式配置
chore: 在.gitignore中添加docs目录
feat(JsonDiff): 添加差异引擎类型定义
feat: 新增 JSON 差异比较工具功能
feat(主题): 添加暗色模式支持并重构主题系统
This commit is contained in:
LingandRX
2026-05-09 00:18:29 +08:00
committed by GitHub
parent ff964e3608
commit 3c9c9e740e
43 changed files with 1812 additions and 388 deletions
+11 -7
View File
@@ -4,9 +4,10 @@
* 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、
* 快照内容展示,具备悬停动画效果。
*/
import { alpha, Box, Card, CardActionArea, Stack, Typography } from '@mui/material';
import { alpha, Box, Card, CardActionArea, Stack, Typography, useTheme } from '@mui/material';
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
import React from 'react';
import type { PaletteColorKey } from '@/config/features';
/**
* ToolCard 组件属性接口
@@ -18,8 +19,8 @@ interface ToolCardProps {
description?: string;
/** 快照内容,用于在卡片底部展示额外信息(可选) */
snapshot?: React.ReactNode;
/** 主题色代码,用于图标背景和悬停效果 */
colorCode: string;
/** 主题色键,映射到 theme.palette[key].main */
colorKey: PaletteColorKey;
/** 工具图标元素 */
icon: React.ReactNode;
/** 卡片点击事件处理函数 */
@@ -36,10 +37,13 @@ export default function ToolCard({
title,
description,
snapshot,
colorCode,
colorKey,
icon,
onClick,
}: ToolCardProps) {
const theme = useTheme();
const colorCode = theme.palette[colorKey].main;
return (
<Card
elevation={0}
@@ -47,7 +51,7 @@ export default function ToolCard({
position: 'relative',
borderRadius: 4,
border: '1px solid',
borderColor: 'grey.100',
borderColor: 'divider',
height: '100%',
boxSizing: 'border-box',
transition:
@@ -124,7 +128,7 @@ export default function ToolCard({
className="arrow-icon"
sx={{
fontSize: 12,
color: 'grey.300',
color: 'text.disabled',
mt: 0.5,
transition: 'all 0.3s ease',
}}
@@ -137,7 +141,7 @@ export default function ToolCard({
mt: 'auto',
pt: 1.5,
borderTop: '1px dashed',
borderColor: 'grey.100',
borderColor: 'divider',
width: '100%',
}}
>
+2 -2
View File
@@ -19,14 +19,14 @@ export default function DashboardPage() {
if (!visibleSet.has(key as PageType)) return null;
const feature = getFeatureByKey(key);
if (!feature?.themeColor || feature.icon == null) return null;
if (!feature?.themeColorKey || feature.icon == null) return null;
return (
<ToolCard
key={key}
title={t(feature.labelKey)}
description={t(feature.descriptionKey)}
colorCode={feature.themeColor}
colorKey={feature.themeColorKey}
icon={feature.icon}
onClick={() => navigateTo(key)}
/>
+46
View File
@@ -0,0 +1,46 @@
import { Box, IconButton, Typography } from '@mui/material';
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { useTranslation } from 'react-i18next';
import { jsonDiffPageStyles } from '@/config/pageTheme';
interface DiffNavigatorProps {
total: number;
/** 0-based index */
currentIndex: number;
onPrev: () => void;
onNext: () => void;
}
export default function DiffNavigator({ total, currentIndex, onPrev, onNext }: DiffNavigatorProps) {
const { t } = useTranslation(['jsonDiff']);
if (total === 0) {
return (
<Box sx={jsonDiffPageStyles.NAVIGATOR}>
<Typography variant="body2" sx={{ fontWeight: 700, color: 'text.secondary' }}>
{t('jsonDiff:noDiffs')}
</Typography>
</Box>
);
}
return (
<Box sx={jsonDiffPageStyles.NAVIGATOR}>
<IconButton size="small" aria-label={t('jsonDiff:previousDiff')} onClick={onPrev}>
<NavigateBeforeIcon />
</IconButton>
<Typography
variant="body2"
sx={{ fontWeight: 800, fontFamily: 'monospace', minWidth: 60, textAlign: 'center' }}
>
{currentIndex + 1} / {total}
</Typography>
<IconButton size="small" aria-label={t('jsonDiff:nextDiff')} onClick={onNext}>
<NavigateNextIcon />
</IconButton>
</Box>
);
}
export type { DiffNavigatorProps };
+220
View File
@@ -0,0 +1,220 @@
import { Box, Stack, Typography, useTheme } from '@mui/material';
import type { Theme } from '@mui/material/styles';
import { useTranslation } from 'react-i18next';
import { jsonDiffPageStyles, surfaceTint } from '@/config/pageTheme';
import JsonTree from './JsonTree';
import type { DiffNode, DiffResult as DiffResultType, DiffType, ViewMode } from './types';
interface DiffResultProps {
result: DiffResultType;
viewMode: ViewMode;
activePath?: string;
}
export default function DiffResult({ result, viewMode, activePath }: DiffResultProps) {
const { t } = useTranslation(['jsonDiff']);
if (viewMode === 'sideBySide') {
return (
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2} alignItems="stretch">
<Box sx={{ flex: 1, minWidth: 0 }}>
<SectionLabel text={t('jsonDiff:leftLabel')} />
<JsonTree node={result.root} side="left" activePath={activePath} />
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<SectionLabel text={t('jsonDiff:rightLabel')} />
<JsonTree node={result.root} side="right" activePath={activePath} />
</Box>
</Stack>
);
}
return (
<Box sx={jsonDiffPageStyles.TREE_CONTAINER}>
<UnifiedView node={result.root} depth={0} activePath={activePath} />
</Box>
);
}
const SectionLabel = ({ text }: { text: string }) => (
<Typography
variant="caption"
sx={{
display: 'block',
mb: 0.6,
fontWeight: 800,
fontSize: '0.7rem',
letterSpacing: 0.4,
color: 'text.secondary',
textTransform: 'uppercase',
}}
>
{text}
</Typography>
);
const formatPrimitive = (v: unknown): string => {
if (v === undefined) return 'undefined';
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);
};
const isContainerType = (v: unknown): boolean =>
(typeof v === 'object' && v !== null) || Array.isArray(v);
const prefixForType = (type: DiffType): string => {
if (type === 'added') return '+ ';
if (type === 'removed') return '- ';
if (type === 'modified') return '~ ';
return ' ';
};
const colorForType = (type: DiffType): string | undefined => {
if (type === 'added') return jsonDiffPageStyles.addedText;
if (type === 'removed') return jsonDiffPageStyles.removedText;
if (type === 'modified') return jsonDiffPageStyles.modifiedText;
return undefined;
};
const bgForType = (type: DiffType, theme: Theme): string | 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;
};
interface UnifiedViewProps {
node: DiffNode;
depth: number;
activePath?: string;
}
const UnifiedView = ({ node, depth, activePath }: UnifiedViewProps) => {
const isRoot = depth === 0;
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
const isContainer =
hasChildren || isContainerType(node.oldValue) || isContainerType(node.newValue);
const keyLabel = isRoot ? '' : `${node.key}: `;
if (!isContainer) {
// 叶子节点
if (node.type === 'modified') {
return (
<>
<UnifiedRow
depth={depth}
type="removed"
text={`${keyLabel}${formatPrimitive(node.oldValue)}`}
active={activePath === node.path}
/>
<UnifiedRow
depth={depth}
type="added"
text={`${keyLabel}${formatPrimitive(node.newValue)}`}
active={activePath === node.path}
/>
</>
);
}
const value = node.type === 'added' ? node.newValue : node.oldValue;
return (
<UnifiedRow
depth={depth}
type={node.type}
text={`${keyLabel}${formatPrimitive(value)}`}
active={activePath === node.path}
/>
);
}
// 容器节点:added/removed 整块呈现
if (node.type === 'added') {
return (
<UnifiedRow
depth={depth}
type="added"
text={`${keyLabel}${stringifyMultiline(node.newValue, depth)}`}
active={activePath === node.path}
multiline
/>
);
}
if (node.type === 'removed') {
return (
<UnifiedRow
depth={depth}
type="removed"
text={`${keyLabel}${stringifyMultiline(node.oldValue, depth)}`}
active={activePath === node.path}
multiline
/>
);
}
const isArr = Array.isArray(node.oldValue) || Array.isArray(node.newValue);
const open = isArr ? '[' : '{';
const close = isArr ? ']' : '}';
return (
<>
<UnifiedRow depth={depth} type="unchanged" text={`${keyLabel}${open}`} />
{node.children?.map((child) => (
<UnifiedView key={child.path} node={child} depth={depth + 1} activePath={activePath} />
))}
<UnifiedRow depth={depth} type="unchanged" text={close} />
</>
);
};
interface UnifiedRowProps {
depth: number;
type: DiffType;
text: string;
active?: boolean;
multiline?: boolean;
}
const UnifiedRow = ({ depth, type, text, active, multiline }: UnifiedRowProps) => {
const theme = useTheme();
const color = colorForType(type);
const bg = bgForType(type, theme);
return (
<Box
sx={{
pl: depth * 1.5,
pr: 1,
py: 0.2,
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 }}>
{prefixForType(type)}
</Box>
<Box component="span">{text}</Box>
</Box>
);
};
const stringifyMultiline = (v: unknown, depth: number): string => {
try {
const json = JSON.stringify(v, null, 2);
if (!json) return formatPrimitive(v);
const indent = ' '.repeat(depth);
return json
.split('\n')
.map((line, idx) => (idx === 0 ? line : indent + line))
.join('\n');
} catch {
return formatPrimitive(v);
}
};
export type { DiffResultProps };
+55
View File
@@ -0,0 +1,55 @@
import { Box, FormHelperText, TextField, Typography } from '@mui/material';
import { jsonDiffPageStyles } from '@/config/pageTheme';
interface JsonDiffInputProps {
label: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
error?: string | null;
}
export default function JsonDiffInput({
label,
placeholder,
value,
onChange,
error,
}: JsonDiffInputProps) {
return (
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="caption"
sx={{
display: 'block',
mb: 0.6,
fontWeight: 800,
fontSize: '0.7rem',
letterSpacing: 0.4,
color: 'text.secondary',
textTransform: 'uppercase',
}}
>
{label}
</Typography>
<TextField
multiline
rows={8}
fullWidth
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
error={Boolean(error)}
sx={jsonDiffPageStyles.INPUT_STYLE}
/>
{error && (
<FormHelperText error sx={{ mx: 1.5, mt: 0.5, fontWeight: 600 }}>
{error}
</FormHelperText>
)}
</Box>
);
}
export { JsonDiffInput };
export type { JsonDiffInputProps };
+272
View File
@@ -0,0 +1,272 @@
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<HTMLDivElement | null>(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 <Box sx={{ pl: depth * 1.5, color: 'transparent', userSelect: 'none' }}>·</Box>;
}
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 (
<Box ref={rowRef}>
<Box
onClick={() => 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' },
}}
>
<Box component="span" sx={{ width: 12, color: 'text.secondary', fontSize: '0.7rem' }}>
{expanded ? '▾' : '▸'}
</Box>
{!isRoot && (
<Box component="span" sx={{ color: 'text.primary', fontWeight: 700 }}>
{isArrayKeyDisplay(node.key)}:
</Box>
)}
<Box component="span" sx={{ color: 'text.secondary' }}>
{open}
</Box>
{!expanded && (
<Box component="span" sx={{ color: 'text.disabled', fontStyle: 'italic' }}>
{summarize(value)}
</Box>
)}
{!expanded && (
<Box component="span" sx={{ color: 'text.secondary' }}>
{close}
{isLastChild ? '' : ','}
</Box>
)}
</Box>
<Collapse in={expanded} unmountOnExit>
<Box>
{node.children.map((child, idx) => (
<NodeRow
key={child.path}
node={child}
side={side}
depth={depth + 1}
defaultExpandDepth={defaultExpandDepth}
activePath={activePath}
isLastChild={idx === node.children!.length - 1}
/>
))}
</Box>
<Box
sx={{
pl: depth * 1.5,
color: 'text.secondary',
whiteSpace: 'nowrap',
ml: '17px',
}}
>
{close}
{isLastChild ? '' : ','}
</Box>
</Collapse>
</Box>
);
}
// 叶子节点
return (
<Box
ref={rowRef}
sx={{
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',
}}
>
<Box component="span" sx={{ width: 12 }} />
{!isRoot && (
<Box component="span" sx={{ color: 'text.primary', fontWeight: 700 }}>
{isArrayKeyDisplay(node.key)}:
</Box>
)}
<Box component="span" sx={{ color: valueColor ?? 'text.primary' }}>
{formatPrimitive(value)}
{isLastChild ? '' : ','}
</Box>
</Box>
);
};
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 (
<Box
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
node={node}
side={sideKey}
depth={0}
defaultExpandDepth={defaultExpandDepth}
activePath={activePath}
isLastChild
/>
</Box>
);
}
export type { JsonTreeProps };
+154
View File
@@ -0,0 +1,154 @@
import type { DiffNode, DiffResult, DiffType } from './types';
const ROOT_PATH = '$';
const SENTINEL = Symbol('missing');
type MaybeMissing = unknown | typeof SENTINEL;
const isObject = (v: unknown): v is Record<string, unknown> =>
typeof v === 'object' && v !== null && !Array.isArray(v);
const isArray = (v: unknown): v is unknown[] => Array.isArray(v);
const buildPath = (parent: string, key: string, isArrayChild: boolean): string => {
if (parent === ROOT_PATH) {
return isArrayChild ? `${ROOT_PATH}[${key}]` : `${ROOT_PATH}.${key}`;
}
return isArrayChild ? `${parent}[${key}]` : `${parent}.${key}`;
};
const primitiveEqual = (a: unknown, b: unknown): boolean => {
// NaN handling: treat NaN === NaN as equal for diff purposes
if (typeof a === 'number' && typeof b === 'number' && Number.isNaN(a) && Number.isNaN(b)) {
return true;
}
return a === b;
};
const diffNode = (
left: MaybeMissing,
right: MaybeMissing,
key: string,
path: string,
diffPaths: string[],
): DiffNode => {
// Added: left missing, right present
if (left === SENTINEL && right !== SENTINEL) {
diffPaths.push(path);
return {
key,
type: 'added',
newValue: right,
path,
isLeaf: !isObject(right) && !isArray(right),
};
}
// Removed: right missing, left present
if (right === SENTINEL && left !== SENTINEL) {
diffPaths.push(path);
return {
key,
type: 'removed',
oldValue: left,
path,
isLeaf: !isObject(left) && !isArray(left),
};
}
const leftObj = isObject(left);
const rightObj = isObject(right);
const leftArr = isArray(left);
const rightArr = isArray(right);
// Both objects
if (leftObj && rightObj) {
const keys = Array.from(new Set([...Object.keys(left), ...Object.keys(right)]));
const children: DiffNode[] = keys.map((k) => {
const childPath = buildPath(path, k, false);
const l: MaybeMissing = k in left ? left[k] : SENTINEL;
const r: MaybeMissing = k in right ? right[k] : SENTINEL;
return diffNode(l, r, k, childPath, diffPaths);
});
const allUnchanged = children.every((c) => c.type === 'unchanged');
return {
key,
type: allUnchanged ? 'unchanged' : 'modified',
oldValue: left,
newValue: right,
children,
path,
isLeaf: false,
};
}
// Both arrays
if (leftArr && rightArr) {
const len = Math.max(left.length, right.length);
const children: DiffNode[] = [];
for (let i = 0; i < len; i++) {
const k = String(i);
const childPath = buildPath(path, k, true);
const l: MaybeMissing = i < left.length ? left[i] : SENTINEL;
const r: MaybeMissing = i < right.length ? right[i] : SENTINEL;
children.push(diffNode(l, r, k, childPath, diffPaths));
}
const allUnchanged = children.every((c) => c.type === 'unchanged');
return {
key,
type: allUnchanged ? 'unchanged' : 'modified',
oldValue: left,
newValue: right,
children,
path,
isLeaf: false,
};
}
// Type mismatch (object vs array, object vs primitive, array vs primitive, etc.)
// or both primitives
const leftIsContainer = leftObj || leftArr;
const rightIsContainer = rightObj || rightArr;
const sameKind =
!leftIsContainer && !rightIsContainer && typeof left === typeof right && left !== null
? primitiveEqual(left, right)
: left === null && right === null
? true
: false;
if (sameKind) {
return {
key,
type: 'unchanged',
oldValue: left,
newValue: right,
path,
isLeaf: true,
};
}
diffPaths.push(path);
return {
key,
type: 'modified',
oldValue: left,
newValue: right,
path,
isLeaf: !leftIsContainer && !rightIsContainer,
};
};
/**
* 比较两个 JSON 值的差异,返回差异树及差异路径列表。
*/
export const diffJson = (left: unknown, right: unknown): DiffResult => {
const diffPaths: string[] = [];
const root = diffNode(left, right, '', ROOT_PATH, diffPaths);
return {
root,
diffPaths,
diffCount: diffPaths.length,
};
};
export type { DiffNode, DiffResult, DiffType };
+196
View File
@@ -0,0 +1,196 @@
import { useEffect, useMemo, useState } from 'react';
import {
Box,
Button,
Container,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
import { useTranslation } from 'react-i18next';
import PageHeader from '@/components/PageHeader';
import { jsonDiffPageStyles } from '@/config/pageTheme';
import JsonDiffInput from './JsonDiffInput';
import DiffResult from './DiffResult';
import DiffNavigator from './DiffNavigator';
import { diffJson } from './diffEngine';
import type { DiffResult as DiffResultType, ViewMode } from './types';
interface ParseState {
value: unknown;
error: string | null;
}
const tryParse = (raw: string, invalidMsg: string): ParseState => {
const trimmed = raw.trim();
if (!trimmed) return { value: undefined, error: null };
try {
return { value: JSON.parse(trimmed), error: null };
} catch {
return { value: undefined, error: invalidMsg };
}
};
export default function Index() {
const { t } = useTranslation(['jsonDiff']);
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);
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;
}
const result = diffJson(left.value, right.value);
setDiffResult(result);
setCurrentDiffIndex(0);
};
const handleClear = () => {
setLeftInput('');
setRightInput('');
setLeftError(null);
setRightError(null);
setDiffResult(null);
setCurrentDiffIndex(0);
};
const total = diffResult?.diffPaths.length ?? 0;
const handlePrev = () => {
if (total === 0) return;
setCurrentDiffIndex((idx) => (idx - 1 + total) % total);
};
const handleNext = () => {
if (total === 0) return;
setCurrentDiffIndex((idx) => (idx + 1) % total);
};
const activePath = diffResult && total > 0 ? diffResult.diffPaths[currentDiffIndex] : undefined;
return (
<Box>
<Container sx={{ p: 2 }}>
<PageHeader
title={t('jsonDiff:pageTitle')}
subtitle={t('jsonDiff:pageSubtitle')}
icon={<CompareArrowsIcon />}
iconColor={jsonDiffPageStyles.primaryColor}
/>
<Stack spacing={2.5}>
{/* 工具栏 */}
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'center' }}
>
<ToggleButtonGroup
size="small"
exclusive
value={viewMode}
onChange={(_, v: ViewMode | null) => v && setViewMode(v)}
sx={{ borderRadius: 3 }}
>
<ToggleButton value="sideBySide" sx={{ px: 2, fontWeight: 700 }}>
{t('jsonDiff:sideBySideMode')}
</ToggleButton>
<ToggleButton value="unified" sx={{ px: 2, fontWeight: 700 }}>
{t('jsonDiff:unifiedMode')}
</ToggleButton>
</ToggleButtonGroup>
<Stack direction="row" spacing={1}>
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}>
{t('jsonDiff:clearButton')}
</Button>
<Button
variant="contained"
disabled={!canCompare}
onClick={handleCompare}
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }}
>
{t('jsonDiff:compareButton')}
</Button>
</Stack>
</Stack>
{/* 输入区 */}
<Stack direction={{ xs: 'column', md: 'row' }} spacing={2}>
<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}
/>
</Stack>
{/* 差异展示 */}
{diffResult ? (
<>
<DiffNavigator
total={total}
currentIndex={currentDiffIndex}
onPrev={handlePrev}
onNext={handleNext}
/>
<DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} />
</>
) : (
<Box
sx={{
p: 3,
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>
)}
</Stack>
</Container>
</Box>
);
}
+29
View File
@@ -0,0 +1,29 @@
export type DiffType = 'added' | 'removed' | 'modified' | 'unchanged';
export interface DiffNode {
/** 节点键名(数组项为索引字符串) */
key: string;
/** 差异类型 */
type: DiffType;
/** 左侧值 */
oldValue?: unknown;
/** 右侧值 */
newValue?: unknown;
/** 子节点(对象或数组时存在) */
children?: DiffNode[];
/** 完整路径,用于导航定位 */
path: string;
/** 是否为叶子节点(原始值) */
isLeaf: boolean;
}
export type ViewMode = 'sideBySide' | 'unified';
export interface DiffResult {
/** 根节点差异树 */
root: DiffNode;
/** 所有差异节点路径列表(用于导航) */
diffPaths: string[];
/** 差异总数 */
diffCount: number;
}
+1 -1
View File
@@ -149,7 +149,7 @@ export default function Index() {
>
<Typography
variant="subtitle2"
sx={{ fontWeight: 800, color: '#00b9f1', letterSpacing: 0.5 }}
sx={{ fontWeight: 800, color: 'info.main', letterSpacing: 0.5 }}
>
{t('jwt:signatureTitle')}
</Typography>
+1 -1
View File
@@ -223,7 +223,7 @@ const QrCodeToUrlSection = ({ expanded, onExpandedChange }: QrCodeToUrlSectionPr
</Box>
) : (
<>
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{t('qrCode:clickToUpload')}
</Typography>
+2 -2
View File
@@ -57,8 +57,8 @@ const UrlToQrCodeSection = ({ expanded, onExpandedChange }: UrlToQrCodeSectionPr
value: url,
size: 250,
level: 'H',
foreground: qrCodePageStyles.black,
background: qrCodePageStyles.white,
foreground: '#000000',
background: '#FFFFFF',
});
setQrCodeDataUrl(qr.toDataURL());
+1 -1
View File
@@ -49,7 +49,7 @@ export default function StorageOptionsGrid({
))}
</Grid>
</Box>
<Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
<Divider sx={{ mx: 0, borderColor: 'divider' }} />
<Box sx={storageCleanerPageStyles.OPTIONS_GRID_FOOTER}>
<Typography
variant="body2"
+6 -3
View File
@@ -50,13 +50,16 @@ export default function Index() {
mb: 3,
'& .MuiOutlinedInput-root': {
borderRadius: 4,
bgcolor: 'grey.50',
bgcolor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
transition: 'all 0.2s',
'& fieldset': {
borderColor: 'grey.200',
borderColor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'grey.200',
},
'&:hover fieldset': {
borderColor: 'grey.300',
borderColor: (theme) =>
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'grey.300',
},
'&.Mui-focused fieldset': {
borderColor: textStatisticsPageStyles.primaryColor,
+1 -1
View File
@@ -35,7 +35,7 @@ const ResultView = React.memo(({ result, mode, unit, zone }: ResultViewProps) =>
return (
<Fade in={!!result}>
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'divider' }}>
<Typography variant="caption" sx={timestampPageStyles.RESULT_LABEL}>
{t('timestamp:resultLabel')}
</Typography>