diff --git a/.gitignore b/.gitignore index 1887fd6..20d5dbd 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,5 @@ stats-*.json .trae/* .workbuddy/* dev/* + +docs/* diff --git a/components/CopyButton.tsx b/components/CopyButton.tsx index 6c178e4..9ff6670 100644 --- a/components/CopyButton.tsx +++ b/components/CopyButton.tsx @@ -67,15 +67,16 @@ export const CopyButton: React.FC = ({ style={style} sx={{ color: copied ? 'success.main' : color, - bgcolor: '#fff', - boxShadow: '0 2px 8px rgba(0,0,0,0.05)', + bgcolor: 'background.paper', + boxShadow: (theme) => + `0 2px 8px ${theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.3)' : 'rgba(0,0,0,0.05)'}`, '&:hover': { bgcolor: copied ? 'success.main' : !['primary', 'secondary', 'success', 'error', 'info', 'warning'].includes(color) ? color : `${color}.main`, - color: '#fff', + color: 'background.paper', }, }} > diff --git a/components/ErrorBoundary.tsx b/components/ErrorBoundary.tsx index c417b3f..ad96dee 100644 --- a/components/ErrorBoundary.tsx +++ b/components/ErrorBoundary.tsx @@ -1,5 +1,6 @@ import { Component, ErrorInfo, ReactNode } from 'react'; import { Box, Button, Container, Paper, Typography } from '@mui/material'; +import type { Theme } from '@mui/material/styles'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -60,7 +61,8 @@ export class ErrorBoundary extends Component { sx={{ mb: 3, p: 2, - bgcolor: 'grey.100', + bgcolor: (theme: Theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.100', borderRadius: 2, textAlign: 'left', maxHeight: '200px', diff --git a/components/GlobalSnackbar.tsx b/components/GlobalSnackbar.tsx index 2c9f95e..9d71124 100644 --- a/components/GlobalSnackbar.tsx +++ b/components/GlobalSnackbar.tsx @@ -182,8 +182,8 @@ export function GlobalSnackbar({ backgroundImage: 'none', boxShadow: (theme: Theme) => `0 12px 32px ${alpha(theme.palette[severity].main, 0.35)}`, - '& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem', color: '#fff' }, - '& .MuiAlert-message': { color: '#fff', padding: '6px 0' }, + '& .MuiAlert-icon': { mr: 0.5, fontSize: '1.1rem' }, + '& .MuiAlert-message': { padding: '6px 0' }, }} > {message} diff --git a/components/TopBar.tsx b/components/TopBar.tsx index b531525..6b259f3 100644 --- a/components/TopBar.tsx +++ b/components/TopBar.tsx @@ -20,16 +20,22 @@ import SearchIcon from '@mui/icons-material/Search'; import HistoryIcon from '@mui/icons-material/History'; import CloseIcon from '@mui/icons-material/Close'; 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 { useThemeMode } from '@/providers/ThemeModeProvider'; import { FEATURES, FeatureConfig } from '@/config/features'; import { storageUtil } from '@/utils/chromeStorage'; import { openExtensionPage } from '@/utils/chromeTabs'; import { useTranslation } from 'react-i18next'; +import { alpha } from '@mui/material/styles'; import { SUPPORTED_LANGUAGES, normalizeLanguage } from '@/i18n'; import { topBarStyles } from '@/config/pageTheme'; export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) { const { currentPage, goBack, navigateTo } = useRouter(); + const { mode, setMode } = useThemeMode(); const { t, i18n } = useTranslation(['common', 'features']); const [searchQuery, setSearchQuery] = useState(''); const [showResults, setShowResults] = useState(false); @@ -107,6 +113,14 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) 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 totalItems = searchQuery.trim() ? searchResults.length : displayedHistory.length; @@ -152,7 +166,7 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) px: { xs: 1, sm: 2 }, py: 1.5, borderBottom: '1px solid', - borderColor: 'grey.100', + borderColor: 'divider', bgcolor: 'background.paper', zIndex: topBarStyles.Z_INDEX, position: 'relative', @@ -165,8 +179,12 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) onClick={goBack} aria-label={t('common:buttons.back')} sx={{ - bgcolor: 'grey.50', - '&:hover': { bgcolor: 'grey.200' }, + bgcolor: (theme) => + 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', + }, }} > @@ -217,18 +235,24 @@ export default function TopBar({ onOpenOptions }: { onOpenOptions: () => void }) } sx={{ width: '100%', - bgcolor: 'grey.50', + bgcolor: (theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'grey.50', px: 1.5, py: 0.5, borderRadius: 2, 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', - '&:hover': { bgcolor: 'grey.100' }, + '&:hover': { + bgcolor: (theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.08)' : 'grey.100', + }, '&.Mui-focused': { bgcolor: 'background.paper', 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 }) - + void }) + + + + + { } onClick={() => {}} />, @@ -26,12 +26,7 @@ describe('ToolCard 组件', () => { it('无描述时仅渲染标题', () => { render( - } - onClick={() => {}} - />, + } onClick={() => {}} />, ); expect(screen.getByText('仅标题')).toBeInTheDocument(); @@ -41,7 +36,7 @@ describe('ToolCard 组件', () => { render( } onClick={() => {}} />, @@ -54,7 +49,7 @@ describe('ToolCard 组件', () => { render( } onClick={() => {}} snapshot={
快照内容
} @@ -66,12 +61,7 @@ describe('ToolCard 组件', () => { it('未提供快照时不渲染快照区域', () => { const { container } = render( - } - onClick={() => {}} - />, + } onClick={() => {}} />, ); expect(container.querySelector('[data-testid="snapshot"]')).not.toBeInTheDocument(); @@ -79,12 +69,7 @@ describe('ToolCard 组件', () => { it('应使用 CardActionArea 渲染,支持键盘聚焦', () => { render( - } - onClick={() => {}} - />, + } onClick={() => {}} />, ); const button = screen.getByRole('button', { name: /可聚焦/ }); @@ -99,7 +84,7 @@ describe('ToolCard 组件', () => { render( } onClick={handleClick} />, @@ -116,7 +101,7 @@ describe('ToolCard 组件', () => { render( } onClick={handleClick} />, @@ -131,11 +116,10 @@ describe('ToolCard 组件', () => { describe('样式测试', () => { it('应应用自定义颜色代码', () => { - const customColor = '#ff5722'; render( } onClick={() => {}} />, diff --git a/components/__tests__/TopBar.test.tsx b/components/__tests__/TopBar.test.tsx index 3a9a6ba..a2ea1a8 100644 --- a/components/__tests__/TopBar.test.tsx +++ b/components/__tests__/TopBar.test.tsx @@ -1,10 +1,27 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 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 = { currentPage: 'dashboard' as PageType, visiblePages: ['dashboard', 'timestamp'] as PageType[], @@ -29,7 +46,11 @@ describe('TopBar 组件', () => { }); const renderWithProvider = (ui: React.ReactElement) => { - return render({ui}); + return render( + + {ui} + , + ); }; describe('渲染测试', () => { diff --git a/config/__tests__/features.test.ts b/config/__tests__/features.test.ts index ceef5fc..a448249 100644 --- a/config/__tests__/features.test.ts +++ b/config/__tests__/features.test.ts @@ -9,8 +9,8 @@ import { describe('features', () => { describe('FEATURES', () => { - it('should have 6 features defined', () => { - expect(FEATURES).toHaveLength(6); + it('should have 7 features defined', () => { + expect(FEATURES).toHaveLength(7); }); it('should have all required properties for each feature', () => { @@ -32,8 +32,8 @@ describe('features', () => { // Optional UI properties for non-hidden features if (feature.key !== 'dashboard') { expect(feature).toHaveProperty('icon'); - expect(feature).toHaveProperty('themeColor'); - expect(typeof feature.themeColor).toBe('string'); + expect(feature).toHaveProperty('themeColorKey'); + expect(typeof feature.themeColorKey).toBe('string'); } }); }); @@ -58,7 +58,7 @@ describe('features', () => { expect(feature).toBeDefined(); expect(feature?.key).toBe('timestamp'); expect(feature?.labelKey).toBe('features:timestamp.title'); - expect(feature?.themeColor).toBeDefined(); + expect(feature?.themeColorKey).toBeDefined(); }); it('should return storageCleaner feature', () => { @@ -95,13 +95,14 @@ describe('features', () => { describe('getAllFeatureKeys', () => { it('should return all feature keys', () => { const allKeys = getAllFeatureKeys(); - expect(allKeys).toHaveLength(6); + expect(allKeys).toHaveLength(7); expect(allKeys).toContain('dashboard'); expect(allKeys).toContain('timestamp'); expect(allKeys).toContain('storageCleaner'); expect(allKeys).toContain('qrCode'); expect(allKeys).toContain('textStatistics'); expect(allKeys).toContain('jwt'); + expect(allKeys).toContain('jsonDiff'); }); }); @@ -118,9 +119,9 @@ describe('features', () => { expect(pageOrder).toContain('qrCode'); }); - it('should have 5 items in page order', () => { + it('should have 6 items in page order', () => { const pageOrder = getDefaultPageOrder(); - expect(pageOrder).toHaveLength(5); + expect(pageOrder).toHaveLength(6); }); }); }); diff --git a/config/features.tsx b/config/features.tsx index 82cec13..efae701 100644 --- a/config/features.tsx +++ b/config/features.tsx @@ -5,8 +5,9 @@ import StorageIcon from '@mui/icons-material/Storage'; import QrCodeIcon from '@mui/icons-material/QrCode'; import DescriptionIcon from '@mui/icons-material/Description'; 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')); @@ -15,6 +16,7 @@ const StorageCleanerPage = lazy(() => import('@/pages/StorageCleaner')); const QrCodePage = lazy(() => import('@/pages/QrCode')); const TextStatisticsPage = lazy(() => import('@/pages/TextStatistics')); const JwtPage = lazy(() => import('@/pages/Jwt')); +const JsonDiffPage = lazy(() => import('@/pages/JsonDiff')); /** * 功能配置接口 @@ -28,8 +30,8 @@ export interface FeatureConfig { labelKey: string; /** 功能描述翻译键 */ descriptionKey: string; - /** 主题颜色(用于仪表盘卡片) */ - themeColor?: string; + /** 主题颜色键(用于仪表盘卡片,映射到 theme.palette[key].main) */ + themeColorKey?: PaletteColorKey; /** 图标组件(用于仪表盘卡片) */ icon?: ReactNode; /** 默认是否在仪表盘显示 */ @@ -61,7 +63,7 @@ export const FEATURES: FeatureConfig[] = [ key: 'timestamp', labelKey: 'features:timestamp.title', descriptionKey: 'features:timestamp.description', - themeColor: THEME_COLORS.primary, + themeColorKey: 'primary', icon: , defaultVisible: true, components: { @@ -74,7 +76,7 @@ export const FEATURES: FeatureConfig[] = [ key: 'storageCleaner', labelKey: 'features:storageCleaner.title', descriptionKey: 'features:storageCleaner.description', - themeColor: THEME_COLORS.warning, + themeColorKey: 'warning', icon: , defaultVisible: true, components: { @@ -87,7 +89,7 @@ export const FEATURES: FeatureConfig[] = [ key: 'qrCode', labelKey: 'features:qrCode.title', descriptionKey: 'features:qrCode.description', - themeColor: THEME_COLORS.success, + themeColorKey: 'success', icon: , defaultVisible: true, components: { @@ -100,7 +102,7 @@ export const FEATURES: FeatureConfig[] = [ key: 'textStatistics', labelKey: 'features:textStatistics.title', descriptionKey: 'features:textStatistics.description', - themeColor: THEME_COLORS.purple, + themeColorKey: 'secondary', icon: , defaultVisible: true, components: { @@ -113,7 +115,7 @@ export const FEATURES: FeatureConfig[] = [ key: 'jwt', labelKey: 'features:jwt.title', descriptionKey: 'features:jwt.description', - themeColor: THEME_COLORS.indigo, + themeColorKey: 'info', icon: , defaultVisible: true, components: { @@ -122,6 +124,19 @@ export const FEATURES: FeatureConfig[] = [ tab: JwtPage, }, }, + { + key: 'jsonDiff', + labelKey: 'features:jsonDiff.title', + descriptionKey: 'features:jsonDiff.description', + themeColorKey: 'primary', + icon: , + defaultVisible: true, + components: { + popup: JsonDiffPage, + sidepanel: JsonDiffPage, + tab: JsonDiffPage, + }, + }, ]; export function getFeatureByKey(key: PageType): FeatureConfig | undefined { diff --git a/config/pageTheme.ts b/config/pageTheme.ts index edb7152..1454374 100644 --- a/config/pageTheme.ts +++ b/config/pageTheme.ts @@ -11,6 +11,9 @@ export type ZoneType = (typeof ZONES)[number]; /** * 符合 WCAG AA 标准(4.5:1 对比度)的主题颜色体系 * 所有颜色都经过对比度计算,确保可访问性 + * + * 注意:这些颜色是品牌色源,实际组件应优先使用 theme.palette.* 令牌, + * 以便在亮色/暗色模式下自动切换。 */ export const THEME_COLORS = { // 主要颜色 - 蓝色系 @@ -66,11 +69,10 @@ export const STATUS_COLORS = { } as const; /** - * 全局样式配置 + * 暗色模式下自动加深 alpha 值的辅助函数 */ -export const globalStyles = { - backgroundColor: '#f5f5f5', -} as const; +export const surfaceTint = (theme: Theme, color: string, baseAlpha: number) => + alpha(color, theme.palette.mode === 'dark' ? Math.min(baseAlpha + 0.1, 0.9) : baseAlpha); /** * 时间戳转换页面样式 @@ -82,18 +84,18 @@ export const timestampPageStyles = { bgcolor: 'background.paper', borderRadius: 3, border: '1px solid', - borderColor: 'grey.100', + borderColor: 'divider', transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', '& fieldset': { border: 'none' }, - '&:hover': { borderColor: 'grey.300', bgcolor: 'grey.50' }, + '&:hover': { borderColor: 'action.active', bgcolor: 'action.hover' }, '&.Mui-focused': { - bgcolor: '#fff', + bgcolor: 'background.paper', 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': { 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': { @@ -109,21 +111,21 @@ export const timestampPageStyles = { sx: { borderRadius: 3, mt: 1, boxShadow: '0 12px 32px rgba(0,0,0,0.1)' }, }, }, - cardBg: alpha(THEME_COLORS.primary, 0.04), - cardBorder: alpha(THEME_COLORS.primary, 0.1), - switcherBg: alpha(THEME_COLORS.primary, 0.08), - switcherBorder: alpha(THEME_COLORS.primary, 0.1), - mutedText: alpha(THEME_COLORS.primary, 0.4), - resultBg: alpha(THEME_COLORS.primary, 0.05), - buttonHover: `0 8px 24px ${alpha(THEME_COLORS.primary, 0.2)}`, + cardBg: (theme: Theme) => alpha(theme.palette.primary.main, 0.04), + cardBorder: (theme: Theme) => alpha(theme.palette.primary.main, 0.1), + switcherBg: (theme: Theme) => alpha(theme.palette.primary.main, 0.08), + switcherBorder: (theme: Theme) => alpha(theme.palette.primary.main, 0.1), + mutedText: (theme: Theme) => alpha(theme.palette.primary.main, 0.4), + resultBg: (theme: Theme) => alpha(theme.palette.primary.main, 0.05), + buttonHover: (theme: Theme) => `0 8px 24px ${alpha(theme.palette.primary.main, 0.2)}`, /** 模式切换器 (ToggleButtonGroup) 样式 */ MODE_SWITCHER: { width: '100%', mb: 2.5, borderRadius: 4, - bgcolor: 'grey.100', + bgcolor: (theme: Theme) => (theme.palette.mode === 'light' ? 'grey.100' : 'grey.900'), border: '1px solid', - borderColor: 'grey.200', + borderColor: 'divider', p: 0.6, '& .MuiToggleButtonGroup-grouped': { flex: 1, @@ -135,7 +137,7 @@ export const timestampPageStyles = { color: 'text.secondary', transition: 'color 0.3s', '&.Mui-selected': { - bgcolor: '#fff', + bgcolor: 'background.paper', color: 'primary.main', boxShadow: '0 4px 12px rgba(0,0,0,0.05)', }, @@ -145,11 +147,11 @@ export const timestampPageStyles = { UNIT_SWITCHER_CONTAINER: { flex: 1, display: 'flex', - bgcolor: 'grey.50', + bgcolor: 'action.hover', p: 0.5, borderRadius: 3.5, border: '1px solid', - borderColor: 'grey.100', + borderColor: 'divider', }, UNIT_SWITCHER_ITEM: (active: boolean) => ({ flex: 1, @@ -160,12 +162,12 @@ export const timestampPageStyles = { fontSize: '0.75rem', fontWeight: 800, transition: 'all 0.2s', - bgcolor: active ? '#fff' : 'transparent', + bgcolor: active ? 'background.paper' : 'transparent', color: active ? 'primary.main' : 'text.disabled', boxShadow: active ? '0 2px 8px rgba(0,0,0,0.05)' : 'none', }), /** LiveClock 卡片样式 */ - LIVE_CLOCK_CARD: { + LIVE_CLOCK_CARD: (theme: Theme) => ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', @@ -173,13 +175,13 @@ export const timestampPageStyles = { gap: 1.5, p: 1.8, mb: 2.5, - bgcolor: alpha(THEME_COLORS.primary, 0.04), + bgcolor: alpha(theme.palette.primary.main, 0.04), borderRadius: 4, border: '1px solid', - borderColor: alpha(THEME_COLORS.primary, 0.1), - }, + borderColor: alpha(theme.palette.primary.main, 0.1), + }), LIVE_CLOCK_LABEL: { - color: THEME_COLORS.primary, + color: 'primary.main', fontWeight: 800, fontSize: '0.6rem', textTransform: 'uppercase', @@ -187,21 +189,21 @@ export const timestampPageStyles = { }, LIVE_CLOCK_VALUE: { fontWeight: 800, - color: THEME_COLORS.primary, + color: 'primary.main', fontFamily: 'monospace', fontSize: { xs: '1.1rem', sm: '1.2rem' }, letterSpacing: '-0.5px', lineHeight: 1.2, }, - LIVE_CLOCK_UNIT_SWITCHER: { + LIVE_CLOCK_UNIT_SWITCHER: (theme: Theme) => ({ display: 'flex', p: 0.4, - bgcolor: alpha(THEME_COLORS.primary, 0.08), + bgcolor: alpha(theme.palette.primary.main, 0.08), borderRadius: 2.5, border: '1px solid', - borderColor: alpha(THEME_COLORS.primary, 0.1), - }, - LIVE_CLOCK_UNIT_ITEM: (active: boolean) => ({ + borderColor: alpha(theme.palette.primary.main, 0.1), + }), + LIVE_CLOCK_UNIT_ITEM: (active: boolean) => (theme: Theme) => ({ px: { xs: 1, sm: 1.2 }, py: 0.35, borderRadius: 2, @@ -209,20 +211,20 @@ export const timestampPageStyles = { fontSize: '0.65rem', fontWeight: 900, transition: 'all 0.2s', - bgcolor: active ? '#fff' : 'transparent', - color: active ? 'primary.main' : alpha(THEME_COLORS.primary, 0.4), + bgcolor: active ? 'background.paper' : 'transparent', + color: active ? 'primary.main' : alpha(theme.palette.primary.main, 0.4), boxShadow: active ? '0 2px 6px rgba(33, 150, 243, 0.2)' : 'none', }), LIVE_CLOCK_ICON_BUTTON: { - color: THEME_COLORS.primary, - bgcolor: '#fff', + color: 'primary.main', + bgcolor: 'background.paper', 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: { mx: 0.5, my: 1, - borderColor: alpha(THEME_COLORS.primary, 0.1), + borderColor: (theme: Theme) => alpha(theme.palette.primary.main, 0.1), }, /** ResultView 样式 */ RESULT_LABEL: { @@ -232,33 +234,33 @@ export const timestampPageStyles = { fontWeight: 800, fontSize: '0.7rem', }, - RESULT_MAIN_BOX: { - bgcolor: alpha(THEME_COLORS.primary, 0.05), + RESULT_MAIN_BOX: (theme: Theme) => ({ + bgcolor: alpha(theme.palette.primary.main, 0.05), p: 2, borderRadius: 4, position: 'relative', mb: 2.5, border: '1px solid', - borderColor: alpha(THEME_COLORS.primary, 0.1), + borderColor: alpha(theme.palette.primary.main, 0.1), display: 'flex', justifyContent: 'space-between', alignItems: 'center', - }, + }), RESULT_MAIN_TEXT: { fontFamily: 'monospace', fontWeight: 700, - color: THEME_COLORS.primary, + color: 'primary.main', wordBreak: 'break-all', pr: 4, fontSize: '1rem', }, - RESULT_EXTRA_STACK: { - bgcolor: alpha(THEME_COLORS.primary, 0.05), + RESULT_EXTRA_STACK: (theme: Theme) => ({ + bgcolor: alpha(theme.palette.primary.main, 0.05), p: 2, borderRadius: 4, border: '1px solid', - borderColor: alpha(THEME_COLORS.primary, 0.1), - }, + borderColor: alpha(theme.palette.primary.main, 0.1), + }), RESULT_EXTRA_LABEL: { color: 'text.disabled', fontWeight: 700, @@ -267,7 +269,7 @@ export const timestampPageStyles = { }, RESULT_EXTRA_VALUE: { fontFamily: 'monospace', - color: THEME_COLORS.primary, + color: 'primary.main', fontWeight: 600, fontSize: '0.65rem', }, @@ -279,15 +281,14 @@ export const timestampPageStyles = { export const storageCleanerPageStyles = { warningColor: THEME_COLORS.warning, warningDark: THEME_COLORS.warningDark, - warningBg: alpha(THEME_COLORS.warning, 0.05), - warningBorder: `1px solid ${alpha(THEME_COLORS.warning, 0.2)}`, - errorBorder: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`, - errorBg: alpha(THEME_COLORS.error, 0.05), + warningBg: (theme: Theme) => surfaceTint(theme, theme.palette.warning.main, 0.05), + errorBorder: (theme: Theme) => `1px solid ${surfaceTint(theme, theme.palette.error.main, 0.2)}`, + errorBg: (theme: Theme) => surfaceTint(theme, theme.palette.error.main, 0.05), /** 选项网格容器 */ OPTIONS_GRID_CONTAINER: { mb: 3, border: '1px solid', - borderColor: 'grey.100', + borderColor: 'divider', borderRadius: 4, bgcolor: 'background.paper', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.05)', @@ -307,7 +308,7 @@ export const storageCleanerPageStyles = { borderBottomRightRadius: 4, transition: 'all 0.2s', '&:hover': { - bgcolor: 'rgba(0, 0, 0, 0.04)', + bgcolor: 'action.hover', }, }, OPTIONS_GRID_CHECKBOX: { @@ -322,7 +323,7 @@ export const storageCleanerPageStyles = { }, }, /** 选项项 */ - OPTION_ITEM: (checked: boolean) => ({ + OPTION_ITEM: (checked: boolean) => (theme: Theme) => ({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', @@ -330,10 +331,10 @@ export const storageCleanerPageStyles = { px: { xs: 1, sm: 1.5 }, borderRadius: 3, transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', - bgcolor: checked ? alpha(THEME_COLORS.warning, 0.05) : 'transparent', - border: `1px solid ${checked ? alpha(THEME_COLORS.warning, 0.2) : 'transparent'}`, + bgcolor: checked ? surfaceTint(theme, theme.palette.warning.main, 0.05) : 'transparent', + border: `1px solid ${checked ? surfaceTint(theme, theme.palette.warning.main, 0.2) : 'transparent'}`, '&: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)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)', }, @@ -346,7 +347,7 @@ export const storageCleanerPageStyles = { overflow: 'hidden', textOverflow: 'ellipsis', transition: 'color 0.2s', - color: checked ? THEME_COLORS.warning : 'text.primary', + color: checked ? 'warning.main' : 'text.primary', }), OPTION_ITEM_SIZE: { color: 'text.secondary', @@ -359,7 +360,7 @@ export const storageCleanerPageStyles = { opacity: 0.8, }, OPTION_ITEM_NO_DATA: { - color: 'grey.400', + color: 'text.disabled', fontSize: '0.65rem', fontWeight: 500, display: 'block', @@ -384,7 +385,7 @@ export const storageCleanerPageStyles = { borderRadius: 4, bgcolor: 'background.paper', border: '1px solid', - borderColor: 'grey.100', + borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center', @@ -407,30 +408,30 @@ export const storageCleanerPageStyles = { }, }, /** DomainHeader */ - DOMAIN_HEADER_BADGE: { - bgcolor: alpha(THEME_COLORS.warning, 0.15), - color: THEME_COLORS.warning, + DOMAIN_HEADER_BADGE: (theme: Theme) => ({ + bgcolor: surfaceTint(theme, theme.palette.warning.main, 0.15), + color: 'warning.main', px: 1.5, py: 0.3, borderRadius: 2, fontWeight: 800, 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', '&: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, 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', '&:hover': { - bgcolor: alpha(THEME_COLORS.warning, 0.15), + bgcolor: surfaceTint(theme, theme.palette.warning.main, 0.15), transform: 'scale(1.05)', }, - }, + }), /** ErrorDisplay */ ERROR_DISPLAY_CONTAINER: { py: 8, @@ -440,17 +441,18 @@ export const storageCleanerPageStyles = { minHeight: { xs: 'auto', sm: '400px' }, textAlign: 'center', }, - ERROR_DISPLAY_BOX: { + ERROR_DISPLAY_BOX: (theme: Theme) => ({ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', borderRadius: 4, p: 4, - boxShadow: `0 8px 24px ${alpha(THEME_COLORS.error, 0.15)}`, - border: `1px solid ${alpha(THEME_COLORS.error, 0.2)}`, - bgcolor: alpha(THEME_COLORS.error, 0.05), - }, + boxShadow: `0 8px 24px ${surfaceTint(theme, theme.palette.error.main, 0.15)}`, + border: '1px solid', + borderColor: surfaceTint(theme, theme.palette.error.main, 0.2), + bgcolor: surfaceTint(theme, theme.palette.error.main, 0.05), + }), /** CleaningResult */ CLEANING_RESULT_ALERT: { borderRadius: 3, @@ -471,7 +473,7 @@ export const storageCleanerPageStyles = { CONFIRM_DIALOG_PAPER: { borderRadius: 6, 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, bgcolor: 'background.paper', }, @@ -493,29 +495,29 @@ export const storageCleanerPageStyles = { fontWeight: 500, fontSize: '0.9rem', }, - CONFIRM_DIALOG_CHIP: { - bgcolor: alpha(THEME_COLORS.warning, 0.04), + CONFIRM_DIALOG_CHIP: (theme: Theme) => ({ + bgcolor: surfaceTint(theme, theme.palette.warning.main, 0.04), fontWeight: 700, - color: THEME_COLORS.warning, + color: 'warning.main', fontSize: '0.75rem', border: '1px solid', - borderColor: alpha(THEME_COLORS.warning, 0.15), + borderColor: surfaceTint(theme, theme.palette.warning.main, 0.15), borderRadius: 2.5, height: 'auto', '& .MuiChip-label': { px: 1.2, py: 0.6 }, - }, - CONFIRM_DIALOG_WARNING_BOX: { + }), + CONFIRM_DIALOG_WARNING_BOX: (theme: Theme) => ({ display: 'inline-flex', alignItems: 'center', gap: 1, - bgcolor: alpha(THEME_COLORS.error, 0.05), - color: THEME_COLORS.error, + bgcolor: surfaceTint(theme, theme.palette.error.main, 0.05), + color: 'error.main', px: 2, py: 0.8, borderRadius: 3, border: '1px dashed', - borderColor: alpha(THEME_COLORS.error, 0.2), - }, + borderColor: surfaceTint(theme, theme.palette.error.main, 0.2), + }), CONFIRM_DIALOG_WARNING_TEXT: { fontWeight: 800, display: 'flex', @@ -527,29 +529,23 @@ export const storageCleanerPageStyles = { boxShadow: '0 0 1px 1px rgba(0, 0, 0, 0.1)', color: 'text.secondary', '&:hover': { - bgcolor: 'grey.100', + bgcolor: 'action.hover', color: 'text.primary', }, }, CONFIRM_DIALOG_CONFIRM: { - bgcolor: THEME_COLORS.warning, + bgcolor: 'warning.main', '&:hover': { - bgcolor: THEME_COLORS.warningDark, + bgcolor: 'warning.dark', }, }, } as const; /** * 二维码工具页面样式 - * 注意:保留 successColor 和 successDark 以保持向后兼容性 */ export const qrCodePageStyles = { 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: { py: 4, @@ -580,10 +576,10 @@ export const qrCodePageStyles = { PRIMARY_BUTTON: { py: 1.2, borderRadius: 3, - bgcolor: THEME_COLORS.success, + bgcolor: 'success.main', fontWeight: 700, '&:hover': { - bgcolor: THEME_COLORS.successDark, + bgcolor: 'success.dark', }, } as const, /** 二维码展示区域 */ @@ -594,10 +590,10 @@ export const qrCodePageStyles = { alignItems: 'center', minHeight: 200, border: '2px dashed', - borderColor: 'grey.200', + borderColor: 'divider', borderRadius: 3, p: 2, - bgcolor: 'grey.50', + bgcolor: 'action.hover', } as const, QR_PREVIEW_INNER: { display: 'flex', @@ -618,23 +614,23 @@ export const qrCodePageStyles = { /** 下载按钮 */ DOWNLOAD_BUTTON: { borderRadius: 2, - borderColor: THEME_COLORS.success, - color: THEME_COLORS.success, + borderColor: 'success.main', + color: 'success.main', '&:hover': { - borderColor: THEME_COLORS.successDark, - bgcolor: alpha(THEME_COLORS.success, 0.05), + borderColor: 'success.dark', + bgcolor: (theme: Theme) => alpha(theme.palette.success.main, 0.05), }, } as const, /** 复制按钮 */ COPY_BUTTON: { borderRadius: 2, - bgcolor: THEME_COLORS.success, + bgcolor: 'success.main', '&:hover': { - bgcolor: THEME_COLORS.successDark, + bgcolor: 'success.dark', }, } as const, /** 拖拽上传区域 */ - DROPZONE: (dragging: boolean, hasFile: boolean) => + DROPZONE: (dragging: boolean, hasFile: boolean) => (theme: Theme) => ({ display: 'flex', flexDirection: 'column', @@ -642,19 +638,19 @@ export const qrCodePageStyles = { justifyContent: 'center', minHeight: 200, border: '2px dashed', - borderColor: dragging || hasFile ? THEME_COLORS.success : 'grey.200', + borderColor: dragging || hasFile ? 'success.main' : 'divider', borderRadius: 3, p: 4, bgcolor: dragging - ? alpha(THEME_COLORS.success, 0.1) + ? alpha(theme.palette.success.main, 0.1) : hasFile - ? alpha(THEME_COLORS.success, 0.05) - : 'grey.50', + ? alpha(theme.palette.success.main, 0.05) + : 'action.hover', cursor: 'pointer', transition: 'all 0.2s', '&:hover': { - borderColor: THEME_COLORS.success, - bgcolor: alpha(THEME_COLORS.success, 0.05), + borderColor: 'success.main', + bgcolor: alpha(theme.palette.success.main, 0.05), }, }) as const, /** 图片预览容器 */ @@ -674,16 +670,16 @@ export const qrCodePageStyles = { objectFit: 'contain', } as const, /** 清除按钮 */ - CLEAR_BUTTON: { + CLEAR_BUTTON: (theme: Theme) => ({ position: 'absolute', top: -8, right: -8, - bgcolor: alpha(THEME_COLORS.error, 0.9), + bgcolor: alpha(theme.palette.error.main, 0.9), color: 'white', '&:hover': { - bgcolor: alpha(THEME_COLORS.errorDark, 0.95), + bgcolor: 'error.dark', }, - } as const, + }), /** 结果输入框 */ RESULT_INPUT: { position: 'relative', @@ -700,9 +696,6 @@ export const qrCodePageStyles = { * 仪表盘页面样式 */ export const dashboardPageStyles = { - primaryColor: THEME_COLORS.primary, - backgroundColor: '#f5f5f5', - cardBackgroundColor: '#ffffff', GRID_CONTAINER: { display: 'grid', gridTemplateColumns: { @@ -719,14 +712,14 @@ export const dashboardPageStyles = { * 使用语义化的颜色命名:valid(有效)、invalid(无效)、clear(清除) */ export const formRecognizerPageStyles = { - primaryColor: '#ff5722', - validColor: THEME_COLORS.success, - validDark: THEME_COLORS.successDark, - invalidColor: THEME_COLORS.warning, - invalidDark: THEME_COLORS.warningDark, - clearColor: THEME_COLORS.error, - clearDark: THEME_COLORS.errorDark, - clearBg: alpha(THEME_COLORS.error, 0.05), + primaryColor: 'warning.main', + validColor: 'success.main', + validDark: 'success.dark', + invalidColor: 'warning.main', + invalidDark: 'warning.dark', + clearColor: 'error.main', + clearDark: 'error.dark', + clearBg: (theme: Theme) => surfaceTint(theme, theme.palette.error.main, 0.05), buttonStyle: { py: 1.2, borderRadius: 3, @@ -738,7 +731,7 @@ export const formRecognizerPageStyles = { * 表单映射页面样式 */ export const formMappingPageStyles = { - secondaryColor: THEME_COLORS.purple, + secondaryColor: 'secondary.main', } as const; /** @@ -746,13 +739,10 @@ export const formMappingPageStyles = { */ export const textStatisticsPageStyles = { primaryColor: THEME_COLORS.purple, - cardBg: alpha(THEME_COLORS.purple, 0.04), - cardBorder: alpha(THEME_COLORS.purple, 0.1), + cardBg: (theme: Theme) => alpha(theme.palette.secondary.main, 0.04), + cardBorder: (theme: Theme) => alpha(theme.palette.secondary.main, 0.1), } as const; -/** - * JWT 解析工具页面样式 - */ /** * TopBar 组件样式 */ @@ -770,8 +760,8 @@ export const topBarStyles = { */ export const jwtPageStyles = { primaryColor: THEME_COLORS.indigo, - cardBg: alpha(THEME_COLORS.indigo, 0.04), - cardBorder: alpha(THEME_COLORS.indigo, 0.1), + cardBg: (theme: Theme) => alpha(theme.palette.info.main, 0.04), + cardBorder: (theme: Theme) => alpha(theme.palette.info.main, 0.1), INPUT_STYLE: { '& .MuiOutlinedInput-root': { bgcolor: 'background.paper', @@ -779,8 +769,66 @@ export const jwtPageStyles = { fontSize: '0.85rem', fontFamily: 'monospace', transition: 'all 0.2s', - '&:hover': { bgcolor: 'grey.50' }, - '&.Mui-focused': { bgcolor: '#fff' }, + '&:hover': { bgcolor: 'action.hover' }, + '&.Mui-focused': { bgcolor: 'background.paper' }, }, }, } 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; diff --git a/config/theme.ts b/config/theme.ts index f785f60..c78e979 100644 --- a/config/theme.ts +++ b/config/theme.ts @@ -1,131 +1,153 @@ -import { createTheme } from '@mui/material/styles'; +import { createTheme, type PaletteMode, type Theme } from '@mui/material/styles'; import { THEME_COLORS } from './pageTheme'; /** - * 统一的 MUI 主题配置 - * 整合了原有的外部 CSS 样式(滚动条、动画、基础重置) + * 按模式生成 MUI 主题 + * + * - light:使用 THEME_COLORS 中饱和度较高的版本作为 main + * - dark:使用 *Light 变体作为 main,保证暗底对比度满足 WCAG AA + * + * 所有调色板槽位均显式指定,不依赖 MUI 默认值。 */ -const theme = createTheme({ - palette: { - primary: { - main: THEME_COLORS.primary, - dark: THEME_COLORS.primaryDark, - light: THEME_COLORS.primaryLight, +export function getTheme(mode: PaletteMode): Theme { + const isDark = mode === 'dark'; + + return createTheme({ + palette: { + mode, + primary: { + main: isDark ? THEME_COLORS.primaryLight : THEME_COLORS.primary, + dark: THEME_COLORS.primaryDark, + light: THEME_COLORS.primaryLight, + }, + secondary: { + main: isDark ? THEME_COLORS.purpleLight : THEME_COLORS.purple, + dark: THEME_COLORS.purpleDark, + light: THEME_COLORS.purpleLight, + }, + success: { + main: isDark ? THEME_COLORS.successLight : THEME_COLORS.success, + dark: THEME_COLORS.successDark, + light: THEME_COLORS.successLight, + }, + warning: { + main: isDark ? THEME_COLORS.warningLight : THEME_COLORS.warning, + dark: THEME_COLORS.warningDark, + light: THEME_COLORS.warningLight, + }, + error: { + main: isDark ? THEME_COLORS.errorLight : THEME_COLORS.error, + dark: THEME_COLORS.errorDark, + light: THEME_COLORS.errorLight, + }, + info: { + main: isDark ? THEME_COLORS.indigoLight : THEME_COLORS.indigo, + dark: THEME_COLORS.indigoDark, + 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)', }, - success: { - main: THEME_COLORS.success, - dark: THEME_COLORS.successDark, - light: THEME_COLORS.successLight, + typography: { + fontFamily: [ + '-apple-system', + 'BlinkMacSystemFont', + '"Segoe UI"', + 'Roboto', + '"Helvetica Neue"', + 'Arial', + 'sans-serif', + '"Apple Color Emoji"', + '"Segoe UI Emoji"', + '"Segoe UI Symbol"', + ].join(','), }, - warning: { - main: THEME_COLORS.warning, - dark: THEME_COLORS.warningDark, - light: THEME_COLORS.warningLight, - }, - error: { - main: THEME_COLORS.error, - dark: THEME_COLORS.errorDark, - light: THEME_COLORS.errorLight, - }, - secondary: { - main: THEME_COLORS.purple, - dark: THEME_COLORS.purpleDark, - light: THEME_COLORS.purpleLight, - }, - }, - typography: { - fontFamily: [ - '-apple-system', - 'BlinkMacSystemFont', - '"Segoe UI"', - 'Roboto', - '"Helvetica Neue"', - 'Arial', - 'sans-serif', - '"Apple Color Emoji"', - '"Segoe UI Emoji"', - '"Segoe UI Symbol"', - ].join(','), - }, - components: { - MuiCssBaseline: { - styleOverrides: { - ':root': { - '--sb-width': '6px', - '--sb-thumb-color': 'rgba(0, 0, 0, 0.1)', - '--sb-thumb-hover': 'rgba(0, 0, 0, 0.2)', - '--sb-track-color': 'transparent', - }, - html: { - margin: 0, - padding: 0, - width: '100%', - minHeight: '100%', - backgroundColor: '#f5f5f5', - }, - 'body, #root': { - margin: 0, - padding: 0, - width: '100%', - minHeight: '100%', - }, - // 针对 Popup 的特殊处理(如果需要固定宽高,可以在具体入口点或容器中处理, - // 这里提供全局基础,具体尺寸在 App 容器中限制) - body: { - WebkitFontSmoothing: 'antialiased', - MozOsxFontSmoothing: 'grayscale', - }, - code: { - fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace', - }, - 'h1, h2, h3, h4, h5, h6': { - fontSize: 'inherit', - fontWeight: 'inherit', - }, - /* 全局极简滚动条定制 */ - '*::-webkit-scrollbar': { - width: 'var(--sb-width)', - }, - '*::-webkit-scrollbar-track': { - background: 'var(--sb-track-color)', - }, - '*::-webkit-scrollbar-thumb': { - background: 'var(--sb-thumb-color)', - borderRadius: '10px', - backgroundClip: 'content-box', - border: '1px solid transparent', - }, - '*::-webkit-scrollbar-thumb:hover': { - background: 'var(--sb-thumb-hover)', - }, - /* Animations */ - '@keyframes slideInRight': { - from: { - transform: 'translateX(30px)', - opacity: 0, + components: { + MuiCssBaseline: { + styleOverrides: (theme) => ({ + ':root': { + '--sb-width': '6px', + '--sb-thumb-color': + 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', }, - to: { - transform: 'translateX(0)', - opacity: 1, + html: { + margin: 0, + padding: 0, + width: '100%', + minHeight: '100%', + backgroundColor: theme.palette.background.default, }, - }, - '@keyframes fadeIn': { - from: { - opacity: 0, + 'body, #root': { + margin: 0, + padding: 0, + width: '100%', + minHeight: '100%', }, - to: { - opacity: 1, + body: { + WebkitFontSmoothing: 'antialiased', + MozOsxFontSmoothing: 'grayscale', + backgroundColor: theme.palette.background.default, + color: theme.palette.text.primary, }, - }, - '.page-transition-enter': { - animation: 'slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards', - }, - '.page-transition-dashboard': { - animation: 'fadeIn 0.3s ease-out forwards', - }, + code: { + fontFamily: 'source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace', + }, + 'h1, h2, h3, h4, h5, h6': { + fontSize: 'inherit', + fontWeight: 'inherit', + }, + /* 全局极简滚动条定制 */ + '*::-webkit-scrollbar': { + width: 'var(--sb-width)', + }, + '*::-webkit-scrollbar-track': { + background: 'var(--sb-track-color)', + }, + '*::-webkit-scrollbar-thumb': { + background: 'var(--sb-thumb-color)', + borderRadius: '10px', + backgroundClip: 'content-box', + border: '1px solid transparent', + }, + '*::-webkit-scrollbar-thumb:hover': { + background: 'var(--sb-thumb-hover)', + }, + /* Animations */ + '@keyframes slideInRight': { + from: { + transform: 'translateX(30px)', + opacity: 0, + }, + to: { + transform: 'translateX(0)', + opacity: 1, + }, + }, + '@keyframes fadeIn': { + from: { + opacity: 0, + }, + to: { + opacity: 1, + }, + }, + '.page-transition-enter': { + animation: 'slideInRight 0.3s cubic-bezier(0.25, 0.8, 0.25, 1) forwards', + }, + '.page-transition-dashboard': { + animation: 'fadeIn 0.3s ease-out forwards', + }, + }), }, }, - }, -}); + }); +} +const theme = getTheme('light'); export default theme; diff --git a/entrypoints/options/App.tsx b/entrypoints/options/App.tsx index 31d7046..4c66d17 100644 --- a/entrypoints/options/App.tsx +++ b/entrypoints/options/App.tsx @@ -28,8 +28,13 @@ import { import GlobalSnackbar, { useSnackbarState } from '@/components/GlobalSnackbar'; import ErrorBoundary from '@/components/ErrorBoundary'; 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 type { PaletteColorKey } from '@/config/features'; + +/** 安全地从 theme.palette 中取 PaletteColor */ +const getPaletteColor = (theme: Theme, key: PaletteColorKey): PaletteColor => + (theme.palette as unknown as Record)[key]; type WindowType = 'popup' | 'sidepanel' | 'tab'; @@ -38,6 +43,7 @@ type WindowType = 'popup' | 'sidepanel' | 'tab'; * 支持对不同窗口入口的功能显示和排序进行独立配置 */ export default function App() { + const theme = useTheme(); const { t } = useTranslation(['features', 'common']); // 从 URL 参数中初始化当前的 Tab 类型 const initialWindowType = useMemo(() => { @@ -204,7 +210,7 @@ export default function App() { className="app" sx={{ minHeight: '100vh', - bgcolor: 'grey.50', + bgcolor: 'background.default', display: 'flex', flexDirection: 'column', }} @@ -216,7 +222,7 @@ export default function App() { width: '100%', bgcolor: 'background.paper', borderBottom: '1px solid', - borderColor: 'grey.200', + borderColor: 'divider', pt: { xs: 3, sm: 5 }, pb: 0, px: { xs: 2, sm: 4 }, @@ -225,7 +231,7 @@ export default function App() { } - iconColor={THEME_COLORS.primary} + iconColor={theme.palette.primary.main} title="应用设置" subtitle="针对不同窗口类型独立配置 Dashboard 中显示的功能及其排序" sx={{ mb: 4 }} @@ -270,12 +276,12 @@ export default function App() { borderRadius: 2.5, fontWeight: 700, textTransform: 'none', - borderColor: 'grey.200', + borderColor: 'divider', color: 'text.secondary', '&:hover': { borderColor: '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={{ borderRadius: 4, border: '1px solid', - borderColor: 'grey.200', + borderColor: 'divider', overflow: 'hidden', bgcolor: 'background.paper', boxShadow: '0 4px 24px rgba(0,0,0,0.03)', @@ -316,10 +322,13 @@ export default function App() { justifyContent: 'space-between', p: { xs: 2, sm: 2.5 }, 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)', '&: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 }} > {/* 拖拽/排序暗示图标 */} - + @@ -339,8 +348,12 @@ export default function App() { sx={{ p: 1.2, borderRadius: 2.5, - bgcolor: alpha(feature.themeColor || THEME_COLORS.primary, 0.1), - color: feature.themeColor || THEME_COLORS.primary, + bgcolor: alpha( + getPaletteColor(theme, feature.themeColorKey ?? 'primary').main, + 0.1, + ), + color: getPaletteColor(theme, feature.themeColorKey ?? 'primary') + .main, display: 'flex', flexShrink: 0, }} @@ -386,11 +399,11 @@ export default function App() { onClick={() => handleMove(index, 'up')} disabled={index === 0} sx={{ - color: 'grey.400', + color: 'text.disabled', p: { xs: 0.5, sm: 1 }, '&:hover': { 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')} disabled={index === array.length - 1} sx={{ - color: 'grey.400', + color: 'text.disabled', p: { xs: 0.5, sm: 1 }, '&:hover': { 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} sx={{ '& .MuiSwitch-switchBase.Mui-checked': { - color: feature.themeColor || THEME_COLORS.primary, + color: getPaletteColor(theme, feature.themeColorKey ?? 'primary') + .main, }, '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { - backgroundColor: feature.themeColor || THEME_COLORS.primary, + backgroundColor: getPaletteColor( + theme, + feature.themeColorKey ?? 'primary', + ).main, }, }} /> diff --git a/entrypoints/options/main.tsx b/entrypoints/options/main.tsx index 9be295d..d14e48a 100644 --- a/entrypoints/options/main.tsx +++ b/entrypoints/options/main.tsx @@ -1,16 +1,10 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; -import { ThemeProvider } from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; -import theme from '@/config/theme'; +import AppRoot from '@/providers/AppRoot'; import '@/i18n'; import App from './App'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - , + + + , ); diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx index 34256c0..3e71281 100644 --- a/entrypoints/popup/App.tsx +++ b/entrypoints/popup/App.tsx @@ -2,7 +2,6 @@ import RouterProvider from '@/providers/RouterProvider'; import TopBar from '@/components/TopBar'; import RouterContainer from '@/components/RouterContainer'; import ErrorBoundary from '@/components/ErrorBoundary'; -import { globalStyles } from '@/config/pageTheme'; import { SnackbarProvider } from '@/components/GlobalSnackbar'; import { Box } from '@mui/material'; import { getEntryPointType } from '@/config/features'; @@ -49,7 +48,7 @@ export default function App() { height: '600px', minHeight: '600px', overflow: 'hidden', - backgroundColor: globalStyles.backgroundColor, + backgroundColor: 'background.default', // 仅在明确的大屏幕(如独立页面或侧边栏拉伸)下才允许扩展 '@media screen and (min-width: 600px)': { width: '100vw', diff --git a/entrypoints/popup/main.tsx b/entrypoints/popup/main.tsx index 5a429fb..d841129 100644 --- a/entrypoints/popup/main.tsx +++ b/entrypoints/popup/main.tsx @@ -1,16 +1,10 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; -import { ThemeProvider } from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; -import theme from '@/config/theme'; +import AppRoot from '@/providers/AppRoot'; import '@/i18n'; import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - , + + + , ); diff --git a/entrypoints/sidepanel/main.tsx b/entrypoints/sidepanel/main.tsx index 5a429fb..d841129 100644 --- a/entrypoints/sidepanel/main.tsx +++ b/entrypoints/sidepanel/main.tsx @@ -1,16 +1,10 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; -import { ThemeProvider } from '@mui/material/styles'; -import CssBaseline from '@mui/material/CssBaseline'; -import theme from '@/config/theme'; +import AppRoot from '@/providers/AppRoot'; import '@/i18n'; import App from './App.tsx'; ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - , + + + , ); diff --git a/i18n/index.ts b/i18n/index.ts index f2be107..bb768c1 100644 --- a/i18n/index.ts +++ b/i18n/index.ts @@ -19,6 +19,8 @@ import textStatisticsZh from './locales/zh/textStatistics.json'; import textStatisticsEn from './locales/en/textStatistics.json'; import jwtZh from './locales/zh/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 = { zh: { @@ -29,6 +31,7 @@ const resources = { qrCode: qrCodeZh, textStatistics: textStatisticsZh, jwt: jwtZh, + jsonDiff: jsonDiffZh, }, en: { common: commonEn, @@ -38,6 +41,7 @@ const resources = { qrCode: qrCodeEn, textStatistics: textStatisticsEn, jwt: jwtEn, + jsonDiff: jsonDiffEn, }, }; @@ -100,7 +104,16 @@ i18n resources, fallbackLng: 'en', lng: syncLng || undefined, - ns: ['common', 'features', 'timestamp', 'storageCleaner', 'qrCode', 'textStatistics', 'jwt'], + ns: [ + 'common', + 'features', + 'timestamp', + 'storageCleaner', + 'qrCode', + 'textStatistics', + 'jwt', + 'jsonDiff', + ], defaultNS: 'common', debug: false, interpolation: { diff --git a/i18n/locales/en/common.json b/i18n/locales/en/common.json index fa2f59d..9db3fa4 100644 --- a/i18n/locales/en/common.json +++ b/i18n/locales/en/common.json @@ -8,6 +8,12 @@ "clear": "Clear", "refresh": "Refresh", "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...", "back": "Back", "clearSearch": "Clear search", diff --git a/i18n/locales/en/features.json b/i18n/locales/en/features.json index 302c76c..35d5a2b 100644 --- a/i18n/locales/en/features.json +++ b/i18n/locales/en/features.json @@ -21,5 +21,9 @@ "jwt": { "title": "JWT Parser", "description": "JSON Web Token decoding and viewing" + }, + "jsonDiff": { + "title": "JSON Diff", + "description": "Compare differences between two JSON values" } } diff --git a/i18n/locales/en/jsonDiff.json b/i18n/locales/en/jsonDiff.json new file mode 100644 index 0000000..316a062 --- /dev/null +++ b/i18n/locales/en/jsonDiff.json @@ -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" +} diff --git a/i18n/locales/zh/common.json b/i18n/locales/zh/common.json index e49b770..1caae28 100644 --- a/i18n/locales/zh/common.json +++ b/i18n/locales/zh/common.json @@ -8,6 +8,12 @@ "clear": "清理", "refresh": "刷新", "toggleLanguage": "切换语言", + "toggleTheme": "切换主题", + "themeMode": { + "light": "切换到深色模式", + "dark": "切换到系统模式", + "system": "切换到浅色模式" + }, "search": "搜索工具...", "back": "返回", "clearSearch": "清除搜索", diff --git a/i18n/locales/zh/features.json b/i18n/locales/zh/features.json index 72a8f6e..7f08c2e 100644 --- a/i18n/locales/zh/features.json +++ b/i18n/locales/zh/features.json @@ -21,5 +21,9 @@ "jwt": { "title": "JWT 解析", "description": "JSON Web Token 解码与查看" + }, + "jsonDiff": { + "title": "JSON 差异比较", + "description": "对比两个 JSON 数据的差异" } } diff --git a/i18n/locales/zh/jsonDiff.json b/i18n/locales/zh/jsonDiff.json new file mode 100644 index 0000000..6fbe180 --- /dev/null +++ b/i18n/locales/zh/jsonDiff.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": "修改" +} diff --git a/pages/Dashboard/ToolCard.tsx b/pages/Dashboard/ToolCard.tsx index 5a93c75..7415aaf 100644 --- a/pages/Dashboard/ToolCard.tsx +++ b/pages/Dashboard/ToolCard.tsx @@ -4,9 +4,10 @@ * 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、 * 快照内容展示,具备悬停动画效果。 */ -import { alpha, Box, Card, CardActionArea, Stack, Typography } from '@mui/material'; +import { alpha, Box, Card, CardActionArea, Stack, Typography, useTheme } from '@mui/material'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; import React from 'react'; +import type { PaletteColorKey } from '@/config/features'; /** * ToolCard 组件属性接口 @@ -18,8 +19,8 @@ interface ToolCardProps { description?: string; /** 快照内容,用于在卡片底部展示额外信息(可选) */ snapshot?: React.ReactNode; - /** 主题色代码,用于图标背景和悬停效果 */ - colorCode: string; + /** 主题色键,映射到 theme.palette[key].main */ + colorKey: PaletteColorKey; /** 工具图标元素 */ icon: React.ReactNode; /** 卡片点击事件处理函数 */ @@ -36,10 +37,13 @@ export default function ToolCard({ title, description, snapshot, - colorCode, + colorKey, icon, onClick, }: ToolCardProps) { + const theme = useTheme(); + const colorCode = theme.palette[colorKey].main; + return ( diff --git a/pages/Dashboard/index.tsx b/pages/Dashboard/index.tsx index 7084c18..91b7a85 100644 --- a/pages/Dashboard/index.tsx +++ b/pages/Dashboard/index.tsx @@ -19,14 +19,14 @@ export default function DashboardPage() { if (!visibleSet.has(key as PageType)) return null; const feature = getFeatureByKey(key); - if (!feature?.themeColor || feature.icon == null) return null; + if (!feature?.themeColorKey || feature.icon == null) return null; return ( navigateTo(key)} /> diff --git a/pages/JsonDiff/DiffNavigator.tsx b/pages/JsonDiff/DiffNavigator.tsx new file mode 100644 index 0000000..2573e4e --- /dev/null +++ b/pages/JsonDiff/DiffNavigator.tsx @@ -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 ( + + + {t('jsonDiff:noDiffs')} + + + ); + } + + return ( + + + + + + {currentIndex + 1} / {total} + + + + + + ); +} + +export type { DiffNavigatorProps }; diff --git a/pages/JsonDiff/DiffResult.tsx b/pages/JsonDiff/DiffResult.tsx new file mode 100644 index 0000000..1967f90 --- /dev/null +++ b/pages/JsonDiff/DiffResult.tsx @@ -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 ( + + + + + + + + + + + ); + } + + return ( + + + + ); +} + +const SectionLabel = ({ text }: { text: string }) => ( + + {text} + +); + +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 ( + <> + + + + ); + } + const value = node.type === 'added' ? node.newValue : node.oldValue; + return ( + + ); + } + + // 容器节点:added/removed 整块呈现 + if (node.type === 'added') { + return ( + + ); + } + if (node.type === 'removed') { + return ( + + ); + } + + const isArr = Array.isArray(node.oldValue) || Array.isArray(node.newValue); + const open = isArr ? '[' : '{'; + const close = isArr ? ']' : '}'; + + return ( + <> + + {node.children?.map((child) => ( + + ))} + + + ); +}; + +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 ( + + + {prefixForType(type)} + + {text} + + ); +}; + +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 }; diff --git a/pages/JsonDiff/JsonDiffInput.tsx b/pages/JsonDiff/JsonDiffInput.tsx new file mode 100644 index 0000000..bc9ad83 --- /dev/null +++ b/pages/JsonDiff/JsonDiffInput.tsx @@ -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 ( + + + {label} + + onChange(e.target.value)} + error={Boolean(error)} + sx={jsonDiffPageStyles.INPUT_STYLE} + /> + {error && ( + + {error} + + )} + + ); +} + +export { JsonDiffInput }; +export type { JsonDiffInputProps }; diff --git a/pages/JsonDiff/JsonTree.tsx b/pages/JsonDiff/JsonTree.tsx new file mode 100644 index 0000000..332cf59 --- /dev/null +++ b/pages/JsonDiff/JsonTree.tsx @@ -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(null); + const theme = useTheme(); + + const onActivePath = Boolean( + activePath && + (activePath === node.path || + activePath.startsWith(`${node.path}.`) || + activePath.startsWith(`${node.path}[`)), + ); + + const expanded = + override === 'open' + ? true + : override === 'closed' + ? false + : onActivePath || depth < defaultExpandDepth; + + // 当激活路径定位到本节点时滚动到视图中心(仅 DOM 副作用,不更新 state) + useEffect(() => { + if (activePath === node.path) { + rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [activePath, node.path]); + + if (!shouldRenderOnSide(node.type, side)) { + // 渲染占位空行以保持左右两侧高度一致 + return ·; + } + + const value = getValueForSide(node, side); + const isContainer = isContainerValue(value) && Array.isArray(node.children); + const isArray = Array.isArray(value); + const bg = getRowBg(node.type, side, theme); + const valueColor = getValueColor(node.type, side); + const isActive = activePath === node.path; + + // 根节点渲染 + const isRoot = depth === 0; + + if (isContainer && node.children) { + const open = isArray ? '[' : '{'; + const close = isArray ? ']' : '}'; + return ( + + setOverride(expanded ? 'closed' : 'open')} + sx={{ + cursor: 'pointer', + pl: depth * 1.5, + pr: 1, + py: 0.2, + bgcolor: bg, + outline: isActive ? '2px solid' : 'none', + outlineColor: 'primary.main', + borderRadius: 0.5, + display: 'flex', + alignItems: 'center', + gap: 0.5, + whiteSpace: 'nowrap', + '&:hover': { bgcolor: bg ?? 'action.hover' }, + }} + > + + {expanded ? '▾' : '▸'} + + {!isRoot && ( + + {isArrayKeyDisplay(node.key)}: + + )} + + {open} + + {!expanded && ( + + {summarize(value)} + + )} + {!expanded && ( + + {close} + {isLastChild ? '' : ','} + + )} + + + + {node.children.map((child, idx) => ( + + ))} + + + {close} + {isLastChild ? '' : ','} + + + + ); + } + + // 叶子节点 + return ( + + + {!isRoot && ( + + {isArrayKeyDisplay(node.key)}: + + )} + + {formatPrimitive(value)} + {isLastChild ? '' : ','} + + + ); +}; + +const isArrayKeyDisplay = (key: string): string => { + // 数组索引在父级渲染中已加方括号;这里仅显示对象键名 + return key; +}; + +const summarize = (v: unknown): string => { + if (Array.isArray(v)) return ` ${v.length} ${v.length === 1 ? 'item' : 'items'} `; + if (v && typeof v === 'object') { + const n = Object.keys(v).length; + return ` ${n} ${n === 1 ? 'key' : 'keys'} `; + } + return ''; +}; + +export default function JsonTree({ + node, + side, + defaultExpandDepth = 2, + activePath, +}: JsonTreeProps) { + const sideKey = useMemo(() => side, [side]); + return ( + + + + ); +} + +export type { JsonTreeProps }; diff --git a/pages/JsonDiff/diffEngine.ts b/pages/JsonDiff/diffEngine.ts new file mode 100644 index 0000000..66924f9 --- /dev/null +++ b/pages/JsonDiff/diffEngine.ts @@ -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 => + 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 }; diff --git a/pages/JsonDiff/index.tsx b/pages/JsonDiff/index.tsx new file mode 100644 index 0000000..55ec0ac --- /dev/null +++ b/pages/JsonDiff/index.tsx @@ -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(null); + const [rightError, setRightError] = useState(null); + const [diffResult, setDiffResult] = useState(null); + const [viewMode, setViewMode] = useState('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 ( + + + } + iconColor={jsonDiffPageStyles.primaryColor} + /> + + + {/* 工具栏 */} + + v && setViewMode(v)} + sx={{ borderRadius: 3 }} + > + + {t('jsonDiff:sideBySideMode')} + + + {t('jsonDiff:unifiedMode')} + + + + + + + + + {/* 输入区 */} + + + + + + {/* 差异展示 */} + {diffResult ? ( + <> + + + + ) : ( + + 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', + }} + > + + {t('jsonDiff:emptyHint')} + + + )} + + + + ); +} diff --git a/pages/JsonDiff/types.ts b/pages/JsonDiff/types.ts new file mode 100644 index 0000000..79b618c --- /dev/null +++ b/pages/JsonDiff/types.ts @@ -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; +} diff --git a/pages/Jwt/index.tsx b/pages/Jwt/index.tsx index c54fdae..b98c9c5 100644 --- a/pages/Jwt/index.tsx +++ b/pages/Jwt/index.tsx @@ -149,7 +149,7 @@ export default function Index() { > {t('jwt:signatureTitle')} diff --git a/pages/QrCode/QrCodeToUrlSection.tsx b/pages/QrCode/QrCodeToUrlSection.tsx index 5ce767d..52ce9bd 100644 --- a/pages/QrCode/QrCodeToUrlSection.tsx +++ b/pages/QrCode/QrCodeToUrlSection.tsx @@ -223,7 +223,7 @@ const QrCodeToUrlSection = ({ expanded, onExpandedChange }: QrCodeToUrlSectionPr ) : ( <> - + {t('qrCode:clickToUpload')} diff --git a/pages/QrCode/UrlToQrCodeSection.tsx b/pages/QrCode/UrlToQrCodeSection.tsx index 85c4a0f..6960dff 100644 --- a/pages/QrCode/UrlToQrCodeSection.tsx +++ b/pages/QrCode/UrlToQrCodeSection.tsx @@ -57,8 +57,8 @@ const UrlToQrCodeSection = ({ expanded, onExpandedChange }: UrlToQrCodeSectionPr value: url, size: 250, level: 'H', - foreground: qrCodePageStyles.black, - background: qrCodePageStyles.white, + foreground: '#000000', + background: '#FFFFFF', }); setQrCodeDataUrl(qr.toDataURL()); diff --git a/pages/StorageCleaner/StorageOptionsGrid.tsx b/pages/StorageCleaner/StorageOptionsGrid.tsx index 8acea10..f4d2d37 100644 --- a/pages/StorageCleaner/StorageOptionsGrid.tsx +++ b/pages/StorageCleaner/StorageOptionsGrid.tsx @@ -49,7 +49,7 @@ export default function StorageOptionsGrid({ ))} - + + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50', transition: 'all 0.2s', '& fieldset': { - borderColor: 'grey.200', + borderColor: (theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'grey.200', }, '&:hover fieldset': { - borderColor: 'grey.300', + borderColor: (theme) => + theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'grey.300', }, '&.Mui-focused fieldset': { borderColor: textStatisticsPageStyles.primaryColor, diff --git a/pages/Timestamp/ResultView.tsx b/pages/Timestamp/ResultView.tsx index 0cbb891..cb42272 100644 --- a/pages/Timestamp/ResultView.tsx +++ b/pages/Timestamp/ResultView.tsx @@ -35,7 +35,7 @@ const ResultView = React.memo(({ result, mode, unit, zone }: ResultViewProps) => return ( - + {t('timestamp:resultLabel')} diff --git a/providers/AppRoot.tsx b/providers/AppRoot.tsx new file mode 100644 index 0000000..906e841 --- /dev/null +++ b/providers/AppRoot.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { ThemeModeProvider } from './ThemeModeProvider'; + +interface AppRootProps { + children: React.ReactNode; +} + +export default function AppRoot({ children }: AppRootProps) { + return ( + + {children} + + ); +} diff --git a/providers/ThemeModeProvider.tsx b/providers/ThemeModeProvider.tsx new file mode 100644 index 0000000..fd592a8 --- /dev/null +++ b/providers/ThemeModeProvider.tsx @@ -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(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(getSyncSnapshot); + const [resolvedMode, setResolvedMode] = useState( + 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 ( + + + + {children} + + + ); +} + +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; diff --git a/types/storage.d.ts b/types/storage.d.ts index 4468112..dcdcc7f 100644 --- a/types/storage.d.ts +++ b/types/storage.d.ts @@ -7,7 +7,8 @@ export type PageType = | 'storageCleaner' // 存储清理工具 | 'qrCode' // 二维码工具 | 'textStatistics' // 文本统计工具 - | 'jwt'; // JWT 解析工具 + | 'jwt' // JWT 解析工具 + | 'jsonDiff'; // JSON 差异比较工具 /** * 表单映射条目定义 @@ -75,6 +76,8 @@ export interface StorageSchema { 'app/lastRoute': string; /** 应用主题配置 */ 'app/theme': string; + /** 主题模式偏好(light/dark/system) */ + 'app/themeMode': 'light' | 'dark' | 'system'; /** 存储清理工具的偏好设置 */ 'storageCleaner/preferences': StorageCleanerPreferences; /** 二维码工具中二维码部分是否展开 */ diff --git a/utils/__tests__/diffEngine.test.ts b/utils/__tests__/diffEngine.test.ts new file mode 100644 index 0000000..a00dfc6 --- /dev/null +++ b/utils/__tests__/diffEngine.test.ts @@ -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); + }); +}); diff --git a/vitest.setup.ts b/vitest.setup.ts index 0de4f1b..c6fb4ec 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -84,3 +84,21 @@ beforeEach(() => { afterEach(() => { 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(), + })), + }); +});