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:
@@ -4,9 +4,10 @@
|
||||
* 用于在仪表盘中展示各个工具功能的卡片组件,支持图标、标题、描述、
|
||||
* 快照内容展示,具备悬停动画效果。
|
||||
*/
|
||||
import { alpha, Box, Card, CardActionArea, Stack, Typography } from '@mui/material';
|
||||
import { alpha, Box, Card, CardActionArea, Stack, Typography, useTheme } from '@mui/material';
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||
import React from 'react';
|
||||
import type { PaletteColorKey } from '@/config/features';
|
||||
|
||||
/**
|
||||
* ToolCard 组件属性接口
|
||||
@@ -18,8 +19,8 @@ interface ToolCardProps {
|
||||
description?: string;
|
||||
/** 快照内容,用于在卡片底部展示额外信息(可选) */
|
||||
snapshot?: React.ReactNode;
|
||||
/** 主题色代码,用于图标背景和悬停效果 */
|
||||
colorCode: string;
|
||||
/** 主题色键,映射到 theme.palette[key].main */
|
||||
colorKey: PaletteColorKey;
|
||||
/** 工具图标元素 */
|
||||
icon: React.ReactNode;
|
||||
/** 卡片点击事件处理函数 */
|
||||
@@ -36,10 +37,13 @@ export default function ToolCard({
|
||||
title,
|
||||
description,
|
||||
snapshot,
|
||||
colorCode,
|
||||
colorKey,
|
||||
icon,
|
||||
onClick,
|
||||
}: ToolCardProps) {
|
||||
const theme = useTheme();
|
||||
const colorCode = theme.palette[colorKey].main;
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
@@ -47,7 +51,7 @@ export default function ToolCard({
|
||||
position: 'relative',
|
||||
borderRadius: 4,
|
||||
border: '1px solid',
|
||||
borderColor: 'grey.100',
|
||||
borderColor: 'divider',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
transition:
|
||||
@@ -124,7 +128,7 @@ export default function ToolCard({
|
||||
className="arrow-icon"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'grey.300',
|
||||
color: 'text.disabled',
|
||||
mt: 0.5,
|
||||
transition: 'all 0.3s ease',
|
||||
}}
|
||||
@@ -137,7 +141,7 @@ export default function ToolCard({
|
||||
mt: 'auto',
|
||||
pt: 1.5,
|
||||
borderTop: '1px dashed',
|
||||
borderColor: 'grey.100',
|
||||
borderColor: 'divider',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -19,14 +19,14 @@ export default function DashboardPage() {
|
||||
if (!visibleSet.has(key as PageType)) return null;
|
||||
|
||||
const feature = getFeatureByKey(key);
|
||||
if (!feature?.themeColor || feature.icon == null) return null;
|
||||
if (!feature?.themeColorKey || feature.icon == null) return null;
|
||||
|
||||
return (
|
||||
<ToolCard
|
||||
key={key}
|
||||
title={t(feature.labelKey)}
|
||||
description={t(feature.descriptionKey)}
|
||||
colorCode={feature.themeColor}
|
||||
colorKey={feature.themeColorKey}
|
||||
icon={feature.icon}
|
||||
onClick={() => navigateTo(key)}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 800, color: '#00b9f1', letterSpacing: 0.5 }}
|
||||
sx={{ fontWeight: 800, color: 'info.main', letterSpacing: 0.5 }}
|
||||
>
|
||||
{t('jwt:signatureTitle')}
|
||||
</Typography>
|
||||
|
||||
@@ -223,7 +223,7 @@ const QrCodeToUrlSection = ({ expanded, onExpandedChange }: QrCodeToUrlSectionPr
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
|
||||
<ImageIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
{t('qrCode:clickToUpload')}
|
||||
</Typography>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function StorageOptionsGrid({
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
<Divider sx={{ mx: 0, borderColor: 'grey.100' }} />
|
||||
<Divider sx={{ mx: 0, borderColor: 'divider' }} />
|
||||
<Box sx={storageCleanerPageStyles.OPTIONS_GRID_FOOTER}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
||||
@@ -50,13 +50,16 @@ export default function Index() {
|
||||
mb: 3,
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 4,
|
||||
bgcolor: 'grey.50',
|
||||
bgcolor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
|
||||
transition: 'all 0.2s',
|
||||
'& fieldset': {
|
||||
borderColor: 'grey.200',
|
||||
borderColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.1)' : 'grey.200',
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'grey.300',
|
||||
borderColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.2)' : 'grey.300',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: textStatisticsPageStyles.primaryColor,
|
||||
|
||||
@@ -35,7 +35,7 @@ const ResultView = React.memo(({ result, mode, unit, zone }: ResultViewProps) =>
|
||||
|
||||
return (
|
||||
<Fade in={!!result}>
|
||||
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'grey.50' }}>
|
||||
<Box sx={{ mt: 3, pt: 2.5, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="caption" sx={timestampPageStyles.RESULT_LABEL}>
|
||||
{t('timestamp:resultLabel')}
|
||||
</Typography>
|
||||
|
||||
Reference in New Issue
Block a user