Develop (#21)
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:
@@ -29,3 +29,5 @@ stats-*.json
|
|||||||
.trae/*
|
.trae/*
|
||||||
.workbuddy/*
|
.workbuddy/*
|
||||||
dev/*
|
dev/*
|
||||||
|
|
||||||
|
docs/*
|
||||||
|
|||||||
@@ -67,15 +67,16 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
|||||||
style={style}
|
style={style}
|
||||||
sx={{
|
sx={{
|
||||||
color: copied ? 'success.main' : color,
|
color: copied ? 'success.main' : color,
|
||||||
bgcolor: '#fff',
|
bgcolor: 'background.paper',
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
|
boxShadow: (theme) =>
|
||||||
|
`0 2px 8px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.05)'}`,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: copied
|
bgcolor: copied
|
||||||
? 'success.main'
|
? 'success.main'
|
||||||
: !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
|
: !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color)
|
||||||
? color
|
? color
|
||||||
: `${color}.main`,
|
: `${color}.main`,
|
||||||
color: '#fff',
|
color: 'background.paper',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
import { Box, Button, Container, Paper, Typography } from '@mui/material';
|
import { Box, Button, Container, Paper, Typography } from '@mui/material';
|
||||||
|
import type { Theme } from '@mui/material/styles';
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
||||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
|
||||||
@@ -60,7 +61,8 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
sx={{
|
sx={{
|
||||||
mb: 3,
|
mb: 3,
|
||||||
p: 2,
|
p: 2,
|
||||||
bgcolor: 'grey.100',
|
bgcolor: (theme: Theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.100',
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
maxHeight: '200px',
|
maxHeight: '200px',
|
||||||
|
|||||||
@@ -182,8 +182,8 @@ export function GlobalSnackbar({
|
|||||||
backgroundImage: 'none',
|
backgroundImage: 'none',
|
||||||
boxShadow: (theme: Theme) =>
|
boxShadow: (theme: Theme) =>
|
||||||
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
|
`0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`,
|
||||||
'& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' },
|
'& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem' },
|
||||||
'& .MuiAlert-message': { color: '#fff', padding: '6px 0' },
|
'& .MuiAlert-message': { padding: '6px 0' },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{message}
|
{message}
|
||||||
|
|||||||
+41
-12
@@ -20,16 +20,22 @@ import SearchIcon from '@mui/icons-material/Search';
|
|||||||
import HistoryIcon from '@mui/icons-material/History';
|
import HistoryIcon from '@mui/icons-material/History';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
import LanguageIcon from '@mui/icons-material/Language';
|
||||||
|
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||||
|
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||||
|
import SettingsBrightnessIcon from '@mui/icons-material/SettingsBrightness';
|
||||||
import { useRouter } from '@/providers/RouterProvider';
|
import { useRouter } from '@/providers/RouterProvider';
|
||||||
|
import { useThemeMode } from '@/providers/ThemeModeProvider';
|
||||||
import { FEATURES, FeatureConfig } from '@/config/features';
|
import { FEATURES, FeatureConfig } from '@/config/features';
|
||||||
import { storageUtil } from '@/utils/chromeStorage';
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
import { openExtensionPage } from '@/utils/chromeTabs';
|
import { openExtensionPage } from '@/utils/chromeTabs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { alpha } from '@mui/material/styles';
|
||||||
import { SUPPORTED_LANGUAGES, normalizeLanguage } from '@/i18n';
|
import { SUPPORTED_LANGUAGES, normalizeLanguage } from '@/i18n';
|
||||||
import { topBarStyles } from '@/config/pageTheme';
|
import { topBarStyles } from '@/config/pageTheme';
|
||||||
|
|
||||||
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) {
|
||||||
const { currentPage, goBack, navigateTo } = useRouter();
|
const { currentPage, goBack, navigateTo } = useRouter();
|
||||||
|
const { mode, setMode } = useThemeMode();
|
||||||
const { t, i18n } = useTranslation(['common', 'features']);
|
const { t, i18n } = useTranslation(['common', 'features']);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
@@ -107,6 +113,14 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
await storageUtil.set('app/language', newLng);
|
await storageUtil.set('app/language', newLng);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cycleThemeMode = () => {
|
||||||
|
const next = { light: 'dark', dark: 'system', system: 'light' } as const;
|
||||||
|
setMode(next[mode]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ThemeIcon =
|
||||||
|
mode === 'light' ? LightModeIcon : mode === 'dark' ? DarkModeIcon : SettingsBrightnessIcon;
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
const totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length;
|
||||||
|
|
||||||
@@ -152,7 +166,7 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
px: { xs: 1, sm: 2 },
|
px: { xs: 1, sm: 2 },
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'divider',
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
zIndex: topBarStyles.Z_INDEX,
|
zIndex: topBarStyles.Z_INDEX,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -165,8 +179,12 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
onClick={goBack}
|
onClick={goBack}
|
||||||
aria-label={t('common:buttons.back')}
|
aria-label={t('common:buttons.back')}
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: 'grey.50',
|
bgcolor: (theme) =>
|
||||||
'&:hover': { bgcolor: 'grey.200' },
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.50',
|
||||||
|
'&:hover': {
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'grey.200',
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
|
<ArrowBackIosNewIcon sx={{ fontSize: 14 }} />
|
||||||
@@ -217,18 +235,24 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
}
|
}
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
bgcolor: 'grey.50',
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.50',
|
||||||
px: 1.5,
|
px: 1.5,
|
||||||
py: 0.5,
|
py: 0.5,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
fontSize: '0.875rem',
|
fontSize: '0.875rem',
|
||||||
border: '1px solid transparent',
|
border: '1px solid',
|
||||||
|
borderColor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'transparent',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'&:hover': { bgcolor: 'grey.100' },
|
'&:hover': {
|
||||||
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'grey.100',
|
||||||
|
},
|
||||||
'&.Mui-focused': {
|
'&.Mui-focused': {
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
borderColor: 'primary.main',
|
borderColor: 'primary.main',
|
||||||
boxShadow: '0 0 0 2px rgba(25, 118, 210, 0.1)',
|
boxShadow: (theme) => `0 0 0 2px ${alpha(theme.palette.primary.main, 0.15)}`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -312,11 +336,7 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
</ClickAwayListener>
|
</ClickAwayListener>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Stack
|
<Stack direction="row" spacing={0.5} sx={{ justifyContent: 'flex-end', flexShrink: 0 }}>
|
||||||
direction="row"
|
|
||||||
spacing={0.5}
|
|
||||||
sx={{ width: { xs: 100, sm: 150 }, justifyContent: 'flex-end' }}
|
|
||||||
>
|
|
||||||
<Tooltip title={t('common:buttons.toggleLanguage')}>
|
<Tooltip title={t('common:buttons.toggleLanguage')}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -326,6 +346,15 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void })
|
|||||||
<LanguageIcon sx={{ fontSize: 18 }} />
|
<LanguageIcon sx={{ fontSize: 18 }} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
<Tooltip title={t(`common:buttons.themeMode.${mode}`)}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={cycleThemeMode}
|
||||||
|
aria-label={t('common:buttons.toggleTheme')}
|
||||||
|
>
|
||||||
|
<ThemeIcon sx={{ fontSize: 18 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
<Tooltip title={t('common:buttons.openInTab')}>
|
<Tooltip title={t('common:buttons.openInTab')}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe('ToolCard 组件', () => {
|
|||||||
<ToolCard
|
<ToolCard
|
||||||
title="测试工具"
|
title="测试工具"
|
||||||
description="这是一个测试工具"
|
description="这是一个测试工具"
|
||||||
colorCode="#2196f3"
|
colorKey="primary"
|
||||||
icon={<AccessTimeIcon />}
|
icon={<AccessTimeIcon />}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>,
|
/>,
|
||||||
@@ -26,12 +26,7 @@ describe('ToolCard 组件', () => {
|
|||||||
|
|
||||||
it('无描述时仅渲染标题', () => {
|
it('无描述时仅渲染标题', () => {
|
||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard title="仅标题" colorKey="primary" icon={<AccessTimeIcon />} onClick={() => {}} />,
|
||||||
title="仅标题"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
expect(screen.getByText('仅标题')).toBeInTheDocument();
|
||||||
@@ -41,7 +36,7 @@ describe('ToolCard 组件', () => {
|
|||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title="带图标"
|
title="带图标"
|
||||||
colorCode="#2196f3"
|
colorKey="primary"
|
||||||
icon={<AccessTimeIcon data-testid="test-icon" />}
|
icon={<AccessTimeIcon data-testid="test-icon" />}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>,
|
/>,
|
||||||
@@ -54,7 +49,7 @@ describe('ToolCard 组件', () => {
|
|||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title="带快照"
|
title="带快照"
|
||||||
colorCode="#2196f3"
|
colorKey="primary"
|
||||||
icon={<AccessTimeIcon />}
|
icon={<AccessTimeIcon />}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
snapshot={<div data-testid="snapshot">快照内容</div>}
|
snapshot={<div data-testid="snapshot">快照内容</div>}
|
||||||
@@ -66,12 +61,7 @@ describe('ToolCard 组件', () => {
|
|||||||
|
|
||||||
it('未提供快照时不渲染快照区域', () => {
|
it('未提供快照时不渲染快照区域', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<ToolCard
|
<ToolCard title="无快照" colorKey="primary" icon={<AccessTimeIcon />} onClick={() => {}} />,
|
||||||
title="无快照"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument();
|
||||||
@@ -79,12 +69,7 @@ describe('ToolCard 组件', () => {
|
|||||||
|
|
||||||
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
it('应使用 CardActionArea 渲染,支持键盘聚焦', () => {
|
||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard title="可聚焦" colorKey="primary" icon={<AccessTimeIcon />} onClick={() => {}} />,
|
||||||
title="可聚焦"
|
|
||||||
colorCode="#2196f3"
|
|
||||||
icon={<AccessTimeIcon />}
|
|
||||||
onClick={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /可聚焦/ });
|
const button = screen.getByRole('button', { name: /可聚焦/ });
|
||||||
@@ -99,7 +84,7 @@ describe('ToolCard 组件', () => {
|
|||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title="可点击"
|
title="可点击"
|
||||||
colorCode="#2196f3"
|
colorKey="primary"
|
||||||
icon={<AccessTimeIcon />}
|
icon={<AccessTimeIcon />}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>,
|
/>,
|
||||||
@@ -116,7 +101,7 @@ describe('ToolCard 组件', () => {
|
|||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title="键盘可触发"
|
title="键盘可触发"
|
||||||
colorCode="#2196f3"
|
colorKey="primary"
|
||||||
icon={<AccessTimeIcon />}
|
icon={<AccessTimeIcon />}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
/>,
|
/>,
|
||||||
@@ -131,11 +116,10 @@ describe('ToolCard 组件', () => {
|
|||||||
|
|
||||||
describe('样式测试', () => {
|
describe('样式测试', () => {
|
||||||
it('应应用自定义颜色代码', () => {
|
it('应应用自定义颜色代码', () => {
|
||||||
const customColor = '#ff5722';
|
|
||||||
render(
|
render(
|
||||||
<ToolCard
|
<ToolCard
|
||||||
title="自定义颜色"
|
title="自定义颜色"
|
||||||
colorCode={customColor}
|
colorKey="warning"
|
||||||
icon={<AccessTimeIcon data-testid="custom-color-icon" />}
|
icon={<AccessTimeIcon data-testid="custom-color-icon" />}
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
/>,
|
/>,
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
import TopBar from '@/components/TopBar';
|
|
||||||
import { RouterProvider } from '@/providers/RouterProvider';
|
|
||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
// matchMedia must be mocked before ThemeModeProvider is imported
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
import TopBar from '@/components/TopBar';
|
||||||
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
|
import { ThemeModeProvider } from '@/providers/ThemeModeProvider';
|
||||||
|
|
||||||
const mockRouterValue = {
|
const mockRouterValue = {
|
||||||
currentPage: 'dashboard' as PageType,
|
currentPage: 'dashboard' as PageType,
|
||||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||||
@@ -29,7 +46,11 @@ describe('TopBar 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderWithProvider = (ui: React.ReactElement) => {
|
const renderWithProvider = (ui: React.ReactElement) => {
|
||||||
return render(<RouterProvider>{ui}</RouterProvider>);
|
return render(
|
||||||
|
<ThemeModeProvider>
|
||||||
|
<RouterProvider>{ui}</RouterProvider>
|
||||||
|
</ThemeModeProvider>,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
|
|
||||||
describe('features', () => {
|
describe('features', () => {
|
||||||
describe('FEATURES', () => {
|
describe('FEATURES', () => {
|
||||||
it('should have 6 features defined', () => {
|
it('should have 7 features defined', () => {
|
||||||
expect(FEATURES).toHaveLength(6);
|
expect(FEATURES).toHaveLength(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have all required properties for each feature', () => {
|
it('should have all required properties for each feature', () => {
|
||||||
@@ -32,8 +32,8 @@ describe('features', () => {
|
|||||||
// Optional UI properties for non-hidden features
|
// Optional UI properties for non-hidden features
|
||||||
if (feature.key !== 'dashboard') {
|
if (feature.key !== 'dashboard') {
|
||||||
expect(feature).toHaveProperty('icon');
|
expect(feature).toHaveProperty('icon');
|
||||||
expect(feature).toHaveProperty('themeColor');
|
expect(feature).toHaveProperty('themeColorKey');
|
||||||
expect(typeof feature.themeColor).toBe('string');
|
expect(typeof feature.themeColorKey).toBe('string');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -58,7 +58,7 @@ describe('features', () => {
|
|||||||
expect(feature).toBeDefined();
|
expect(feature).toBeDefined();
|
||||||
expect(feature?.key).toBe('timestamp');
|
expect(feature?.key).toBe('timestamp');
|
||||||
expect(feature?.labelKey).toBe('features:timestamp.title');
|
expect(feature?.labelKey).toBe('features:timestamp.title');
|
||||||
expect(feature?.themeColor).toBeDefined();
|
expect(feature?.themeColorKey).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return storageCleaner feature', () => {
|
it('should return storageCleaner feature', () => {
|
||||||
@@ -95,13 +95,14 @@ describe('features', () => {
|
|||||||
describe('getAllFeatureKeys', () => {
|
describe('getAllFeatureKeys', () => {
|
||||||
it('should return all feature keys', () => {
|
it('should return all feature keys', () => {
|
||||||
const allKeys = getAllFeatureKeys();
|
const allKeys = getAllFeatureKeys();
|
||||||
expect(allKeys).toHaveLength(6);
|
expect(allKeys).toHaveLength(7);
|
||||||
expect(allKeys).toContain('dashboard');
|
expect(allKeys).toContain('dashboard');
|
||||||
expect(allKeys).toContain('timestamp');
|
expect(allKeys).toContain('timestamp');
|
||||||
expect(allKeys).toContain('storageCleaner');
|
expect(allKeys).toContain('storageCleaner');
|
||||||
expect(allKeys).toContain('qrCode');
|
expect(allKeys).toContain('qrCode');
|
||||||
expect(allKeys).toContain('textStatistics');
|
expect(allKeys).toContain('textStatistics');
|
||||||
expect(allKeys).toContain('jwt');
|
expect(allKeys).toContain('jwt');
|
||||||
|
expect(allKeys).toContain('jsonDiff');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,9 +119,9 @@ describe('features', () => {
|
|||||||
expect(pageOrder).toContain('qrCode');
|
expect(pageOrder).toContain('qrCode');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have 5 items in page order', () => {
|
it('should have 6 items in page order', () => {
|
||||||
const pageOrder = getDefaultPageOrder();
|
const pageOrder = getDefaultPageOrder();
|
||||||
expect(pageOrder).toHaveLength(5);
|
expect(pageOrder).toHaveLength(6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+23
-8
@@ -5,8 +5,9 @@ import StorageIcon from '@mui/icons-material/Storage';
|
|||||||
import QrCodeIcon from '@mui/icons-material/QrCode';
|
import QrCodeIcon from '@mui/icons-material/QrCode';
|
||||||
import DescriptionIcon from '@mui/icons-material/Description';
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||||||
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
import VpnKeyIcon from '@mui/icons-material/VpnKey';
|
||||||
|
import CompareArrowsIcon from '@mui/icons-material/CompareArrows';
|
||||||
|
|
||||||
import { THEME_COLORS } from './pageTheme';
|
export type PaletteColorKey = 'primary' | 'success' | 'warning' | 'error' | 'secondary' | 'info';
|
||||||
|
|
||||||
// 懒加载页面组件
|
// 懒加载页面组件
|
||||||
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
|
const DashboardPage = lazy(() => import('@/pages/Dashboard'));
|
||||||
@@ -15,6 +16,7 @@ const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner'));
|
|||||||
const QrCodePage = lazy(() => import('@/pages/QrCode'));
|
const QrCodePage = lazy(() => import('@/pages/QrCode'));
|
||||||
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics'));
|
||||||
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
const JwtPage = lazy(() => import('@/pages/Jwt'));
|
||||||
|
const JsonDiffPage = lazy(() => import('@/pages/JsonDiff'));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 功能配置接口
|
* 功能配置接口
|
||||||
@@ -28,8 +30,8 @@ export interface FeatureConfig {
|
|||||||
labelKey: string;
|
labelKey: string;
|
||||||
/** 功能描述翻译键 */
|
/** 功能描述翻译键 */
|
||||||
descriptionKey: string;
|
descriptionKey: string;
|
||||||
/** 主题颜色(用于仪表盘卡片) */
|
/** 主题颜色键(用于仪表盘卡片,映射到 theme.palette[key].main) */
|
||||||
themeColor?: string;
|
themeColorKey?: PaletteColorKey;
|
||||||
/** 图标组件(用于仪表盘卡片) */
|
/** 图标组件(用于仪表盘卡片) */
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
/** 默认是否在仪表盘显示 */
|
/** 默认是否在仪表盘显示 */
|
||||||
@@ -61,7 +63,7 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
labelKey: 'features:timestamp.title',
|
labelKey: 'features:timestamp.title',
|
||||||
descriptionKey: 'features:timestamp.description',
|
descriptionKey: 'features:timestamp.description',
|
||||||
themeColor: THEME_COLORS.primary,
|
themeColorKey: 'primary',
|
||||||
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
|
icon: <AccessTimeIcon sx={{ fontSize: 20 }} />,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
@@ -74,7 +76,7 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
key: 'storageCleaner',
|
key: 'storageCleaner',
|
||||||
labelKey: 'features:storageCleaner.title',
|
labelKey: 'features:storageCleaner.title',
|
||||||
descriptionKey: 'features:storageCleaner.description',
|
descriptionKey: 'features:storageCleaner.description',
|
||||||
themeColor: THEME_COLORS.warning,
|
themeColorKey: 'warning',
|
||||||
icon: <StorageIcon sx={{ fontSize: 20 }} />,
|
icon: <StorageIcon sx={{ fontSize: 20 }} />,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
@@ -87,7 +89,7 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
key: 'qrCode',
|
key: 'qrCode',
|
||||||
labelKey: 'features:qrCode.title',
|
labelKey: 'features:qrCode.title',
|
||||||
descriptionKey: 'features:qrCode.description',
|
descriptionKey: 'features:qrCode.description',
|
||||||
themeColor: THEME_COLORS.success,
|
themeColorKey: 'success',
|
||||||
icon: <QrCodeIcon sx={{ fontSize: 20 }} />,
|
icon: <QrCodeIcon sx={{ fontSize: 20 }} />,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
@@ -100,7 +102,7 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
key: 'textStatistics',
|
key: 'textStatistics',
|
||||||
labelKey: 'features:textStatistics.title',
|
labelKey: 'features:textStatistics.title',
|
||||||
descriptionKey: 'features:textStatistics.description',
|
descriptionKey: 'features:textStatistics.description',
|
||||||
themeColor: THEME_COLORS.purple,
|
themeColorKey: 'secondary',
|
||||||
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
icon: <DescriptionIcon sx={{ fontSize: 20 }} />,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
@@ -113,7 +115,7 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
key: 'jwt',
|
key: 'jwt',
|
||||||
labelKey: 'features:jwt.title',
|
labelKey: 'features:jwt.title',
|
||||||
descriptionKey: 'features:jwt.description',
|
descriptionKey: 'features:jwt.description',
|
||||||
themeColor: THEME_COLORS.indigo,
|
themeColorKey: 'info',
|
||||||
icon: <VpnKeyIcon sx={{ fontSize: 20 }} />,
|
icon: <VpnKeyIcon sx={{ fontSize: 20 }} />,
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
components: {
|
components: {
|
||||||
@@ -122,6 +124,19 @@ export const FEATURES: FeatureConfig[] = [
|
|||||||
tab: JwtPage,
|
tab: JwtPage,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'jsonDiff',
|
||||||
|
labelKey: 'features:jsonDiff.title',
|
||||||
|
descriptionKey: 'features:jsonDiff.description',
|
||||||
|
themeColorKey: 'primary',
|
||||||
|
icon: <CompareArrowsIcon sx={{ fontSize: 20 }} />,
|
||||||
|
defaultVisible: true,
|
||||||
|
components: {
|
||||||
|
popup: JsonDiffPage,
|
||||||
|
sidepanel: JsonDiffPage,
|
||||||
|
tab: JsonDiffPage,
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
export function getFeatureByKey(key: PageType): FeatureConfig | undefined {
|
||||||
|
|||||||
+187
-139
@@ -11,6 +11,9 @@ export type ZoneType = (typeof ZONES)[number];
|
|||||||
/**
|
/**
|
||||||
* 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系
|
* 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系
|
||||||
* 所有颜色都经过对比度计算,确保可访问性
|
* 所有颜色都经过对比度计算,确保可访问性
|
||||||
|
*
|
||||||
|
* 注意:这些颜色是品牌色源,实际组件应优先使用 theme.palette.* 令牌,
|
||||||
|
* 以便在亮色/暗色模式下自动切换。
|
||||||
*/
|
*/
|
||||||
export const THEME_COLORS = {
|
export const THEME_COLORS = {
|
||||||
// 主要颜色 - 蓝色系
|
// 主要颜色 - 蓝色系
|
||||||
@@ -66,11 +69,10 @@ export const STATUS_COLORS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全局样式配置
|
* 暗色模式下自动加深 alpha 值的辅助函数
|
||||||
*/
|
*/
|
||||||
export const globalStyles = {
|
export const surfaceTint = (theme: Theme, color: string, baseAlpha: number) =>
|
||||||
backgroundColor: '#f5f5f5',
|
alpha(color, theme.palette.mode === 'dark' ? Math.min(baseAlpha + 0.1, 0.9) : baseAlpha);
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 时间戳转换页面样式
|
* 时间戳转换页面样式
|
||||||
@@ -82,18 +84,18 @@ export const timestampPageStyles = {
|
|||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'divider',
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
'& fieldset': { border: 'none' },
|
'& fieldset': { border: 'none' },
|
||||||
'&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' },
|
'&:hover': { borderColor: 'action.active', bgcolor: 'action.hover' },
|
||||||
'&.Mui-focused': {
|
'&.Mui-focused': {
|
||||||
bgcolor: '#fff',
|
bgcolor: 'background.paper',
|
||||||
borderColor: 'primary.main',
|
borderColor: 'primary.main',
|
||||||
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.primary.main}1a`,
|
boxShadow: (theme: Theme) => `0 0 0 4px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||||
},
|
},
|
||||||
'&.Mui-error': {
|
'&.Mui-error': {
|
||||||
borderColor: 'error.main',
|
borderColor: 'error.main',
|
||||||
boxShadow: (theme: Theme) => `0 0 0 4px ${theme.palette.error.main}1a`,
|
boxShadow: (theme: Theme) => `0 0 0 4px ${alpha(theme.palette.error.main, 0.1)}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'& .MuiInputBase-input': {
|
'& .MuiInputBase-input': {
|
||||||
@@ -109,21 +111,21 @@ export const timestampPageStyles = {
|
|||||||
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
|
sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cardBg: alpha(THEME_COLORS.primary, 0.04),
|
cardBg: (theme: Theme) => alpha(theme.palette.primary.main, 0.04),
|
||||||
cardBorder: alpha(THEME_COLORS.primary, 0.1),
|
cardBorder: (theme: Theme) => alpha(theme.palette.primary.main, 0.1),
|
||||||
switcherBg: alpha(THEME_COLORS.primary, 0.08),
|
switcherBg: (theme: Theme) => alpha(theme.palette.primary.main, 0.08),
|
||||||
switcherBorder: alpha(THEME_COLORS.primary, 0.1),
|
switcherBorder: (theme: Theme) => alpha(theme.palette.primary.main, 0.1),
|
||||||
mutedText: alpha(THEME_COLORS.primary, 0.4),
|
mutedText: (theme: Theme) => alpha(theme.palette.primary.main, 0.4),
|
||||||
resultBg: alpha(THEME_COLORS.primary, 0.05),
|
resultBg: (theme: Theme) => alpha(theme.palette.primary.main, 0.05),
|
||||||
buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`,
|
buttonHover: (theme: Theme) => `0 8px 24px ${alpha(theme.palette.primary.main, 0.2)}`,
|
||||||
/** 模式切换器 (ToggleButtonGroup) 样式 */
|
/** 模式切换器 (ToggleButtonGroup) 样式 */
|
||||||
MODE_SWITCHER: {
|
MODE_SWITCHER: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
mb: 2.5,
|
mb: 2.5,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: 'grey.100',
|
bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'),
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'grey.200',
|
borderColor: 'divider',
|
||||||
p: 0.6,
|
p: 0.6,
|
||||||
'& .MuiToggleButtonGroup-grouped': {
|
'& .MuiToggleButtonGroup-grouped': {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -135,7 +137,7 @@ export const timestampPageStyles = {
|
|||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
transition: 'color 0.3s',
|
transition: 'color 0.3s',
|
||||||
'&.Mui-selected': {
|
'&.Mui-selected': {
|
||||||
bgcolor: '#fff',
|
bgcolor: 'background.paper',
|
||||||
color: 'primary.main',
|
color: 'primary.main',
|
||||||
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
|
||||||
},
|
},
|
||||||
@@ -145,11 +147,11 @@ export const timestampPageStyles = {
|
|||||||
UNIT_SWITCHER_CONTAINER: {
|
UNIT_SWITCHER_CONTAINER: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
bgcolor: 'grey.50',
|
bgcolor: 'action.hover',
|
||||||
p: 0.5,
|
p: 0.5,
|
||||||
borderRadius: 3.5,
|
borderRadius: 3.5,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'divider',
|
||||||
},
|
},
|
||||||
UNIT_SWITCHER_ITEM: (active: boolean) => ({
|
UNIT_SWITCHER_ITEM: (active: boolean) => ({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -160,12 +162,12 @@ export const timestampPageStyles = {
|
|||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
bgcolor: active ? '#fff' : 'transparent',
|
bgcolor: active ? 'background.paper' : 'transparent',
|
||||||
color: active ? 'primary.main' : 'text.disabled',
|
color: active ? 'primary.main' : 'text.disabled',
|
||||||
boxShadow: active ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
|
boxShadow: active ? '0 2px 8px rgba(0,0,0,0.05)' : 'none',
|
||||||
}),
|
}),
|
||||||
/** LiveClock 卡片样式 */
|
/** LiveClock 卡片样式 */
|
||||||
LIVE_CLOCK_CARD: {
|
LIVE_CLOCK_CARD: (theme: Theme) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
@@ -173,13 +175,13 @@ export const timestampPageStyles = {
|
|||||||
gap: 1.5,
|
gap: 1.5,
|
||||||
p: 1.8,
|
p: 1.8,
|
||||||
mb: 2.5,
|
mb: 2.5,
|
||||||
bgcolor: alpha(THEME_COLORS.primary, 0.04),
|
bgcolor: alpha(theme.palette.primary.main, 0.04),
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: alpha(THEME_COLORS.primary, 0.1),
|
borderColor: alpha(theme.palette.primary.main, 0.1),
|
||||||
},
|
}),
|
||||||
LIVE_CLOCK_LABEL: {
|
LIVE_CLOCK_LABEL: {
|
||||||
color: THEME_COLORS.primary,
|
color: 'primary.main',
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
fontSize: '0.6rem',
|
fontSize: '0.6rem',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
@@ -187,21 +189,21 @@ export const timestampPageStyles = {
|
|||||||
},
|
},
|
||||||
LIVE_CLOCK_VALUE: {
|
LIVE_CLOCK_VALUE: {
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
color: THEME_COLORS.primary,
|
color: 'primary.main',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: { xs: '1.1rem', sm: '1.2rem' },
|
fontSize: { xs: '1.1rem', sm: '1.2rem' },
|
||||||
letterSpacing: '-0.5px',
|
letterSpacing: '-0.5px',
|
||||||
lineHeight: 1.2,
|
lineHeight: 1.2,
|
||||||
},
|
},
|
||||||
LIVE_CLOCK_UNIT_SWITCHER: {
|
LIVE_CLOCK_UNIT_SWITCHER: (theme: Theme) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
p: 0.4,
|
p: 0.4,
|
||||||
bgcolor: alpha(THEME_COLORS.primary, 0.08),
|
bgcolor: alpha(theme.palette.primary.main, 0.08),
|
||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: alpha(THEME_COLORS.primary, 0.1),
|
borderColor: alpha(theme.palette.primary.main, 0.1),
|
||||||
},
|
}),
|
||||||
LIVE_CLOCK_UNIT_ITEM: (active: boolean) => ({
|
LIVE_CLOCK_UNIT_ITEM: (active: boolean) => (theme: Theme) => ({
|
||||||
px: { xs: 1, sm: 1.2 },
|
px: { xs: 1, sm: 1.2 },
|
||||||
py: 0.35,
|
py: 0.35,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
@@ -209,20 +211,20 @@ export const timestampPageStyles = {
|
|||||||
fontSize: '0.65rem',
|
fontSize: '0.65rem',
|
||||||
fontWeight: 900,
|
fontWeight: 900,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
bgcolor: active ? '#fff' : 'transparent',
|
bgcolor: active ? 'background.paper' : 'transparent',
|
||||||
color: active ? 'primary.main' : alpha(THEME_COLORS.primary, 0.4),
|
color: active ? 'primary.main' : alpha(theme.palette.primary.main, 0.4),
|
||||||
boxShadow: active ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
|
boxShadow: active ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none',
|
||||||
}),
|
}),
|
||||||
LIVE_CLOCK_ICON_BUTTON: {
|
LIVE_CLOCK_ICON_BUTTON: {
|
||||||
color: THEME_COLORS.primary,
|
color: 'primary.main',
|
||||||
bgcolor: '#fff',
|
bgcolor: 'background.paper',
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
boxShadow: '0 2px 4px rgba(0,0,0,0.05)',
|
||||||
'&:hover': { bgcolor: THEME_COLORS.primary, color: '#fff' },
|
'&:hover': { bgcolor: 'primary.main', color: 'primary.contrastText' },
|
||||||
},
|
},
|
||||||
LIVE_CLOCK_DIVIDER: {
|
LIVE_CLOCK_DIVIDER: {
|
||||||
mx: 0.5,
|
mx: 0.5,
|
||||||
my: 1,
|
my: 1,
|
||||||
borderColor: alpha(THEME_COLORS.primary, 0.1),
|
borderColor: (theme: Theme) => alpha(theme.palette.primary.main, 0.1),
|
||||||
},
|
},
|
||||||
/** ResultView 样式 */
|
/** ResultView 样式 */
|
||||||
RESULT_LABEL: {
|
RESULT_LABEL: {
|
||||||
@@ -232,33 +234,33 @@ export const timestampPageStyles = {
|
|||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
},
|
},
|
||||||
RESULT_MAIN_BOX: {
|
RESULT_MAIN_BOX: (theme: Theme) => ({
|
||||||
bgcolor: alpha(THEME_COLORS.primary, 0.05),
|
bgcolor: alpha(theme.palette.primary.main, 0.05),
|
||||||
p: 2,
|
p: 2,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
mb: 2.5,
|
mb: 2.5,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: alpha(THEME_COLORS.primary, 0.1),
|
borderColor: alpha(theme.palette.primary.main, 0.1),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
}),
|
||||||
RESULT_MAIN_TEXT: {
|
RESULT_MAIN_TEXT: {
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: THEME_COLORS.primary,
|
color: 'primary.main',
|
||||||
wordBreak: 'break-all',
|
wordBreak: 'break-all',
|
||||||
pr: 4,
|
pr: 4,
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
},
|
},
|
||||||
RESULT_EXTRA_STACK: {
|
RESULT_EXTRA_STACK: (theme: Theme) => ({
|
||||||
bgcolor: alpha(THEME_COLORS.primary, 0.05),
|
bgcolor: alpha(theme.palette.primary.main, 0.05),
|
||||||
p: 2,
|
p: 2,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: alpha(THEME_COLORS.primary, 0.1),
|
borderColor: alpha(theme.palette.primary.main, 0.1),
|
||||||
},
|
}),
|
||||||
RESULT_EXTRA_LABEL: {
|
RESULT_EXTRA_LABEL: {
|
||||||
color: 'text.disabled',
|
color: 'text.disabled',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@@ -267,7 +269,7 @@ export const timestampPageStyles = {
|
|||||||
},
|
},
|
||||||
RESULT_EXTRA_VALUE: {
|
RESULT_EXTRA_VALUE: {
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
color: THEME_COLORS.primary,
|
color: 'primary.main',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '0.65rem',
|
fontSize: '0.65rem',
|
||||||
},
|
},
|
||||||
@@ -279,15 +281,14 @@ export const timestampPageStyles = {
|
|||||||
export const storageCleanerPageStyles = {
|
export const storageCleanerPageStyles = {
|
||||||
warningColor: THEME_COLORS.warning,
|
warningColor: THEME_COLORS.warning,
|
||||||
warningDark: THEME_COLORS.warningDark,
|
warningDark: THEME_COLORS.warningDark,
|
||||||
warningBg: alpha(THEME_COLORS.warning, 0.05),
|
warningBg: (theme: Theme) => surfaceTint(theme, theme.palette.warning.main, 0.05),
|
||||||
warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`,
|
errorBorder: (theme: Theme) => `1px solid ${surfaceTint(theme, theme.palette.error.main, 0.2)}`,
|
||||||
errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
|
errorBg: (theme: Theme) => surfaceTint(theme, theme.palette.error.main, 0.05),
|
||||||
errorBg: alpha(THEME_COLORS.error, 0.05),
|
|
||||||
/** 选项网格容器 */
|
/** 选项网格容器 */
|
||||||
OPTIONS_GRID_CONTAINER: {
|
OPTIONS_GRID_CONTAINER: {
|
||||||
mb: 3,
|
mb: 3,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'divider',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)',
|
||||||
@@ -307,7 +308,7 @@ export const storageCleanerPageStyles = {
|
|||||||
borderBottomRightRadius: 4,
|
borderBottomRightRadius: 4,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.04)',
|
bgcolor: 'action.hover',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
OPTIONS_GRID_CHECKBOX: {
|
OPTIONS_GRID_CHECKBOX: {
|
||||||
@@ -322,7 +323,7 @@ export const storageCleanerPageStyles = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
/** 选项项 */
|
/** 选项项 */
|
||||||
OPTION_ITEM: (checked: boolean) => ({
|
OPTION_ITEM: (checked: boolean) => (theme: Theme) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -330,10 +331,10 @@ export const storageCleanerPageStyles = {
|
|||||||
px: { xs: 1, sm: 1.5 },
|
px: { xs: 1, sm: 1.5 },
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
bgcolor: checked ? alpha(THEME_COLORS.warning, 0.05) : 'transparent',
|
bgcolor: checked ? surfaceTint(theme, theme.palette.warning.main, 0.05) : 'transparent',
|
||||||
border: `1px solid ${checked ? alpha(THEME_COLORS.warning, 0.2) : 'transparent'}`,
|
border: `1px solid ${checked ? surfaceTint(theme, theme.palette.warning.main, 0.2) : 'transparent'}`,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: checked ? alpha(THEME_COLORS.warning, 0.1) : 'rgba(0, 0, 0, 0.02)',
|
bgcolor: checked ? surfaceTint(theme, theme.palette.warning.main, 0.1) : 'action.hover',
|
||||||
transform: 'translateY(-1px)',
|
transform: 'translateY(-1px)',
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
},
|
},
|
||||||
@@ -346,7 +347,7 @@ export const storageCleanerPageStyles = {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
transition: 'color 0.2s',
|
transition: 'color 0.2s',
|
||||||
color: checked ? THEME_COLORS.warning : 'text.primary',
|
color: checked ? 'warning.main' : 'text.primary',
|
||||||
}),
|
}),
|
||||||
OPTION_ITEM_SIZE: {
|
OPTION_ITEM_SIZE: {
|
||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
@@ -359,7 +360,7 @@ export const storageCleanerPageStyles = {
|
|||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
OPTION_ITEM_NO_DATA: {
|
OPTION_ITEM_NO_DATA: {
|
||||||
color: 'grey.400',
|
color: 'text.disabled',
|
||||||
fontSize: '0.65rem',
|
fontSize: '0.65rem',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
display: 'block',
|
display: 'block',
|
||||||
@@ -384,7 +385,7 @@ export const storageCleanerPageStyles = {
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'divider',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -407,30 +408,30 @@ export const storageCleanerPageStyles = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
/** DomainHeader */
|
/** DomainHeader */
|
||||||
DOMAIN_HEADER_BADGE: {
|
DOMAIN_HEADER_BADGE: (theme: Theme) => ({
|
||||||
bgcolor: alpha(THEME_COLORS.warning, 0.15),
|
bgcolor: surfaceTint(theme, theme.palette.warning.main, 0.15),
|
||||||
color: THEME_COLORS.warning,
|
color: 'warning.main',
|
||||||
px: 1.5,
|
px: 1.5,
|
||||||
py: 0.3,
|
py: 0.3,
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
fontSize: '0.7rem',
|
fontSize: '0.7rem',
|
||||||
boxShadow: `0 2px 4px ${alpha(THEME_COLORS.warning, 0.2)}`,
|
boxShadow: `0 2px 4px ${surfaceTint(theme, theme.palette.warning.main, 0.2)}`,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: alpha(THEME_COLORS.warning, 0.25),
|
bgcolor: surfaceTint(theme, theme.palette.warning.main, 0.25),
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
DOMAIN_HEADER_ICON: {
|
DOMAIN_HEADER_ICON: (theme: Theme) => ({
|
||||||
p: 1.2,
|
p: 1.2,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
boxShadow: `0 2px 8px ${alpha(THEME_COLORS.warning, 0.15)}`,
|
boxShadow: `0 2px 8px ${surfaceTint(theme, theme.palette.warning.main, 0.15)}`,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: alpha(THEME_COLORS.warning, 0.15),
|
bgcolor: surfaceTint(theme, theme.palette.warning.main, 0.15),
|
||||||
transform: 'scale(1.05)',
|
transform: 'scale(1.05)',
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
/** ErrorDisplay */
|
/** ErrorDisplay */
|
||||||
ERROR_DISPLAY_CONTAINER: {
|
ERROR_DISPLAY_CONTAINER: {
|
||||||
py: 8,
|
py: 8,
|
||||||
@@ -440,17 +441,18 @@ export const storageCleanerPageStyles = {
|
|||||||
minHeight: { xs: 'auto', sm: '400px' },
|
minHeight: { xs: 'auto', sm: '400px' },
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
ERROR_DISPLAY_BOX: {
|
ERROR_DISPLAY_BOX: (theme: Theme) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
p: 4,
|
p: 4,
|
||||||
boxShadow: `0 8px 24px ${alpha(THEME_COLORS.error, 0.15)}`,
|
boxShadow: `0 8px 24px ${surfaceTint(theme, theme.palette.error.main, 0.15)}`,
|
||||||
border: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`,
|
border: '1px solid',
|
||||||
bgcolor: alpha(THEME_COLORS.error, 0.05),
|
borderColor: surfaceTint(theme, theme.palette.error.main, 0.2),
|
||||||
},
|
bgcolor: surfaceTint(theme, theme.palette.error.main, 0.05),
|
||||||
|
}),
|
||||||
/** CleaningResult */
|
/** CleaningResult */
|
||||||
CLEANING_RESULT_ALERT: {
|
CLEANING_RESULT_ALERT: {
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
@@ -471,7 +473,7 @@ export const storageCleanerPageStyles = {
|
|||||||
CONFIRM_DIALOG_PAPER: {
|
CONFIRM_DIALOG_PAPER: {
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
backgroundImage: 'none',
|
backgroundImage: 'none',
|
||||||
boxShadow: `0 24px 64px -12px ${alpha(THEME_COLORS.black, 0.18)}`,
|
boxShadow: '0 24px 64px -12px rgba(0, 0, 0, 0.18)',
|
||||||
p: 1.5,
|
p: 1.5,
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
},
|
},
|
||||||
@@ -493,29 +495,29 @@ export const storageCleanerPageStyles = {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
},
|
},
|
||||||
CONFIRM_DIALOG_CHIP: {
|
CONFIRM_DIALOG_CHIP: (theme: Theme) => ({
|
||||||
bgcolor: alpha(THEME_COLORS.warning, 0.04),
|
bgcolor: surfaceTint(theme, theme.palette.warning.main, 0.04),
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: THEME_COLORS.warning,
|
color: 'warning.main',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: alpha(THEME_COLORS.warning, 0.15),
|
borderColor: surfaceTint(theme, theme.palette.warning.main, 0.15),
|
||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
'& .MuiChip-label': { px: 1.2, py: 0.6 },
|
'& .MuiChip-label': { px: 1.2, py: 0.6 },
|
||||||
},
|
}),
|
||||||
CONFIRM_DIALOG_WARNING_BOX: {
|
CONFIRM_DIALOG_WARNING_BOX: (theme: Theme) => ({
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 1,
|
gap: 1,
|
||||||
bgcolor: alpha(THEME_COLORS.error, 0.05),
|
bgcolor: surfaceTint(theme, theme.palette.error.main, 0.05),
|
||||||
color: THEME_COLORS.error,
|
color: 'error.main',
|
||||||
px: 2,
|
px: 2,
|
||||||
py: 0.8,
|
py: 0.8,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
border: '1px dashed',
|
border: '1px dashed',
|
||||||
borderColor: alpha(THEME_COLORS.error, 0.2),
|
borderColor: surfaceTint(theme, theme.palette.error.main, 0.2),
|
||||||
},
|
}),
|
||||||
CONFIRM_DIALOG_WARNING_TEXT: {
|
CONFIRM_DIALOG_WARNING_TEXT: {
|
||||||
fontWeight: 800,
|
fontWeight: 800,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -527,29 +529,23 @@ export const storageCleanerPageStyles = {
|
|||||||
boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)',
|
||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: 'grey.100',
|
bgcolor: 'action.hover',
|
||||||
color: 'text.primary',
|
color: 'text.primary',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
CONFIRM_DIALOG_CONFIRM: {
|
CONFIRM_DIALOG_CONFIRM: {
|
||||||
bgcolor: THEME_COLORS.warning,
|
bgcolor: 'warning.main',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: THEME_COLORS.warningDark,
|
bgcolor: 'warning.dark',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 二维码工具页面样式
|
* 二维码工具页面样式
|
||||||
* 注意:保留 successColor 和 successDark 以保持向后兼容性
|
|
||||||
*/
|
*/
|
||||||
export const qrCodePageStyles = {
|
export const qrCodePageStyles = {
|
||||||
primaryColor: THEME_COLORS.success,
|
primaryColor: THEME_COLORS.success,
|
||||||
primaryDark: THEME_COLORS.successDark,
|
|
||||||
successColor: THEME_COLORS.success,
|
|
||||||
successDark: THEME_COLORS.successDark,
|
|
||||||
white: THEME_COLORS.white,
|
|
||||||
black: THEME_COLORS.black,
|
|
||||||
/** 加载状态容器 */
|
/** 加载状态容器 */
|
||||||
LOADING_CONTAINER: {
|
LOADING_CONTAINER: {
|
||||||
py: 4,
|
py: 4,
|
||||||
@@ -580,10 +576,10 @@ export const qrCodePageStyles = {
|
|||||||
PRIMARY_BUTTON: {
|
PRIMARY_BUTTON: {
|
||||||
py: 1.2,
|
py: 1.2,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
bgcolor: THEME_COLORS.success,
|
bgcolor: 'success.main',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: THEME_COLORS.successDark,
|
bgcolor: 'success.dark',
|
||||||
},
|
},
|
||||||
} as const,
|
} as const,
|
||||||
/** 二维码展示区域 */
|
/** 二维码展示区域 */
|
||||||
@@ -594,10 +590,10 @@ export const qrCodePageStyles = {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
minHeight: 200,
|
minHeight: 200,
|
||||||
border: '2px dashed',
|
border: '2px dashed',
|
||||||
borderColor: 'grey.200',
|
borderColor: 'divider',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
p: 2,
|
p: 2,
|
||||||
bgcolor: 'grey.50',
|
bgcolor: 'action.hover',
|
||||||
} as const,
|
} as const,
|
||||||
QR_PREVIEW_INNER: {
|
QR_PREVIEW_INNER: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -618,23 +614,23 @@ export const qrCodePageStyles = {
|
|||||||
/** 下载按钮 */
|
/** 下载按钮 */
|
||||||
DOWNLOAD_BUTTON: {
|
DOWNLOAD_BUTTON: {
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
borderColor: THEME_COLORS.success,
|
borderColor: 'success.main',
|
||||||
color: THEME_COLORS.success,
|
color: 'success.main',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: THEME_COLORS.successDark,
|
borderColor: 'success.dark',
|
||||||
bgcolor: alpha(THEME_COLORS.success, 0.05),
|
bgcolor: (theme: Theme) => alpha(theme.palette.success.main, 0.05),
|
||||||
},
|
},
|
||||||
} as const,
|
} as const,
|
||||||
/** 复制按钮 */
|
/** 复制按钮 */
|
||||||
COPY_BUTTON: {
|
COPY_BUTTON: {
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
bgcolor: THEME_COLORS.success,
|
bgcolor: 'success.main',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: THEME_COLORS.successDark,
|
bgcolor: 'success.dark',
|
||||||
},
|
},
|
||||||
} as const,
|
} as const,
|
||||||
/** 拖拽上传区域 */
|
/** 拖拽上传区域 */
|
||||||
DROPZONE: (dragging: boolean, hasFile: boolean) =>
|
DROPZONE: (dragging: boolean, hasFile: boolean) => (theme: Theme) =>
|
||||||
({
|
({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -642,19 +638,19 @@ export const qrCodePageStyles = {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
minHeight: 200,
|
minHeight: 200,
|
||||||
border: '2px dashed',
|
border: '2px dashed',
|
||||||
borderColor: dragging || hasFile ? THEME_COLORS.success : 'grey.200',
|
borderColor: dragging || hasFile ? 'success.main' : 'divider',
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
p: 4,
|
p: 4,
|
||||||
bgcolor: dragging
|
bgcolor: dragging
|
||||||
? alpha(THEME_COLORS.success, 0.1)
|
? alpha(theme.palette.success.main, 0.1)
|
||||||
: hasFile
|
: hasFile
|
||||||
? alpha(THEME_COLORS.success, 0.05)
|
? alpha(theme.palette.success.main, 0.05)
|
||||||
: 'grey.50',
|
: 'action.hover',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: THEME_COLORS.success,
|
borderColor: 'success.main',
|
||||||
bgcolor: alpha(THEME_COLORS.success, 0.05),
|
bgcolor: alpha(theme.palette.success.main, 0.05),
|
||||||
},
|
},
|
||||||
}) as const,
|
}) as const,
|
||||||
/** 图片预览容器 */
|
/** 图片预览容器 */
|
||||||
@@ -674,16 +670,16 @@ export const qrCodePageStyles = {
|
|||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
} as const,
|
} as const,
|
||||||
/** 清除按钮 */
|
/** 清除按钮 */
|
||||||
CLEAR_BUTTON: {
|
CLEAR_BUTTON: (theme: Theme) => ({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: -8,
|
top: -8,
|
||||||
right: -8,
|
right: -8,
|
||||||
bgcolor: alpha(THEME_COLORS.error, 0.9),
|
bgcolor: alpha(theme.palette.error.main, 0.9),
|
||||||
color: 'white',
|
color: 'white',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: alpha(THEME_COLORS.errorDark, 0.95),
|
bgcolor: 'error.dark',
|
||||||
},
|
},
|
||||||
} as const,
|
}),
|
||||||
/** 结果输入框 */
|
/** 结果输入框 */
|
||||||
RESULT_INPUT: {
|
RESULT_INPUT: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -700,9 +696,6 @@ export const qrCodePageStyles = {
|
|||||||
* 仪表盘页面样式
|
* 仪表盘页面样式
|
||||||
*/
|
*/
|
||||||
export const dashboardPageStyles = {
|
export const dashboardPageStyles = {
|
||||||
primaryColor: THEME_COLORS.primary,
|
|
||||||
backgroundColor: '#f5f5f5',
|
|
||||||
cardBackgroundColor: '#ffffff',
|
|
||||||
GRID_CONTAINER: {
|
GRID_CONTAINER: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: {
|
gridTemplateColumns: {
|
||||||
@@ -719,14 +712,14 @@ export const dashboardPageStyles = {
|
|||||||
* 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除)
|
* 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除)
|
||||||
*/
|
*/
|
||||||
export const formRecognizerPageStyles = {
|
export const formRecognizerPageStyles = {
|
||||||
primaryColor: '#ff5722',
|
primaryColor: 'warning.main',
|
||||||
validColor: THEME_COLORS.success,
|
validColor: 'success.main',
|
||||||
validDark: THEME_COLORS.successDark,
|
validDark: 'success.dark',
|
||||||
invalidColor: THEME_COLORS.warning,
|
invalidColor: 'warning.main',
|
||||||
invalidDark: THEME_COLORS.warningDark,
|
invalidDark: 'warning.dark',
|
||||||
clearColor: THEME_COLORS.error,
|
clearColor: 'error.main',
|
||||||
clearDark: THEME_COLORS.errorDark,
|
clearDark: 'error.dark',
|
||||||
clearBg: alpha(THEME_COLORS.error, 0.05),
|
clearBg: (theme: Theme) => surfaceTint(theme, theme.palette.error.main, 0.05),
|
||||||
buttonStyle: {
|
buttonStyle: {
|
||||||
py: 1.2,
|
py: 1.2,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
@@ -738,7 +731,7 @@ export const formRecognizerPageStyles = {
|
|||||||
* 表单映射页面样式
|
* 表单映射页面样式
|
||||||
*/
|
*/
|
||||||
export const formMappingPageStyles = {
|
export const formMappingPageStyles = {
|
||||||
secondaryColor: THEME_COLORS.purple,
|
secondaryColor: 'secondary.main',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -746,13 +739,10 @@ export const formMappingPageStyles = {
|
|||||||
*/
|
*/
|
||||||
export const textStatisticsPageStyles = {
|
export const textStatisticsPageStyles = {
|
||||||
primaryColor: THEME_COLORS.purple,
|
primaryColor: THEME_COLORS.purple,
|
||||||
cardBg: alpha(THEME_COLORS.purple, 0.04),
|
cardBg: (theme: Theme) => alpha(theme.palette.secondary.main, 0.04),
|
||||||
cardBorder: alpha(THEME_COLORS.purple, 0.1),
|
cardBorder: (theme: Theme) => alpha(theme.palette.secondary.main, 0.1),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
|
||||||
* JWT 解析工具页面样式
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* TopBar 组件样式
|
* TopBar 组件样式
|
||||||
*/
|
*/
|
||||||
@@ -770,8 +760,8 @@ export const topBarStyles = {
|
|||||||
*/
|
*/
|
||||||
export const jwtPageStyles = {
|
export const jwtPageStyles = {
|
||||||
primaryColor: THEME_COLORS.indigo,
|
primaryColor: THEME_COLORS.indigo,
|
||||||
cardBg: alpha(THEME_COLORS.indigo, 0.04),
|
cardBg: (theme: Theme) => alpha(theme.palette.info.main, 0.04),
|
||||||
cardBorder: alpha(THEME_COLORS.indigo, 0.1),
|
cardBorder: (theme: Theme) => alpha(theme.palette.info.main, 0.1),
|
||||||
INPUT_STYLE: {
|
INPUT_STYLE: {
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
@@ -779,8 +769,66 @@ export const jwtPageStyles = {
|
|||||||
fontSize: '0.85rem',
|
fontSize: '0.85rem',
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'&:hover': { bgcolor: 'grey.50' },
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
'&.Mui-focused': { bgcolor: '#fff' },
|
'&.Mui-focused': { bgcolor: 'background.paper' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 差异比较工具页面样式
|
||||||
|
*/
|
||||||
|
export const jsonDiffPageStyles = {
|
||||||
|
primaryColor: THEME_COLORS.primary,
|
||||||
|
addedBg: (theme: Theme) => surfaceTint(theme, theme.palette.success.main, 0.15),
|
||||||
|
addedBorder: (theme: Theme) => surfaceTint(theme, theme.palette.success.main, 0.4),
|
||||||
|
addedText: 'success.main',
|
||||||
|
removedBg: (theme: Theme) => surfaceTint(theme, theme.palette.error.main, 0.15),
|
||||||
|
removedBorder: (theme: Theme) => surfaceTint(theme, theme.palette.error.main, 0.4),
|
||||||
|
removedText: 'error.main',
|
||||||
|
modifiedBg: (theme: Theme) => surfaceTint(theme, theme.palette.warning.main, 0.15),
|
||||||
|
modifiedBorder: (theme: Theme) => surfaceTint(theme, theme.palette.warning.main, 0.4),
|
||||||
|
modifiedText: 'warning.main',
|
||||||
|
INPUT_STYLE: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
borderRadius: 3,
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
'&.Mui-focused': {
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: (theme: Theme) => `0 0 0 4px ${alpha(theme.palette.primary.main, 0.1)}`,
|
||||||
|
},
|
||||||
|
'&.Mui-error': {
|
||||||
|
boxShadow: (theme: Theme) => `0 0 0 4px ${alpha(theme.palette.error.main, 0.1)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TREE_CONTAINER: {
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
NAVIGATOR: (theme: Theme) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.05),
|
||||||
|
border: '1px solid',
|
||||||
|
borderColor: alpha(theme.palette.primary.main, 0.15),
|
||||||
|
}),
|
||||||
|
} as const;
|
||||||
|
|||||||
+41
-19
@@ -1,37 +1,55 @@
|
|||||||
import { createTheme } from '@mui/material/styles';
|
import { createTheme, type PaletteMode, type Theme } from '@mui/material/styles';
|
||||||
import { THEME_COLORS } from './pageTheme';
|
import { THEME_COLORS } from './pageTheme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一的 MUI 主题配置
|
* 按模式生成 MUI 主题
|
||||||
* 整合了原有的外部 CSS 样式(滚动条、动画、基础重置)
|
*
|
||||||
|
* - light:使用 THEME_COLORS 中饱和度较高的版本作为 main
|
||||||
|
* - dark:使用 *Light 变体作为 main,保证暗底对比度满足 WCAG AA
|
||||||
|
*
|
||||||
|
* 所有调色板槽位均显式指定,不依赖 MUI 默认值。
|
||||||
*/
|
*/
|
||||||
const theme = createTheme({
|
export function getTheme(mode: PaletteMode): Theme {
|
||||||
|
const isDark = mode === 'dark';
|
||||||
|
|
||||||
|
return createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
|
mode,
|
||||||
primary: {
|
primary: {
|
||||||
main: THEME_COLORS.primary,
|
main: isDark ? THEME_COLORS.primaryLight : THEME_COLORS.primary,
|
||||||
dark: THEME_COLORS.primaryDark,
|
dark: THEME_COLORS.primaryDark,
|
||||||
light: THEME_COLORS.primaryLight,
|
light: THEME_COLORS.primaryLight,
|
||||||
},
|
},
|
||||||
|
secondary: {
|
||||||
|
main: isDark ? THEME_COLORS.purpleLight : THEME_COLORS.purple,
|
||||||
|
dark: THEME_COLORS.purpleDark,
|
||||||
|
light: THEME_COLORS.purpleLight,
|
||||||
|
},
|
||||||
success: {
|
success: {
|
||||||
main: THEME_COLORS.success,
|
main: isDark ? THEME_COLORS.successLight : THEME_COLORS.success,
|
||||||
dark: THEME_COLORS.successDark,
|
dark: THEME_COLORS.successDark,
|
||||||
light: THEME_COLORS.successLight,
|
light: THEME_COLORS.successLight,
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
main: THEME_COLORS.warning,
|
main: isDark ? THEME_COLORS.warningLight : THEME_COLORS.warning,
|
||||||
dark: THEME_COLORS.warningDark,
|
dark: THEME_COLORS.warningDark,
|
||||||
light: THEME_COLORS.warningLight,
|
light: THEME_COLORS.warningLight,
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
main: THEME_COLORS.error,
|
main: isDark ? THEME_COLORS.errorLight : THEME_COLORS.error,
|
||||||
dark: THEME_COLORS.errorDark,
|
dark: THEME_COLORS.errorDark,
|
||||||
light: THEME_COLORS.errorLight,
|
light: THEME_COLORS.errorLight,
|
||||||
},
|
},
|
||||||
secondary: {
|
info: {
|
||||||
main: THEME_COLORS.purple,
|
main: isDark ? THEME_COLORS.indigoLight : THEME_COLORS.indigo,
|
||||||
dark: THEME_COLORS.purpleDark,
|
dark: THEME_COLORS.indigoDark,
|
||||||
light: THEME_COLORS.purpleLight,
|
light: THEME_COLORS.indigoLight,
|
||||||
},
|
},
|
||||||
|
background: {
|
||||||
|
default: isDark ? '#121212' : '#f5f5f5',
|
||||||
|
paper: isDark ? '#1e1e1e' : '#ffffff',
|
||||||
|
},
|
||||||
|
divider: isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)',
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: [
|
fontFamily: [
|
||||||
@@ -49,11 +67,13 @@ const theme = createTheme({
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
MuiCssBaseline: {
|
MuiCssBaseline: {
|
||||||
styleOverrides: {
|
styleOverrides: (theme) => ({
|
||||||
':root': {
|
':root': {
|
||||||
'--sb-width': '6px',
|
'--sb-width': '6px',
|
||||||
'--sb-thumb-color': 'rgba(0, 0, 0, 0.1)',
|
'--sb-thumb-color':
|
||||||
'--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)',
|
theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.1)',
|
||||||
|
'--sb-thumb-hover':
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.25)' : 'rgba(0, 0, 0, 0.2)',
|
||||||
'--sb-track-color': 'transparent',
|
'--sb-track-color': 'transparent',
|
||||||
},
|
},
|
||||||
html: {
|
html: {
|
||||||
@@ -61,7 +81,7 @@ const theme = createTheme({
|
|||||||
padding: 0,
|
padding: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: '100%',
|
minHeight: '100%',
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: theme.palette.background.default,
|
||||||
},
|
},
|
||||||
'body, #root': {
|
'body, #root': {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
@@ -69,11 +89,11 @@ const theme = createTheme({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: '100%',
|
minHeight: '100%',
|
||||||
},
|
},
|
||||||
// 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理,
|
|
||||||
// 这里提供全局基础,具体尺寸在 App 容器中限制)
|
|
||||||
body: {
|
body: {
|
||||||
WebkitFontSmoothing: 'antialiased',
|
WebkitFontSmoothing: 'antialiased',
|
||||||
MozOsxFontSmoothing: 'grayscale',
|
MozOsxFontSmoothing: 'grayscale',
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
color: theme.palette.text.primary,
|
||||||
},
|
},
|
||||||
code: {
|
code: {
|
||||||
fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace',
|
||||||
@@ -123,9 +143,11 @@ const theme = createTheme({
|
|||||||
'.page-transition-dashboard': {
|
'.page-transition-dashboard': {
|
||||||
animation: 'fadeIn 0.3s ease-out forwards',
|
animation: 'fadeIn 0.3s ease-out forwards',
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = getTheme('light');
|
||||||
export default theme;
|
export default theme;
|
||||||
|
|||||||
+35
-18
@@ -28,8 +28,13 @@ import {
|
|||||||
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import { THEME_COLORS } from '@/config/pageTheme';
|
import { useTheme, type PaletteColor, type Theme } from '@mui/material/styles';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { PaletteColorKey } from '@/config/features';
|
||||||
|
|
||||||
|
/** 安全地从 theme.palette 中取 PaletteColor */
|
||||||
|
const getPaletteColor = (theme: Theme, key: PaletteColorKey): PaletteColor =>
|
||||||
|
(theme.palette as unknown as Record<string, PaletteColor>)[key];
|
||||||
|
|
||||||
type WindowType = 'popup' | 'sidepanel' | 'tab';
|
type WindowType = 'popup' | 'sidepanel' | 'tab';
|
||||||
|
|
||||||
@@ -38,6 +43,7 @@ type WindowType = 'popup' | 'sidepanel' | 'tab';
|
|||||||
* 支持对不同窗口入口的功能显示和排序进行独立配置
|
* 支持对不同窗口入口的功能显示和排序进行独立配置
|
||||||
*/
|
*/
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const theme = useTheme();
|
||||||
const { t } = useTranslation(['features', 'common']);
|
const { t } = useTranslation(['features', 'common']);
|
||||||
// 从 URL 参数中初始化当前的 Tab 类型
|
// 从 URL 参数中初始化当前的 Tab 类型
|
||||||
const initialWindowType = useMemo(() => {
|
const initialWindowType = useMemo(() => {
|
||||||
@@ -204,7 +210,7 @@ export default function App() {
|
|||||||
className="app"
|
className="app"
|
||||||
sx={{
|
sx={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
bgcolor: 'grey.50',
|
bgcolor: 'background.default',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
@@ -216,7 +222,7 @@ export default function App() {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid',
|
||||||
borderColor: 'grey.200',
|
borderColor: 'divider',
|
||||||
pt: { xs: 3, sm: 5 },
|
pt: { xs: 3, sm: 5 },
|
||||||
pb: 0,
|
pb: 0,
|
||||||
px: { xs: 2, sm: 4 },
|
px: { xs: 2, sm: 4 },
|
||||||
@@ -225,7 +231,7 @@ export default function App() {
|
|||||||
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
|
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={<SettingsIcon />}
|
icon={<SettingsIcon />}
|
||||||
iconColor={THEME_COLORS.primary}
|
iconColor={theme.palette.primary.main}
|
||||||
title="应用设置"
|
title="应用设置"
|
||||||
subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
|
subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序"
|
||||||
sx={{ mb: 4 }}
|
sx={{ mb: 4 }}
|
||||||
@@ -270,12 +276,12 @@ export default function App() {
|
|||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
textTransform: 'none',
|
textTransform: 'none',
|
||||||
borderColor: 'grey.200',
|
borderColor: 'divider',
|
||||||
color: 'text.secondary',
|
color: 'text.secondary',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: 'primary.main',
|
borderColor: 'primary.main',
|
||||||
color: 'primary.main',
|
color: 'primary.main',
|
||||||
bgcolor: alpha(THEME_COLORS.primary, 0.04),
|
bgcolor: alpha(theme.palette.primary.main, 0.04),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -293,7 +299,7 @@ export default function App() {
|
|||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'grey.200',
|
borderColor: 'divider',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
bgcolor: 'background.paper',
|
bgcolor: 'background.paper',
|
||||||
boxShadow: '0 4px 24px rgba(0,0,0,0.03)',
|
boxShadow: '0 4px 24px rgba(0,0,0,0.03)',
|
||||||
@@ -316,10 +322,13 @@ export default function App() {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
p: { xs: 2, sm: 2.5 },
|
p: { xs: 2, sm: 2.5 },
|
||||||
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
|
borderBottom: index === array.length - 1 ? 'none' : '1px solid',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'divider',
|
||||||
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
bgcolor: alpha(feature.themeColor || THEME_COLORS.primary, 0.02),
|
bgcolor: alpha(
|
||||||
|
getPaletteColor(theme, feature.themeColorKey ?? 'primary').main,
|
||||||
|
0.02,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -330,7 +339,7 @@ export default function App() {
|
|||||||
sx={{ flex: 1, minWidth: 0 }}
|
sx={{ flex: 1, minWidth: 0 }}
|
||||||
>
|
>
|
||||||
{/* 拖拽/排序暗示图标 */}
|
{/* 拖拽/排序暗示图标 */}
|
||||||
<Box sx={{ color: 'grey.300', display: 'flex' }}>
|
<Box sx={{ color: 'text.disabled', display: 'flex' }}>
|
||||||
<DragIndicatorIcon fontSize="small" />
|
<DragIndicatorIcon fontSize="small" />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -339,8 +348,12 @@ export default function App() {
|
|||||||
sx={{
|
sx={{
|
||||||
p: 1.2,
|
p: 1.2,
|
||||||
borderRadius: 2.5,
|
borderRadius: 2.5,
|
||||||
bgcolor: alpha(feature.themeColor || THEME_COLORS.primary, 0.1),
|
bgcolor: alpha(
|
||||||
color: feature.themeColor || THEME_COLORS.primary,
|
getPaletteColor(theme, feature.themeColorKey ?? 'primary').main,
|
||||||
|
0.1,
|
||||||
|
),
|
||||||
|
color: getPaletteColor(theme, feature.themeColorKey ?? 'primary')
|
||||||
|
.main,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
@@ -386,11 +399,11 @@ export default function App() {
|
|||||||
onClick={() => handleMove(index, 'up')}
|
onClick={() => handleMove(index, 'up')}
|
||||||
disabled={index === 0}
|
disabled={index === 0}
|
||||||
sx={{
|
sx={{
|
||||||
color: 'grey.400',
|
color: 'text.disabled',
|
||||||
p: { xs: 0.5, sm: 1 },
|
p: { xs: 0.5, sm: 1 },
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
color: 'primary.main',
|
color: 'primary.main',
|
||||||
bgcolor: alpha(THEME_COLORS.primary, 0.08),
|
bgcolor: alpha(theme.palette.primary.main, 0.08),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -401,11 +414,11 @@ export default function App() {
|
|||||||
onClick={() => handleMove(index, 'down')}
|
onClick={() => handleMove(index, 'down')}
|
||||||
disabled={index === array.length - 1}
|
disabled={index === array.length - 1}
|
||||||
sx={{
|
sx={{
|
||||||
color: 'grey.400',
|
color: 'text.disabled',
|
||||||
p: { xs: 0.5, sm: 1 },
|
p: { xs: 0.5, sm: 1 },
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
color: 'primary.main',
|
color: 'primary.main',
|
||||||
bgcolor: alpha(THEME_COLORS.primary, 0.08),
|
bgcolor: alpha(theme.palette.primary.main, 0.08),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -432,10 +445,14 @@ export default function App() {
|
|||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiSwitch-switchBase.Mui-checked': {
|
'& .MuiSwitch-switchBase.Mui-checked': {
|
||||||
color: feature.themeColor || THEME_COLORS.primary,
|
color: getPaletteColor(theme, feature.themeColorKey ?? 'primary')
|
||||||
|
.main,
|
||||||
},
|
},
|
||||||
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
|
||||||
backgroundColor: feature.themeColor || THEME_COLORS.primary,
|
backgroundColor: getPaletteColor(
|
||||||
|
theme,
|
||||||
|
feature.themeColorKey ?? 'primary',
|
||||||
|
).main,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import AppRoot from '@/providers/AppRoot';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
|
||||||
import theme from '@/config/theme';
|
|
||||||
import '@/i18n';
|
import '@/i18n';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<AppRoot>
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
<App />
|
<App />
|
||||||
</ThemeProvider>
|
</AppRoot>,
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import RouterProvider from '@/providers/RouterProvider';
|
|||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { globalStyles } from '@/config/pageTheme';
|
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import { getEntryPointType } from '@/config/features';
|
import { getEntryPointType } from '@/config/features';
|
||||||
@@ -49,7 +48,7 @@ export default function App() {
|
|||||||
height: '600px',
|
height: '600px',
|
||||||
minHeight: '600px',
|
minHeight: '600px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: globalStyles.backgroundColor,
|
backgroundColor: 'background.default',
|
||||||
// 仅在明确的大屏幕(如独立页面或侧边栏拉伸)下才允许扩展
|
// 仅在明确的大屏幕(如独立页面或侧边栏拉伸)下才允许扩展
|
||||||
'@media screen and (min-width: 600px)': {
|
'@media screen and (min-width: 600px)': {
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import AppRoot from '@/providers/AppRoot';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
|
||||||
import theme from '@/config/theme';
|
|
||||||
import '@/i18n';
|
import '@/i18n';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<AppRoot>
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
<App />
|
<App />
|
||||||
</ThemeProvider>
|
</AppRoot>,
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import AppRoot from '@/providers/AppRoot';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
|
||||||
import theme from '@/config/theme';
|
|
||||||
import '@/i18n';
|
import '@/i18n';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<AppRoot>
|
||||||
<ThemeProvider theme={theme}>
|
|
||||||
<CssBaseline />
|
|
||||||
<App />
|
<App />
|
||||||
</ThemeProvider>
|
</AppRoot>,
|
||||||
</React.StrictMode>,
|
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-1
@@ -19,6 +19,8 @@ import textStatisticsZh from './locales/zh/textStatistics.json';
|
|||||||
import textStatisticsEn from './locales/en/textStatistics.json';
|
import textStatisticsEn from './locales/en/textStatistics.json';
|
||||||
import jwtZh from './locales/zh/jwt.json';
|
import jwtZh from './locales/zh/jwt.json';
|
||||||
import jwtEn from './locales/en/jwt.json';
|
import jwtEn from './locales/en/jwt.json';
|
||||||
|
import jsonDiffZh from './locales/zh/jsonDiff.json';
|
||||||
|
import jsonDiffEn from './locales/en/jsonDiff.json';
|
||||||
|
|
||||||
const resources = {
|
const resources = {
|
||||||
zh: {
|
zh: {
|
||||||
@@ -29,6 +31,7 @@ const resources = {
|
|||||||
qrCode: qrCodeZh,
|
qrCode: qrCodeZh,
|
||||||
textStatistics: textStatisticsZh,
|
textStatistics: textStatisticsZh,
|
||||||
jwt: jwtZh,
|
jwt: jwtZh,
|
||||||
|
jsonDiff: jsonDiffZh,
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
common: commonEn,
|
common: commonEn,
|
||||||
@@ -38,6 +41,7 @@ const resources = {
|
|||||||
qrCode: qrCodeEn,
|
qrCode: qrCodeEn,
|
||||||
textStatistics: textStatisticsEn,
|
textStatistics: textStatisticsEn,
|
||||||
jwt: jwtEn,
|
jwt: jwtEn,
|
||||||
|
jsonDiff: jsonDiffEn,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,7 +104,16 @@ i18n
|
|||||||
resources,
|
resources,
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
lng: syncLng || undefined,
|
lng: syncLng || undefined,
|
||||||
ns: ['common', 'features', 'timestamp', 'storageCleaner', 'qrCode', 'textStatistics', 'jwt'],
|
ns: [
|
||||||
|
'common',
|
||||||
|
'features',
|
||||||
|
'timestamp',
|
||||||
|
'storageCleaner',
|
||||||
|
'qrCode',
|
||||||
|
'textStatistics',
|
||||||
|
'jwt',
|
||||||
|
'jsonDiff',
|
||||||
|
],
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
debug: false,
|
debug: false,
|
||||||
interpolation: {
|
interpolation: {
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"toggleLanguage": "Switch Language",
|
"toggleLanguage": "Switch Language",
|
||||||
|
"toggleTheme": "Toggle theme",
|
||||||
|
"themeMode": {
|
||||||
|
"light": "Switch to dark mode",
|
||||||
|
"dark": "Switch to system mode",
|
||||||
|
"system": "Switch to light mode"
|
||||||
|
},
|
||||||
"search": "Search tools...",
|
"search": "Search tools...",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"clearSearch": "Clear search",
|
"clearSearch": "Clear search",
|
||||||
|
|||||||
@@ -21,5 +21,9 @@
|
|||||||
"jwt": {
|
"jwt": {
|
||||||
"title": "JWT Parser",
|
"title": "JWT Parser",
|
||||||
"description": "JSON Web Token decoding and viewing"
|
"description": "JSON Web Token decoding and viewing"
|
||||||
|
},
|
||||||
|
"jsonDiff": {
|
||||||
|
"title": "JSON Diff",
|
||||||
|
"description": "Compare differences between two JSON values"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "JSON Diff",
|
||||||
|
"pageSubtitle": "Compare differences between two JSON values",
|
||||||
|
"leftPlaceholder": "Enter the original JSON...",
|
||||||
|
"rightPlaceholder": "Enter the target JSON...",
|
||||||
|
"leftLabel": "Original JSON",
|
||||||
|
"rightLabel": "Target JSON",
|
||||||
|
"compareButton": "Compare",
|
||||||
|
"clearButton": "Clear",
|
||||||
|
"sideBySideMode": "Side by side",
|
||||||
|
"unifiedMode": "Unified",
|
||||||
|
"previousDiff": "Previous",
|
||||||
|
"nextDiff": "Next",
|
||||||
|
"noDiffs": "No differences",
|
||||||
|
"diffCount": "{{count}} differences",
|
||||||
|
"invalidJson": "Invalid JSON format",
|
||||||
|
"emptyHint": "Enter JSON on both sides and click Compare",
|
||||||
|
"added": "Added",
|
||||||
|
"removed": "Removed",
|
||||||
|
"modified": "Modified"
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@
|
|||||||
"clear": "清理",
|
"clear": "清理",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"toggleLanguage": "切换语言",
|
"toggleLanguage": "切换语言",
|
||||||
|
"toggleTheme": "切换主题",
|
||||||
|
"themeMode": {
|
||||||
|
"light": "切换到深色模式",
|
||||||
|
"dark": "切换到系统模式",
|
||||||
|
"system": "切换到浅色模式"
|
||||||
|
},
|
||||||
"search": "搜索工具...",
|
"search": "搜索工具...",
|
||||||
"back": "返回",
|
"back": "返回",
|
||||||
"clearSearch": "清除搜索",
|
"clearSearch": "清除搜索",
|
||||||
|
|||||||
@@ -21,5 +21,9 @@
|
|||||||
"jwt": {
|
"jwt": {
|
||||||
"title": "JWT 解析",
|
"title": "JWT 解析",
|
||||||
"description": "JSON Web Token 解码与查看"
|
"description": "JSON Web Token 解码与查看"
|
||||||
|
},
|
||||||
|
"jsonDiff": {
|
||||||
|
"title": "JSON 差异比较",
|
||||||
|
"description": "对比两个 JSON 数据的差异"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "JSON 差异比较",
|
||||||
|
"pageSubtitle": "对比两个 JSON 数据的差异",
|
||||||
|
"leftPlaceholder": "输入原始 JSON...",
|
||||||
|
"rightPlaceholder": "输入目标 JSON...",
|
||||||
|
"leftLabel": "原始 JSON",
|
||||||
|
"rightLabel": "目标 JSON",
|
||||||
|
"compareButton": "比较",
|
||||||
|
"clearButton": "清空",
|
||||||
|
"sideBySideMode": "并排",
|
||||||
|
"unifiedMode": "统一",
|
||||||
|
"previousDiff": "上一个",
|
||||||
|
"nextDiff": "下一个",
|
||||||
|
"noDiffs": "无差异",
|
||||||
|
"diffCount": "{{count}} 处差异",
|
||||||
|
"invalidJson": "无效的 JSON 格式",
|
||||||
|
"emptyHint": "输入两侧 JSON 后点击比较",
|
||||||
|
"added": "新增",
|
||||||
|
"removed": "删除",
|
||||||
|
"modified": "修改"
|
||||||
|
}
|
||||||
@@ -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 ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import type { PaletteColorKey } from '@/config/features';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ToolCard 组件属性接口
|
* ToolCard 组件属性接口
|
||||||
@@ -18,8 +19,8 @@ interface ToolCardProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
/** 快照内容,用于在卡片底部展示额外信息(可选) */
|
/** 快照内容,用于在卡片底部展示额外信息(可选) */
|
||||||
snapshot?: React.ReactNode;
|
snapshot?: React.ReactNode;
|
||||||
/** 主题色代码,用于图标背景和悬停效果 */
|
/** 主题色键,映射到 theme.palette[key].main */
|
||||||
colorCode: string;
|
colorKey: PaletteColorKey;
|
||||||
/** 工具图标元素 */
|
/** 工具图标元素 */
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
/** 卡片点击事件处理函数 */
|
/** 卡片点击事件处理函数 */
|
||||||
@@ -36,10 +37,13 @@ export default function ToolCard({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
snapshot,
|
snapshot,
|
||||||
colorCode,
|
colorKey,
|
||||||
icon,
|
icon,
|
||||||
onClick,
|
onClick,
|
||||||
}: ToolCardProps) {
|
}: ToolCardProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const colorCode = theme.palette[colorKey].main;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@@ -47,7 +51,7 @@ export default function ToolCard({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'divider',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
transition:
|
transition:
|
||||||
@@ -124,7 +128,7 @@ export default function ToolCard({
|
|||||||
className="arrow-icon"
|
className="arrow-icon"
|
||||||
sx={{
|
sx={{
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: 'grey.300',
|
color: 'text.disabled',
|
||||||
mt: 0.5,
|
mt: 0.5,
|
||||||
transition: 'all 0.3s ease',
|
transition: 'all 0.3s ease',
|
||||||
}}
|
}}
|
||||||
@@ -137,7 +141,7 @@ export default function ToolCard({
|
|||||||
mt: 'auto',
|
mt: 'auto',
|
||||||
pt: 1.5,
|
pt: 1.5,
|
||||||
borderTop: '1px dashed',
|
borderTop: '1px dashed',
|
||||||
borderColor: 'grey.100',
|
borderColor: 'divider',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ export default function DashboardPage() {
|
|||||||
if (!visibleSet.has(key as PageType)) return null;
|
if (!visibleSet.has(key as PageType)) return null;
|
||||||
|
|
||||||
const feature = getFeatureByKey(key);
|
const feature = getFeatureByKey(key);
|
||||||
if (!feature?.themeColor || feature.icon == null) return null;
|
if (!feature?.themeColorKey || feature.icon == null) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolCard
|
<ToolCard
|
||||||
key={key}
|
key={key}
|
||||||
title={t(feature.labelKey)}
|
title={t(feature.labelKey)}
|
||||||
description={t(feature.descriptionKey)}
|
description={t(feature.descriptionKey)}
|
||||||
colorCode={feature.themeColor}
|
colorKey={feature.themeColorKey}
|
||||||
icon={feature.icon}
|
icon={feature.icon}
|
||||||
onClick={() => navigateTo(key)}
|
onClick={() => navigateTo(key)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -149,7 +149,7 @@ export default function Index() {
|
|||||||
>
|
>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
sx={{ fontWeight: 800, color: '#00b9f1', letterSpacing: 0.5 }}
|
sx={{ fontWeight: 800, color: 'info.main', letterSpacing: 0.5 }}
|
||||||
>
|
>
|
||||||
{t('jwt:signatureTitle')}
|
{t('jwt:signatureTitle')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ const QrCodeToUrlSection = ({ expanded, onExpandedChange }: QrCodeToUrlSectionPr
|
|||||||
</Box>
|
</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 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||||
{t('qrCode:clickToUpload')}
|
{t('qrCode:clickToUpload')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ const UrlToQrCodeSection = ({ expanded, onExpandedChange }: UrlToQrCodeSectionPr
|
|||||||
value: url,
|
value: url,
|
||||||
size: 250,
|
size: 250,
|
||||||
level: 'H',
|
level: 'H',
|
||||||
foreground: qrCodePageStyles.black,
|
foreground: '#000000',
|
||||||
background: qrCodePageStyles.white,
|
background: '#FFFFFF',
|
||||||
});
|
});
|
||||||
|
|
||||||
setQrCodeDataUrl(qr.toDataURL());
|
setQrCodeDataUrl(qr.toDataURL());
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function StorageOptionsGrid({
|
|||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Box>
|
</Box>
|
||||||
<Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
|
<Divider sx={{ mx: 0, borderColor: 'divider' }} />
|
||||||
<Box sx={storageCleanerPageStyles.OPTIONS_GRID_FOOTER}>
|
<Box sx={storageCleanerPageStyles.OPTIONS_GRID_FOOTER}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
|
|||||||
@@ -50,13 +50,16 @@ export default function Index() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: 'grey.50',
|
bgcolor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
'& fieldset': {
|
'& fieldset': {
|
||||||
borderColor: 'grey.200',
|
borderColor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'grey.200',
|
||||||
},
|
},
|
||||||
'&:hover fieldset': {
|
'&:hover fieldset': {
|
||||||
borderColor: 'grey.300',
|
borderColor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'grey.300',
|
||||||
},
|
},
|
||||||
'&.Mui-focused fieldset': {
|
'&.Mui-focused fieldset': {
|
||||||
borderColor: textStatisticsPageStyles.primaryColor,
|
borderColor: textStatisticsPageStyles.primaryColor,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const ResultView = React.memo(({ result, mode, unit, zone }: ResultViewProps) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Fade in={!!result}>
|
<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}>
|
<Typography variant="caption" sx={timestampPageStyles.RESULT_LABEL}>
|
||||||
{t('timestamp:resultLabel')}
|
{t('timestamp:resultLabel')}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ThemeModeProvider } from './ThemeModeProvider';
|
||||||
|
|
||||||
|
interface AppRootProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppRoot({ children }: AppRootProps) {
|
||||||
|
return (
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeModeProvider>{children}</ThemeModeProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
|
import { getTheme } from '@/config/theme';
|
||||||
|
import { storageUtil } from '@/utils/chromeStorage';
|
||||||
|
|
||||||
|
export type ThemeMode = 'light' | 'dark' | 'system';
|
||||||
|
export type ResolvedThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
interface ThemeModeContextType {
|
||||||
|
/** 用户显式选择的模式(含 system) */
|
||||||
|
mode: ThemeMode;
|
||||||
|
/** 实际解析后的模式(不含 system) */
|
||||||
|
resolvedMode: ResolvedThemeMode;
|
||||||
|
/** 切换模式 */
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeModeContext = createContext<ThemeModeContextType | null>(null);
|
||||||
|
|
||||||
|
const THEME_MODE_KEY = 'app/themeMode' as const;
|
||||||
|
const SNAPSHOT_KEY = 'snapshot/app/themeMode';
|
||||||
|
|
||||||
|
const VALID_MODES: ThemeMode[] = ['light', 'dark', 'system'];
|
||||||
|
const isValidMode = (v: unknown): v is ThemeMode => VALID_MODES.includes(v as ThemeMode);
|
||||||
|
|
||||||
|
const getSyncSnapshot = (): ThemeMode => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SNAPSHOT_KEY);
|
||||||
|
if (!raw) return 'system';
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
return isValidMode(parsed) ? parsed : 'system';
|
||||||
|
} catch {
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSystemMode = (): ResolvedThemeMode => {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ThemeModeProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeModeProvider({ children }: ThemeModeProviderProps) {
|
||||||
|
const [mode, setModeState] = useState<ThemeMode>(getSyncSnapshot);
|
||||||
|
const [resolvedMode, setResolvedMode] = useState<ResolvedThemeMode>(
|
||||||
|
mode === 'system' ? getSystemMode() : mode,
|
||||||
|
);
|
||||||
|
const [, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
|
const muiTheme = useMemo(() => getTheme(resolvedMode), [resolvedMode]);
|
||||||
|
|
||||||
|
const updateResolved = useCallback((nextMode: ThemeMode) => {
|
||||||
|
setResolvedMode(nextMode === 'system' ? getSystemMode() : nextMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setMode = useCallback(
|
||||||
|
(next: ThemeMode) => {
|
||||||
|
setModeState(next);
|
||||||
|
updateResolved(next);
|
||||||
|
storageUtil.set(THEME_MODE_KEY, next).catch(console.error);
|
||||||
|
localStorage.setItem(SNAPSHOT_KEY, JSON.stringify(next));
|
||||||
|
},
|
||||||
|
[updateResolved],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 异步校准:从 chrome.storage 读取持久化值
|
||||||
|
useEffect(() => {
|
||||||
|
storageUtil
|
||||||
|
.get(THEME_MODE_KEY, 'system')
|
||||||
|
.then((saved) => {
|
||||||
|
if (isValidMode(saved)) {
|
||||||
|
setModeState(saved);
|
||||||
|
updateResolved(saved);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setIsLoaded(true));
|
||||||
|
}, [updateResolved]);
|
||||||
|
|
||||||
|
// 监听系统主题变化(仅在 system 模式下生效)
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'system') return;
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = (e: MediaQueryListEvent) => {
|
||||||
|
setResolvedMode(e.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
// 跨入口同步:监听 chrome.storage.onChanged
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStorageChange = (changes: { [key: string]: chrome.storage.StorageChange }) => {
|
||||||
|
if (changes[THEME_MODE_KEY]) {
|
||||||
|
const next = changes[THEME_MODE_KEY].newValue as unknown;
|
||||||
|
if (isValidMode(next) && next !== mode) {
|
||||||
|
setModeState(next);
|
||||||
|
updateResolved(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
chrome.storage.onChanged.addListener(handleStorageChange);
|
||||||
|
return () => chrome.storage.onChanged.removeListener(handleStorageChange);
|
||||||
|
}, [mode, updateResolved]);
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({ mode, resolvedMode, setMode }),
|
||||||
|
[mode, resolvedMode, setMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeModeContext.Provider value={contextValue}>
|
||||||
|
<ThemeProvider theme={muiTheme}>
|
||||||
|
<CssBaseline />
|
||||||
|
{children}
|
||||||
|
</ThemeProvider>
|
||||||
|
</ThemeModeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useThemeMode(): ThemeModeContextType {
|
||||||
|
const ctx = useContext(ThemeModeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useThemeMode must be used within a ThemeModeProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ThemeModeProvider;
|
||||||
Vendored
+4
-1
@@ -7,7 +7,8 @@ export type PageType =
|
|||||||
| 'storageCleaner' // 存储清理工具
|
| 'storageCleaner' // 存储清理工具
|
||||||
| 'qrCode' // 二维码工具
|
| 'qrCode' // 二维码工具
|
||||||
| 'textStatistics' // 文本统计工具
|
| 'textStatistics' // 文本统计工具
|
||||||
| 'jwt'; // JWT 解析工具
|
| 'jwt' // JWT 解析工具
|
||||||
|
| 'jsonDiff'; // JSON 差异比较工具
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 表单映射条目定义
|
* 表单映射条目定义
|
||||||
@@ -75,6 +76,8 @@ export interface StorageSchema {
|
|||||||
'app/lastRoute': string;
|
'app/lastRoute': string;
|
||||||
/** 应用主题配置 */
|
/** 应用主题配置 */
|
||||||
'app/theme': string;
|
'app/theme': string;
|
||||||
|
/** 主题模式偏好(light/dark/system) */
|
||||||
|
'app/themeMode': 'light' | 'dark' | 'system';
|
||||||
/** 存储清理工具的偏好设置 */
|
/** 存储清理工具的偏好设置 */
|
||||||
'storageCleaner/preferences': StorageCleanerPreferences;
|
'storageCleaner/preferences': StorageCleanerPreferences;
|
||||||
/** 二维码工具中二维码部分是否展开 */
|
/** 二维码工具中二维码部分是否展开 */
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { diffJson } from '../../pages/JsonDiff/diffEngine';
|
||||||
|
|
||||||
|
describe('diffJson', () => {
|
||||||
|
it('should detect added property', () => {
|
||||||
|
const left = { a: 1 };
|
||||||
|
const right = { a: 1, b: 2 };
|
||||||
|
const result = diffJson(left, right);
|
||||||
|
expect(result.diffCount).toBe(1);
|
||||||
|
expect(result.diffPaths).toContain('$.b');
|
||||||
|
const bNode = result.root.children?.find((n) => n.key === 'b');
|
||||||
|
expect(bNode?.type).toBe('added');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect removed property', () => {
|
||||||
|
const left = { a: 1, b: 2 };
|
||||||
|
const right = { a: 1 };
|
||||||
|
const result = diffJson(left, right);
|
||||||
|
expect(result.diffCount).toBe(1);
|
||||||
|
expect(result.diffPaths).toContain('$.b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect modified value', () => {
|
||||||
|
const left = { a: 1 };
|
||||||
|
const right = { a: 2 };
|
||||||
|
const result = diffJson(left, right);
|
||||||
|
expect(result.diffCount).toBe(1);
|
||||||
|
expect(result.diffPaths).toContain('$.a');
|
||||||
|
const aNode = result.root.children?.find((n) => n.key === 'a');
|
||||||
|
expect(aNode?.type).toBe('modified');
|
||||||
|
expect(aNode?.oldValue).toBe(1);
|
||||||
|
expect(aNode?.newValue).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested objects', () => {
|
||||||
|
const left = { a: { b: 1 } };
|
||||||
|
const right = { a: { b: 2 } };
|
||||||
|
const result = diffJson(left, right);
|
||||||
|
expect(result.diffCount).toBe(1);
|
||||||
|
expect(result.diffPaths).toContain('$.a.b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle arrays', () => {
|
||||||
|
const left = [1, 2];
|
||||||
|
const right = [1, 3];
|
||||||
|
const result = diffJson(left, right);
|
||||||
|
expect(result.diffCount).toBe(1);
|
||||||
|
expect(result.diffPaths).toContain('$[1]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unchanged for identical objects', () => {
|
||||||
|
const obj = { a: 1, b: { c: 2 } };
|
||||||
|
const result = diffJson(obj, obj);
|
||||||
|
expect(result.diffCount).toBe(0);
|
||||||
|
expect(result.root.type).toBe('unchanged');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle type mismatch', () => {
|
||||||
|
const left = { a: 1 };
|
||||||
|
const right = { a: '1' };
|
||||||
|
const result = diffJson(left, right);
|
||||||
|
expect(result.diffCount).toBe(1);
|
||||||
|
const aNode = result.root.children?.find((n) => n.key === 'a');
|
||||||
|
expect(aNode?.type).toBe('modified');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty objects', () => {
|
||||||
|
const result = diffJson({}, {});
|
||||||
|
expect(result.diffCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,3 +84,21 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 全局 matchMedia mock(ThemeModeProvider 依赖)
|
||||||
|
// 必须在 beforeEach 中设置,因为 afterEach 的 restoreAllMocks 会清除它
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
dispatchEvent: vi.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user