* docs: 添加组件文档注释和类型导入

refactor: 统一使用 SnackbarOptions 类型
style: 优化导入语句顺序和格式

* refactor: 简化假数据生成器中的faker导入和使用

Co-authored-by: Copilot <copilot@github.com>

* feat(form-recognizer): 增强表单识别功能并优化UI交互

- 新增字段类型偏好设置功能,支持按域名保存字段类型
- 重构FieldList组件,改进字段选择和类型修改体验
- 添加字段定位闪烁功能,便于在页面上快速找到对应字段
- 优化表单填充逻辑,支持单个字段覆盖默认填充模式
- 移除独立的侧边栏页面,统一使用主页面组件
- 改进useStorageState钩子,增加加载状态管理和防抖处理

* refactor: 移除未使用的组件文件

* refactor(页面头部): 提取通用 PageHeader 组件并替换各页面头部实现

重构各页面头部为统一的 PageHeader 组件,提高代码复用性和维护性

* style(组件): 调整自动刷新开关和存储选项网格的样式

优化自动刷新开关的文本内边距,重构存储选项网格的布局结构,调整间距和边框样式

* style(ui): 调整时间戳页面和结果视图的样式

- 为时区选择器添加圆角
- 优化结果视图的布局和对齐方式
- 调整结果项的内边距和文本样式

* ci(workflow): 移除Firefox测试以简化CI流程

仅保留Chrome浏览器的构建步骤,减少CI运行时间和资源消耗

* refactor(qrcode): 重构二维码解析功能并提取为独立模块

* style(页面样式): 统一页面头部图标颜色并优化表单样式

更新各页面头部组件的图标颜色配置,移除冗余的表单标签属性,优化输入框样式和按钮悬停效果

* feat(formMapping): 添加表单映射功能

- 创建 FormMappingPage 页面组件
- 实现表单扫描器 scanner.ts
- 实现高亮器 highlighter.ts
- 更新路由配置 routes.ts
- 更新内容脚本 content.ts
- 更新类型定义 storage.d.ts
- 添加 formMappingPageStyles 样式配置

* feat(表单映射): 添加配置导出功能及状态提示

添加配置导出为JSON文件的功能,包含导出按钮和错误处理。新增Snackbar组件用于显示导出成功和错误状态。优化页面布局结构,将状态提示移至全局容器外。

* feat(表单填充): 新增智能表单填充功能

添加智能表单填充功能,包括:
1. 新增表单填充页面和路由配置
2. 实现模糊匹配引擎和智能注入引擎
3. 添加Mock数据生成器和视觉反馈渲染器
4. 扩展消息接口支持填充操作

* ci: 精简触发CI的工作流分支

移除对develop分支及其变体的触发,仅保留main分支的触发

* test: 更新路由测试以匹配新增的路由数量

* test: 将测试文件中的描述和断言翻译为中文

* test(PageHeader): 添加组件测试用例验证渲染逻辑和样式

* refactor(theme): 重构主题样式并优化仪表盘卡片组件

将页面样式配置集中管理,移除各页面中硬编码的背景色
新增 DashboardCard 组件封装通用卡片逻辑
添加 dashboardCards 配置文件统一管理卡片数据

* refactor(Button): 统一按钮样式并移除重复样式定义

将按钮样式统一封装到 Button 组件中,移除各页面重复的样式定义
更新组件文档说明,提供更清晰的用法示例

* docs: 更新 README 文件中的目录结构说明

添加新组件和配置文件的说明,保持文档与代码同步

* refactor: 优化代码类型声明和UI布局

修复类型声明从any改为never以提高类型安全性
调整TimestampPage页面布局间距
移除DashboardPage中不必要的实时时钟状态
更新ESLint配置以使用推荐配置
添加选项页面打开的错误处理

* refactor(消息通信): 使用 @webext-core/messaging 重构消息处理逻辑

将原有的 chrome.runtime.onMessage 和 chrome.tabs.sendMessage 替换为类型安全的 @webext-core/messaging 实现
添加 ProtocolMap 类型定义确保消息类型安全
更新相关页面和后台脚本使用新的消息通信方式

* fix: 修复依赖项缺失导致的潜在问题

修复 useStorageState 中缺少 defaultValue 依赖的问题
重构 RouterProvider 的初始化逻辑,使用 useCallback 优化性能

* refactor: 重构表单识别和消息处理逻辑

将表单映射UI逻辑提取到独立文件
将消息处理器提取到独立文件
将表单识别页面逻辑提取到自定义hook
优化代码结构和可维护性

* feat(侧边栏): 添加侧边栏状态变化通知功能

- 在消息类型中新增侧边栏状态变化枚举和字段
- 侧边栏打开和关闭时发送状态通知
- 替换轮询检查方式为消息监听机制

* refactor: 优化高亮组件渲染逻辑并添加防抖处理

重构高亮组件渲染逻辑,使用 requestAnimationFrame 进行节流优化
在 useStorageCleaner 中添加防抖处理以避免频繁加载

* refactor: 统一组件导出方式为默认导出

* refactor: 重构样式系统并迁移至 MUI 主题

删除冗余的 CSS 文件,统一使用 MUI 主题管理样式
新增 useActiveTabDomain 和 useSidePanelState 自定义 Hook
优化各入口点的主题集成和布局处理

* docs: 更新项目文档以反映新增功能和技术栈细节

更新 AGENTS.md 文档,详细描述新增的功能模块(存储管理、URL 管理、二维码生成、表单工具套件等)和更新的技术栈信息

* feat: 添加错误边界组件以捕获子组件错误

在 popup、sidepanel 和 options 入口点添加 ErrorBoundary 组件,用于捕获并处理子组件中的 JavaScript 错误。当错误发生时,显示友好的错误界面并提供刷新功能。

* refactor(ui): 使用全局 snackbar 替换本地通知组件

重构表单页面中的通知系统,移除本地 Snackbar 和 Alert 组件,改用全局 GlobalSnackbar 组件统一管理通知
标准化主题配置中的 CSS 属性命名,移除带前缀的样式属性

* refactor(snackbar): 重构全局消息提示为 SnackbarProvider 组件

将原本分散在各页面的 GlobalSnackbar 组件重构为集中管理的 SnackbarProvider,通过 Context 提供统一的消息提示功能。主要变更包括:

1. 创建新的 SnackbarProvider 组件作为全局消息提示容器
2. 提供 useSnackbar hook 供子组件调用
3. 移除各页面中独立的 GlobalSnackbar 实例
4. 在 App 根组件中统一集成 SnackbarProvider
5. 优化消息提示样式和交互行为

* refactor(Snackbar): 重构消息提示组件并优化样式

重构 Snackbar 组件,将状态管理逻辑提取到 GlobalSnackbar 中复用
优化消息提示样式,调整阴影效果和最小尺寸
统一各页面使用 Snackbar 的方式,移除冗余配置

* refactor: 重构路由和功能配置系统

- 将路由配置和仪表盘卡片配置合并为统一的 features 配置
- 重构 RouterProvider 增加数据校验和本地快照功能
- 优化表单字段识别和填充逻辑,提取公共方法
- 更新相关组件和文档以适配新的配置系统
- 删除废弃的 dashboardCards 和 routes 配置文件

* refactor(GlobalSnackbar): 合并SnackbarProvider到GlobalSnackbar组件

重构Snackbar相关代码,将SnackbarProvider合并到GlobalSnackbar组件中,简化代码结构
更新相关组件引用路径,从SnackbarProvider改为GlobalSnackbar
修复StorageCleanerPage禁用状态逻辑问题

* feat(表单映射): 增强高亮器功能并优化元素定位

- 新增高亮器闪烁功能,支持自定义持续时间
- 改进元素定位算法,支持 Shadow DOM 穿透
- 优化拾取模式,支持连续拾取和悬停预览
- 增强错误处理和信息反馈
- 添加测试用例确保核心功能稳定性

* refactor: 移除表单映射相关功能

删除表单映射相关的UI、逻辑和测试代码,包括表单识别、智能填充和高亮功能
简化消息处理逻辑和配置文件,保留核心功能

* feat: 实现页面组件懒加载并优化加载状态显示

refactor: 移除未使用的数据生成和验证工具类

chore: 更新应用图标文件

* refactor(qrcode): 替换二维码生成和解析库以减小体积

使用 qr-scanner 替代 jsqr 进行二维码解析,使用 qrious 替代 qrcode 进行二维码生成
新的库体积更小且性能更好,同时移除了不再使用的 @faker-js/faker 依赖

* feat: 添加在标签页打开功能并优化错误处理

修复测试中的加载状态检查
将控制台日志改为错误捕获
添加全局消息提示组件

* feat: 支持独立标签页模式并重构路由配置

重构路由系统以支持独立标签页模式,替换原有的独立窗口模式
- 新增标签页路由、可见页面列表和页面顺序的存储键
- 修改 RouterProvider 以支持不同窗口类型的独立配置
- 更新选项页面以支持为不同窗口类型单独配置
- 添加相关单元测试

* feat(ui): 优化响应式布局和样式设计

重构选项页面布局,增强移动端适配性
更新主题配置和组件样式,提升用户体验

* refactor: 移除URL工具相关代码

移除不再需要的URL工具功能,包括组件、页面、样式和类型定义

* refactor: 简化页面标题和存储选项网格的响应式布局

* refactor: 优化样式和错误处理

- 简化 chrome.runtime.openOptionsPage 的错误处理
- 拆分 html, body, #root 的样式定义,使用 minHeight 替代 height

* refactor: 重构页面组件结构
- 将页面组件从 entrypoints/popup/pages 移动到 pages 目录
- 更新文档中的路径引用

* style(DashboardPage): 优化仪表盘布局为响应式网格

refactor(ToolCard): 重构工具卡片组件并添加详细注释

* feat: 新增文本统计功能

添加文本统计工具页面及相关功能,包括:
- 文本统计工具页面组件
- 文本统计计算工具函数
- 相关类型定义和样式配置
- 单元测试覆盖核心功能

* feat(jwt): 添加 JWT 解析工具功能

新增 JWT 解析工具页面,包含以下内容:
- 添加 JWT 类型定义和页面样式配置
- 实现 JWT 解析核心功能及测试用例
- 创建 JWT 解析页面组件
- 在功能配置中注册 JWT 工具

* docs: 更新 README 以更清晰地描述项目功能和结构

- 重构功能描述部分,使用更简洁的列表形式展示
- 更新技术栈和项目结构说明
- 优化开发流程部分的描述
- 移除冗余的权限和依赖说明

* docs: 更新AGENTS.md文档以反映项目最新状态

- 重写项目概述和核心命令部分,使其更简洁准确
- 重构目录结构描述,采用更清晰的树状格式
- 移除过时的功能模块描述,聚焦当前核心功能
- 更新AI代理开发准则和权限管理说明
- 优化文档整体结构和可读性

* test: 更新特征计数测试以匹配新增 textStatistics 和 jwt 工具

* style: 统一导入语句顺序

* refactor(pages): 重构工具页面组件结构并优化功能实现

- 将各工具页面组件迁移至独立目录并重构为模块化结构
- 新增存储清理工具相关组件及状态管理逻辑
- 实现时间戳转换工具的自定义hook和UI组件
- 优化JWT解析工具的代码结构和样式
- 更新文本统计工具的性能计算逻辑
- 调整配置文件中的页面引用路径
- 删除无用文件和过时文档

* feat(顶部栏): 添加搜索功能和历史记录

实现顶部栏搜索框功能,支持模糊搜索工具并显示搜索结果列表
添加搜索历史记录功能,自动保存最近搜索并显示
支持键盘导航和选择搜索结果

* feat: 添加多语言支持(i18n)

实现应用的多语言功能,支持中英文切换
添加i18next和相关依赖
创建语言资源文件
更新组件以使用翻译功能
重构文本统计工具页面
完善测试和构建配置

* feat(顶部栏): 添加语言切换功能并优化国际化支持
feat(i18n): 增加语言归一化和同步功能
fix(剪贴板): 使用国际化文本替换剪贴板消息
test(顶部栏): 更新测试以匹配国际化标题

* refactor: 重构组件目录结构,将组件移动到对应功能页面目录

将多个组件从通用组件目录移动到各自功能页面目录下,包括 ToolCard、StorageCleanerConfirm、UrlToQrCodeSection 和 QrCodeToUrlSection。同时新增 Dashboard 页面相关组件 DashboardCard 和 ToolCard,并优化了组件导入路径。

重构旨在提高代码组织性,使组件与其所属功能页面更紧密关联,便于维护和理解代码结构。所有组件功能保持不变,仅调整文件位置和导入路径。

* refactor(Dashboard): 优化卡片组件结构并移除无用属性

重构 DashboardCard 组件接口,直接传递属性而非配置对象
移除 ToolCard 中无用的 hasAI 属性
使用 useMemo 优化可见性判断性能
统一使用 alpha 函数处理颜色透明度

* refactor(timestamp): 合并输入处理函数并优化样式结构

重构时间戳转换器的输入处理逻辑,将 setTsInput 和 setDtInput 合并为统一的 setInput 函数
将组件样式提取到 pageTheme 配置中统一管理
使用 ToggleButtonGroup 替换自定义模式切换器实现
移除不必要的 showMessage 属性传递,改用 useSnackbar 钩子

* refactor(dashboard): 重构仪表盘卡片组件并优化布局

- 删除冗余的 DashboardCard 组件,直接使用 ToolCard
- 将网格布局样式移至主题配置
- 使用 CardActionArea 增强可访问性
- 简化点击事件处理逻辑
- 更新相关测试用例

* refactor(storageCleaner): 重构样式代码并优化国际化处理

将分散的样式代码集中到pageTheme.ts中统一管理
优化组件中的国际化文本引用
修复useStorageCleaner中的状态同步问题
更新测试用例以匹配国际化文本

* refactor(StorageCleaner): 优化条件判断和清理未使用的代码

简化禁用按钮的条件判断逻辑
移除未使用的 resultTimeoutRef 及相关清理代码

* refactor(QrCode): 重构二维码页面样式和组件结构

将内联样式提取到主题配置文件中
移除未使用的全局消息提示依赖
优化组件结构和代码复用
删除未使用的QrCodeUploader组件

* refactor(ErrorBoundary): 移除冗余状态更新并调整错误背景色

移除 componentDidCatch 中的冗余 setState 调用
将错误背景色从 'error.shortest' 改为 rgba 格式

* feat(TopBar): 优化顶部导航栏功能并添加样式配置

- 新增 openExtensionPage 工具函数用于打开扩展页面
- 将顶部导航栏样式配置提取到 pageTheme 中统一管理
- 改进搜索历史功能,增加错误处理和配置化限制
- 优化键盘导航和搜索结果的交互逻辑

* chore: 在.gitignore中添加docs目录

避免将文档生成目录提交到版本控制

* feat(JsonDiff): add type definitions for diff engine

* feat: 新增 JSON 差异比较工具功能

添加 JSON 差异比较工具页面,支持并排和统一视图模式,包含以下功能:
- 左右两侧 JSON 输入框及校验
- 差异高亮显示及导航
- 支持对象和数组的深度比较
- 国际化支持
- 单元测试覆盖核心差异算法

* feat(主题): 添加暗色模式支持并重构主题系统

- 新增 ThemeModeProvider 提供亮色/暗色/系统模式切换功能
- 重构所有组件使用 MUI 主题变量替代硬编码颜色
- 更新测试用例以适配新的主题系统
- 添加 i18n 翻译支持主题切换相关文本
- 统一入口点使用 AppRoot 组件封装主题和路由提供者

* feat(jsonDiff): 新增 JSON 格式化功能模块

- 新增 jsonFormat 语言资源,支持中英文文本
- 新增 JsonFormatSection 组件,提供 JSON 格式化输入与结果展示
- 在 JsonDiff 页面加入模式切换,支持差异比较与格式化双模式
- 实现 formatJson 和 validateJson 工具方法,支持缩进大小和键名排序选项
- 增加格式化结果显示大小统计和一键复制功能
- 写入大量单元测试,覆盖格式化和校验功能边界
- 优化页面布局与交互,提升用户体验和功能集成度

* feat(jsonDiff): 新增 JSON 转 YAML/TOML 及压缩功能

- 在语言文件(en/zh)中添加 YAML、TOML 和 JSON 压缩模式相关文案
- 扩展 JsonDiff 页面,新增 yaml、toml、minify 三种页面模式
- 新增 JsonConvertSection 组件,提供输入校验、转换操作及结果展示
- 实现 jsonToYaml 工具函数,将 JSON 转换为符合规范的 YAML 格式
- 实现 jsonToToml 工具函数,将 JSON 转换为符合规范且支持嵌套的 TOML 格式
- 实现 minifyJson 工具函数,实现 JSON 字符串压缩为单行格式
- 为所有新功能增加单元测试,覆盖正常及异常场景,保证转换正确性和稳定性
- 在页面中添加对应的切换按钮和图标,提升用户体验和操作便捷性

* refactor(jsonTools): 重命名 JSON 差异比较为 JSON 工具

- 将 JsonDiff 页面组件重命名为 JsonTools 并更新相关引用
- 修改功能配置中对应组件的名称以匹配新命名
- 更新英文和中文语言包中该功能的标题及描述
- 调整测试文件中导入路径以使用新的 JsonTools 文件夹名

* feat(storage): 优化本地存储状态管理和 JSON 工具页模式持久化

- 添加 useStorageState 钩子,支持同步快照和异步加载存储状态
- 实现快照机制,防止页面首屏闪烁现象
- 增加状态值验证器功能,保证恢复数据合法性
- JSON 工具页面模式改用受控存储状态持久化
- 新增 JsonToolsPageMode 类型定义,统一页面模式类型
- 为 useStorageState 编写完整单元测试,覆盖快照、校验及存储同步场景

* feat(base64Converter): 新增 Base64 转换器功能模块

- 添加 Base64 转换器页面及相关样式配置
- 支持文本、文件及图像三种转换模式,含编码和解码功能
- 实现拖拽、文件选择及图像预览功能
- 增加中文与英文多语言资源文件支持
- 补充相关工具函数,支持编码解码及格式校验
- 扩展路由提供者逻辑,合并已保存列表与默认功能集合
- 更新测试用例,覆盖合并新功能的场景验证
- 在特性配置中加入 Base64 转换器信息,支持图标和默认显示配置

* feat(markdown): 新增 Markdown 转 HTML 功能模块

- 添加 marked 依赖及类型定义支持 Markdown 解析
- 新增 MarkdownToHtml 页面,支持实时编辑、预览及 HTML 输出三种模式切换
- 实现 markdownToHtml 工具函数进行 Markdown 转 HTML 操作,含错误处理
- 包装完整 HTML 文档功能,支持打印和下载导出
- 配置全局主题样式,优化 Markdown 渲染视觉效果
- 完善 i18n 国际化资源,支持多语言页面展示
- 统一类型定义,新增 MarkdownToHtml 相关存储状态类型
- 添加相关单元测试,覆盖转换逻辑及功能组件
- 在功能配置中注册 Markdown 转 HTML 工具,支持界面调用和路由访问

* feat(htmlToMarkdown): 添加 HTML 转 Markdown 功能页面及转换工具

- 新增 HtmlToMarkdownPage 组件,支持实时转换 HTML 为 Markdown
- 增加 htmlToMarkdown 工具函数,实现基于 DOM 解析的转换逻辑
- 新增下载 Markdown 文件功能,支持一键导出转换结果
- 配置文件增加 htmlToMarkdown 相关功能项和页面样式
- 国际化资源添加对应中英文文案
- 更新类型定义,新增 HTML 转 Markdown 相关类型和存储键
- 补充对应的单元测试,覆盖转换逻辑和导出功能
- 修正路由提供器测试,确保新增页面被正确识别和排序

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: yll <mtmtchb@outlook.com>
This commit is contained in:
LingandRX
2026-05-10 23:44:45 +08:00
committed by GitHub
parent 3c9c9e740e
commit a6dcdddf66
49 changed files with 5257 additions and 228 deletions
+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 };
+213
View File
@@ -0,0 +1,213 @@
import { useEffect, useMemo, useState } from 'react';
import { Box, Button, FormHelperText, Stack, TextField, Typography } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { formatByteSize } from '@/utils/textStatistics';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { jsonDiffPageStyles } from '@/config/pageTheme';
import CopyButton from '@/components/CopyButton';
import { validateJson } from '@/utils/jsonFormatter';
/** 转换结果通用接口 */
export interface ConvertResult {
/** 转换后的输出字符串 */
output: string;
/** 原始输入的字节大小 */
originalBytes: number;
/** 转换后的字节大小 */
outputBytes: number;
}
/** 转换函数类型 */
export type ConvertFunction = (text: string) => ConvertResult;
/**
* JSON 转换工具区域组件属性
*/
interface JsonConvertSectionProps {
/** i18n 命名空间内翻译键的前缀,如 'yamlMode' / 'tomlMode' / 'minifyMode' */
translationPrefix: string;
/** 转换函数 */
convertFunction: ConvertFunction;
/** 转换按钮的翻译键后缀,默认 'convertButton' */
convertButtonKey?: string;
}
/**
* JSON 转换工具共享组件
*
* 适用于 JSON->YAML、JSON->TOML、JSON 压缩等场景,
* 提供输入区域、转换按钮和结果展示(含一键复制)。
*/
export default function JsonConvertSection({
translationPrefix,
convertFunction,
convertButtonKey = 'convertButton',
}: JsonConvertSectionProps) {
const { t } = useTranslation(['jsonFormat']);
const { showMessage } = useSnackbar();
const [input, setInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ConvertResult | null>(null);
const pk = translationPrefix;
// 防抖校验输入
useEffect(() => {
const handle = setTimeout(() => {
setError(validateJson(input));
}, 300);
return () => clearTimeout(handle);
}, [input]);
const canConvert = useMemo(() => {
return input.trim() !== '' && !error;
}, [input, error]);
const handleConvert = () => {
const validationError = validateJson(input);
if (validationError) {
setError(validationError);
setResult(null);
return;
}
try {
const convertResult = convertFunction(input);
setResult(convertResult);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
setResult(null);
}
};
const handleClear = () => {
setInput('');
setError(null);
setResult(null);
};
return (
<Stack spacing={2.5}>
{/* 工具栏 */}
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'center' }}
>
<Box />
<Stack direction="row" spacing={1}>
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}>
{t('jsonFormat:clearButton')}
</Button>
<Button
variant="contained"
disabled={!canConvert}
onClick={handleConvert}
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }}
>
{t(`jsonFormat:${convertButtonKey}`)}
</Button>
</Stack>
</Stack>
{/* 输入区 */}
<Box>
<TextField
multiline
rows={6}
fullWidth
placeholder={t(`jsonFormat:${pk}InputPlaceholder`)}
value={input}
onChange={(e) => setInput(e.target.value)}
error={Boolean(error)}
sx={jsonDiffPageStyles.INPUT_STYLE}
/>
{error && (
<FormHelperText error sx={{ mx: 1.5, mt: 0.5, fontWeight: 600 }}>
{t('jsonFormat:invalidJson')}
</FormHelperText>
)}
</Box>
{/* 转换结果 */}
{result && result.output ? (
<Box
sx={{
position: 'relative',
borderRadius: 3,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
{/* 结果头部 */}
<Stack
direction="row"
justifyContent="space-between"
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`)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}>
{t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}>
{t('jsonFormat:formattedSize')}: {formatByteSize(result.outputBytes)}
</Typography>
</Stack>
<CopyButton text={result.output} showMessage={showMessage} />
</Stack>
{/* 转换内容 */}
<Box
sx={{
p: 2,
fontFamily: 'monospace',
fontSize: '0.8rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: 400,
overflowY: 'auto',
lineHeight: 1.6,
}}
>
{result.output}
</Box>
</Box>
) : (
<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(`jsonFormat:${pk}EmptyHint`)}
</Typography>
</Box>
)}
</Stack>
);
}
+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 };
+247
View File
@@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'react';
import {
Box,
Button,
FormControlLabel,
FormHelperText,
Stack,
Switch,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import {
formatJson,
validateJson,
type JsonFormatOptions,
type JsonFormatResult,
} from '@/utils/jsonFormatter';
import { formatByteSize } from '@/utils/textStatistics';
import { useSnackbar } from '@/components/GlobalSnackbar';
import { jsonDiffPageStyles } from '@/config/pageTheme';
import CopyButton from '@/components/CopyButton';
/** 缩进大小选项 */
const INDENT_OPTIONS = [2, 4, 6, 8] as const;
/**
* JSON 格式化工具区域组件
*
* 提供输入区域、格式化选项(缩进大小、键名排序)和格式化结果展示,
* 支持一键复制格式化后的 JSON。
*/
export default function JsonFormatSection() {
const { t } = useTranslation(['jsonFormat']);
const { showMessage } = useSnackbar();
const [input, setInput] = useState('');
const [error, setError] = useState<string | null>(null);
const [indentSize, setIndentSize] = useState<number>(2);
const [sortKeys, setSortKeys] = useState(false);
const [result, setResult] = useState<JsonFormatResult | null>(null);
// 防抖校验输入
useEffect(() => {
const handle = setTimeout(() => {
setError(validateJson(input));
}, 300);
return () => clearTimeout(handle);
}, [input]);
const canFormat = useMemo(() => {
return input.trim() !== '' && !error;
}, [input, error]);
const handleFormat = () => {
const validationError = validateJson(input);
if (validationError) {
setError(validationError);
setResult(null);
return;
}
try {
const options: JsonFormatOptions = { indentSize, sortKeys };
const formatResult = formatJson(input, options);
setResult(formatResult);
} catch (e) {
setError(e instanceof SyntaxError ? e.message : String(e));
setResult(null);
}
};
const handleClear = () => {
setInput('');
setError(null);
setResult(null);
};
return (
<Stack spacing={2.5}>
{/* 工具栏 */}
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={1.5}
justifyContent="space-between"
alignItems={{ xs: 'stretch', sm: 'center' }}
>
<Stack direction="row" spacing={1.5} alignItems="center">
{/* 缩进选择 */}
<Typography
variant="caption"
sx={{ fontWeight: 800, color: 'text.secondary', fontSize: '0.7rem' }}
>
{t('jsonFormat:indentSize')}
</Typography>
<ToggleButtonGroup
size="small"
exclusive
value={indentSize}
onChange={(_, v: number | null) => v !== null && setIndentSize(v)}
sx={{ borderRadius: 3 }}
>
{INDENT_OPTIONS.map((size) => (
<ToggleButton
key={size}
value={size}
sx={{ px: 1.5, fontWeight: 700, fontSize: '0.75rem' }}
>
{size}
</ToggleButton>
))}
</ToggleButtonGroup>
{/* 键名排序开关 */}
<FormControlLabel
control={
<Switch
size="small"
checked={sortKeys}
onChange={(e) => setSortKeys(e.target.checked)}
/>
}
label={
<Typography variant="caption" sx={{ fontWeight: 700, fontSize: '0.7rem' }}>
{t('jsonFormat:sortKeys')}
</Typography>
}
sx={{ ml: 1 }}
/>
</Stack>
<Stack direction="row" spacing={1}>
<Button variant="text" onClick={handleClear} sx={{ borderRadius: 3 }}>
{t('jsonFormat:clearButton')}
</Button>
<Button
variant="contained"
disabled={!canFormat}
onClick={handleFormat}
sx={{ borderRadius: 3, fontWeight: 700, px: 3 }}
>
{t('jsonFormat:formatButton')}
</Button>
</Stack>
</Stack>
{/* 输入区 */}
<Box>
<TextField
multiline
rows={6}
fullWidth
placeholder={t('jsonFormat:inputPlaceholder')}
value={input}
onChange={(e) => setInput(e.target.value)}
error={Boolean(error)}
sx={jsonDiffPageStyles.INPUT_STYLE}
/>
{error && (
<FormHelperText error sx={{ mx: 1.5, mt: 0.5, fontWeight: 600 }}>
{t('jsonFormat:invalidJson')}
</FormHelperText>
)}
</Box>
{/* 格式化结果 */}
{result && result.formatted ? (
<Box
sx={{
position: 'relative',
borderRadius: 3,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
overflow: 'hidden',
}}
>
{/* 结果头部 */}
<Stack
direction="row"
justifyContent="space-between"
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')}
</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}>
{t('jsonFormat:originalSize')}: {formatByteSize(result.originalBytes)}
</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: '0.65rem' }}>
{t('jsonFormat:formattedSize')}: {formatByteSize(result.formattedBytes)}
</Typography>
</Stack>
<CopyButton text={result.formatted} showMessage={showMessage} />
</Stack>
{/* 格式化内容 */}
<Box
sx={{
p: 2,
fontFamily: 'monospace',
fontSize: '0.8rem',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
maxHeight: 400,
overflowY: 'auto',
lineHeight: 1.6,
}}
>
{result.formatted}
</Box>
</Box>
) : (
<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('jsonFormat:emptyHint')}
</Typography>
</Box>
)}
</Stack>
);
}
+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 };
+289
View File
@@ -0,0 +1,289 @@
import { useEffect, useMemo, useState, useCallback } from 'react';
import {
Box,
Button,
Container,
Stack,
ToggleButton,
ToggleButtonGroup,
Typography,
} from '@mui/material';
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
import DataObjectIcon from '@mui/icons-material/DataObject';
import TransformIcon from '@mui/icons-material/Transform';
import CompressIcon from '@mui/icons-material/Compress';
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 JsonFormatSection from './JsonFormatSection';
import JsonConvertSection from './JsonConvertSection';
import type { ConvertFunction } 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';
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 };
}
};
/** 页面模式 */
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);
type PageMode = JsonToolsPageMode;
export default function Index() {
const { t } = useTranslation(['jsonDiff', 'jsonFormat']);
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
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;
/** 页面模式对应的标题和副标题翻译键 */
const modeTitles: Record<PageMode, { title: string; subtitle: string }> = {
diff: { title: 'jsonDiff:pageTitle', subtitle: 'jsonDiff:pageSubtitle' },
format: { title: 'jsonFormat:formatTitle', subtitle: 'jsonFormat:formatSubtitle' },
yaml: { title: 'jsonFormat:yamlTitle', subtitle: 'jsonFormat:yamlSubtitle' },
toml: { title: 'jsonFormat:tomlTitle', subtitle: 'jsonFormat:tomlSubtitle' },
minify: { title: 'jsonFormat:minifyTitle', subtitle: 'jsonFormat:minifySubtitle' },
};
const modeIcon: Record<PageMode, React.ReactNode> = {
diff: <CompareArrowsIcon />,
format: <DataObjectIcon />,
yaml: <TransformIcon />,
toml: <TransformIcon />,
minify: <CompressIcon />,
};
const yamlConvert: ConvertFunction = useCallback((text: string) => {
const r = jsonToYaml(text);
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
}, []);
const tomlConvert: ConvertFunction = useCallback((text: string) => {
const r = jsonToToml(text);
return { output: r.output, originalBytes: r.originalBytes, outputBytes: r.outputBytes };
}, []);
const minifyConvert: ConvertFunction = useCallback((text: string) => {
const r = minifyJson(text);
return { output: r.minified, originalBytes: r.originalBytes, outputBytes: r.minifiedBytes };
}, []);
return (
<Box>
<Container sx={{ p: 2 }}>
<PageHeader
title={t(modeTitles[pageMode].title)}
subtitle={t(modeTitles[pageMode].subtitle)}
icon={modeIcon[pageMode]}
iconColor={jsonDiffPageStyles.primaryColor}
/>
<Stack spacing={2.5}>
{/* 页面模式切换器 */}
<ToggleButtonGroup
size="small"
exclusive
value={pageMode}
onChange={(_, v: PageMode | null) => v && setPageMode(v)}
sx={{ borderRadius: 3, flexWrap: 'wrap', gap: 0.5 }}
>
<ToggleButton value="diff" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
{t('jsonFormat:diffMode')}
</ToggleButton>
<ToggleButton value="format" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
{t('jsonFormat:formatMode')}
</ToggleButton>
<ToggleButton value="yaml" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
{t('jsonFormat:yamlMode')}
</ToggleButton>
<ToggleButton value="toml" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
{t('jsonFormat:tomlMode')}
</ToggleButton>
<ToggleButton value="minify" sx={{ px: 2, fontWeight: 700, fontSize: '0.75rem' }}>
{t('jsonFormat:minifyMode')}
</ToggleButton>
</ToggleButtonGroup>
{pageMode === 'diff' ? (
<>
{/* 工具栏 */}
<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>
)}
</>
) : pageMode === 'format' ? (
<JsonFormatSection />
) : pageMode === 'yaml' ? (
<JsonConvertSection translationPrefix="yamlMode" convertFunction={yamlConvert} />
) : pageMode === 'toml' ? (
<JsonConvertSection translationPrefix="tomlMode" convertFunction={tomlConvert} />
) : (
<JsonConvertSection
translationPrefix="minifyMode"
convertFunction={minifyConvert}
convertButtonKey="minifyButton"
/>
)}
</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;
}