Compare commits

..

14 Commits

Author SHA1 Message Date
rsgltzyd 31e28b136f refactor(json-tools): 优化 JsonDiffPanel 交互体验并增强 TextInputArea 灵活性 2026-07-13 23:37:55 +08:00
rsgltzyd 72e3f9012f feat(json-tools): 实现响应式布局与窄屏手动比对模式 2026-07-13 23:37:55 +08:00
rsgltzyd 913b246d56 style(ui): 优化 SwitchButtonGroup 交互样式并清理测试用例 2026-07-13 23:37:55 +08:00
rsgltzyd 4b44345c6e refactor(utils): 将 diffNode 实现从递归改为迭代遍历 2026-07-13 23:37:55 +08:00
rsgltzyd a714ddd5a2 style: 增强 SwitchButtonGroup 的交互样式并移除冗余属性 2026-07-13 23:37:55 +08:00
rsgltzyd e6276c573f refactor: 优化组件导出并移除未使用的组件并清理冗余代码 2026-07-13 23:37:55 +08:00
rsgltzyd e3851ed75d refactor: 为 SelectItem 组件添加 cursor-pointer 类以改善用户体验 2026-07-13 23:37:55 +08:00
rsgltzyd 6774504d39 refactor: 更新组件引用类型为 ComponentRef,以提高类型安全性 2026-07-13 23:37:54 +08:00
rsgltzyd 83866c8ae0 refactor: 更新样式和组件结构以提升一致性和可读性
- 在 utils.ts 中引入 twMerge 以合并 Tailwind 类。
- 在 ConverterForm.tsx 中简化类名,移除冗余样式。
- 在 LiveClock.tsx 中调整样式,优化文本显示。
- 在 ResultView.tsx 中更新布局和样式,提升视觉效果。
2026-07-13 23:37:54 +08:00
rsgltzyd 8424fa219f refactor: 移除 PageSkeleton 组件及相关测试,更新 RouterContainer 以简化加载逻辑 2026-07-13 23:37:54 +08:00
rsgltzyd 06c2a222ab 重构导航逻辑,去除dashboard页面相关功能,增加侧边栏导航功能 2026-07-05 22:34:12 +08:00
rsgltzyd 1af3b25af8 Merge branch 'develop' 2026-07-05 21:39:32 +08:00
rsgltzyd 550f8f82c7 Merge branch 'develop' 2026-06-30 23:26:27 +08:00
LingandRX 36b6bfd20d Develop (#74)
* feat: 生成成功改为 toast 提示
* refactor: 数据预览改为显示示例数据,简化组件逻辑
* fix: 非必填字段预览显示 null
* fix: 修复空值率与必填状态的逻辑
* fix: 切换为非必填时默认空值率 100%
* feat: 字段编辑弹窗添加「完成」按钮 去除关闭按钮,添加取消按钮
* refactor: 删除未使用的 defaultNullRate 功能
* feat: 优化规则管理功能
* fix: 更新 features 测试断言以匹配新增的 testDataGenerator
* refactor: 将 formatFileSize 重命名为 formatBytes 并移至通用工具模块
* refactor: 使用 Label 组件替换原生 label 元素,并在 DecodeResultPaper 中引入 Input 组件
* refactor: 规范化页面组件目录结构
* refactor(JsonTools): 规范化页面组件目录结构
* refactor(StorageCleaner): 规范化页面组件目录结构
* refactor(Timestamp): 规范化页面组件目录结构
* refactor(i18n): 移除 chrome.i18n 国际化,统一使用中文硬编码
* refactor(AppRoot): 移除 RouterProvider 组件,直接渲染子组件
* fix(RouterProvider): 将 goBack 重命名为 goHome,修复跨窗同步 merge 不一致
* fix(ThemeModeProvider): 修复主题快照同步与首屏闪烁问题
* refactor: 将 UI 文案改为中文并优化相关逻辑
* refactor: 清理代码注释并优化组件结构
* feat(Dashboard): 重构仪表板功能组件和逻辑
* refactor: 更新功能键名称并移除 README 文件
* refactor: 重构 TopBar 组件并更新项目结构
* feat(TopBar): 添加搜索功能组件并优化结构
* refactor: 统一使用 Button 组件替换原有 button 元素
* feat: 添加 EmptyPlaceholder 组件并更新相关页面
* feat(TextStatistics): 引入 StatCard 组件以优化统计信息展示
* feat(Timestamp): 重构时间戳转换功能,新增 ConverterForm 组件
* feat(Jwt): 重构 JWT 组件,新增常量和结果视图
* refactor(RightClickRestorer): 用状态配置表简化页面 UI 结构
* fix(business): 修复 skewedRandom 函数中的数学计算错误
* refactor(ImageUploader): 移除预览 URL 相关逻辑并简化粘贴事件处理
* feat(TestDataGenerator): 更新规则编辑提示和警告显示逻辑
* refactor(CopyButton.test): 移除冗余测试用例
* refactor(RouterProvider, chromeStorage, useContextMenuData): 更新存储获取逻辑以移除默认值参数
* refactor(代码重复): 抽取共享工具函数与 UI 组件,消除多处重复实现
* fix(RouterProvider): 修复初始加载状态逻辑,确保在加载前不覆盖 chrome.storage
* feat(TopBar): 添加搜索框快捷键提示功能及相关测试
* refactor(StorageCleaner): 移动 isRestrictedUrl 函数到独立模块并更新相关引用
* fix(StorageCleaner): 修复 IndexedDB 清理竞态、部分成功计数与刷新后状态同步
* fix: 修复路由初始化竞态、存储状态静默覆盖与存储清理误删
* refactor: 统一中文文案并简化组件结构,提升代码可读性
* refactor: 更新 Worker 消息类型以支持生成任务 ID
* fix: 修复 ruleStorage 写入失败处理与 CopyButton 成功态样式
2026-06-27 17:58:59 +08:00
14 changed files with 161 additions and 325 deletions
+3 -2
View File
@@ -669,7 +669,7 @@ src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
### 9.1 功能元数据 ### 9.1 功能元数据
功能名称与描述在 `config/features.tsx``FEATURES` 数组中定义: 功能名称与描述在 `config/featureMeta.ts``FEATURES` 数组中定义:
```typescript ```typescript
{ {
@@ -677,10 +677,11 @@ src/pages/StorageCleaner/useStorageCleaner.ts — 页面级 Hook
label: '时间戳转换', label: '时间戳转换',
description: '日期与时间戳互转', description: '日期与时间戳互转',
defaultVisible: true, defaultVisible: true,
component: TimestampPage,
} }
``` ```
页面组件通过 `config/pageLoaders/` 按需加载,不在 `FEATURES` 中引用。
Dashboard 卡片、TopBar 搜索等功能从此处读取 `label` / `description` Dashboard 卡片、TopBar 搜索等功能从此处读取 `label` / `description`
### 9.2 页面与组件文案 ### 9.2 页面与组件文案
+7 -7
View File
@@ -382,14 +382,14 @@ function loadRule(ruleId: string): void {
使用 `localStorage`,键名 `testDataGenerator_rules`。API 为**命名导出函数**(见 `src/utils/ruleStorage.ts`): 使用 `localStorage`,键名 `testDataGenerator_rules`。API 为**命名导出函数**(见 `src/utils/ruleStorage.ts`):
| 函数 | 说明 | | 函数 | 说明 |
| ---- | ---- | | ------------------------------------------------------ | ---------------------------------- |
| `getAll()` / `getById()` / `getByName()` | 读取 | | `getAll()` / `getById()` / `getByName()` | 读取 |
| `save()` / `update()` / `deleteRule()` / `duplicate()` | 写入;失败时返回 `null``false` | | `save()` / `update()` / `deleteRule()` / `duplicate()` | 写入;失败时返回 `null``false` |
| `recordUse()` | 递增 `useCount`、更新 `lastUsedAt` | | `recordUse()` | 递增 `useCount`、更新 `lastUsedAt` |
| `search()` / `getRecent()` | 搜索与最近使用 | | `search()` / `getRecent()` | 搜索与最近使用 |
| `exportRules()` / `importRules()` | 导入导出 JSON 数组 | | `exportRules()` / `importRules()` | 导入导出 JSON 数组 |
| `clear()` | 清空全部规则 | | `clear()` | 清空全部规则 |
写入失败(如 `QuotaExceededError`)时,内部 `setAll()` 返回 `false``save`/`update` 返回 `null``deleteRule` 返回 `false`,并在控制台输出 `[ruleStorage] 保存规则失败`。调用方须检查返回值,避免误报成功。 写入失败(如 `QuotaExceededError`)时,内部 `setAll()` 返回 `false``save`/`update` 返回 `null``deleteRule` 返回 `false`,并在控制台输出 `[ruleStorage] 保存规则失败`。调用方须检查返回值,避免误报成功。
@@ -1505,9 +1505,7 @@ export class DataExporter {
```typescript ```typescript
// src/types/testDataGenerator.ts // src/types/testDataGenerator.ts
type WorkerRequestMessage = type WorkerRequestMessage = { type: 'start'; payload: WorkerStartPayload } | { type: 'cancel' };
| { type: 'start'; payload: WorkerStartPayload }
| { type: 'cancel' };
type WorkerResponseMessage = type WorkerResponseMessage =
| { type: 'progress'; generationId: number; payload: GenerateProgress } | { type: 'progress'; generationId: number; payload: GenerateProgress }
@@ -64,15 +64,4 @@ describe('useJsonTools 窄屏手动比对', () => {
act(() => result.current.toggleCollapseA()); act(() => result.current.toggleCollapseA());
expect(result.current.collapsedA).toBe(false); expect(result.current.collapsedA).toBe(false);
}); });
it('单输入经防抖后持久化,重挂载可恢复', async () => {
localStorage.clear();
const { result, unmount } = renderHook(() => useJsonTools());
act(() => result.current.setInput('{"x":1}'));
await new Promise((r) => setTimeout(r, 300));
unmount();
const { result: result2 } = renderHook(() => useJsonTools());
expect(result2.current.input).toBe('{"x":1}');
});
}); });
@@ -1,72 +0,0 @@
import React from 'react';
import { ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface CollapsiblePanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
/** 面板标题 */
title: string;
/** 是否处于折叠态 */
collapsed: boolean;
onToggleCollapse: () => void;
/** 折叠态展示的单行缩略预览文本 */
preview?: string;
/** 展开态内容 */
children?: React.ReactNode;
}
export default function CollapsiblePanel({
title,
collapsed,
onToggleCollapse,
preview,
children,
className,
...props
}: CollapsiblePanelProps) {
return (
<div
className={cn(
'flex flex-col overflow-hidden rounded-md border bg-card transition-all duration-300 ease-in-out',
className,
)}
style={{
flex: collapsed ? '0 0 36px' : '1 1 120px',
}}
{...props}
>
<button
type="button"
onClick={onToggleCollapse}
className={cn(
'flex h-[36px] w-full cursor-pointer select-none items-center justify-between bg-muted/20 hover:bg-muted/30 transition-colors px-3 shrink-0 text-left focus:outline-none focus:bg-muted/40',
!collapsed && 'border-b border-border/60',
)}
>
<span className="text-[11px] font-semibold text-foreground/90 shrink-0">{title}</span>
<span
className={cn(
'flex-1 truncate font-mono text-xs text-muted-foreground/70 ml-3 transition-all duration-300',
collapsed ? 'opacity-100 max-w-full' : 'opacity-0 max-w-0 pointer-events-none',
)}
>
{preview}
</span>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground transition-transform duration-300',
!collapsed && 'rotate-90',
)}
/>
</button>
<div
className={cn(
'flex-1 min-h-0 flex flex-col transition-opacity duration-300',
collapsed ? 'opacity-0 pointer-events-none' : 'opacity-100',
)}
>
{children}
</div>
</div>
);
}
@@ -1,45 +1,55 @@
import React, { useMemo } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import JsonResultPanel from './JsonResultPanel'; import JsonResultPanel from './JsonResultPanel';
import CollapsiblePanel from './CollapsiblePanel';
import { validateJson } from '@/utils/jsonFormatter'; import { validateJson } from '@/utils/jsonFormatter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
import type { ConvertFunction, ConvertResult } from '../types'; import type { ConvertFunction, ConvertResult } from '../types';
const CONVERT_LABELS: Record<string, { inputPlaceholder: string; outputLabel: string }> = { const CONVERT_LABELS: Record<
string,
{ inputPlaceholder: string; outputLabel: string; emptyHint: string }
> = {
yaml: { yaml: {
inputPlaceholder: '输入需要转换的 JSON...', inputPlaceholder: '输入需要转换的 JSON...',
outputLabel: 'YAML 结果', outputLabel: 'YAML 结果',
emptyHint: '输入 JSON 后点击转换',
}, },
toml: { toml: {
inputPlaceholder: '输入需要转换的 JSON...', inputPlaceholder: '输入需要转换的 JSON...',
outputLabel: 'TOML 结果', outputLabel: 'TOML 结果',
emptyHint: '输入 JSON 后点击转换',
}, },
minify: { minify: {
inputPlaceholder: '输入需要压缩的 JSON...', inputPlaceholder: '输入需要压缩的 JSON...',
outputLabel: '压缩结果', outputLabel: '压缩结果',
emptyHint: '输入 JSON 后点击压缩',
}, },
}; };
interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> { interface JsonConvertSectionProps extends React.HTMLAttributes<HTMLDivElement> {
tools: UseJsonToolsReturn;
mode: string; mode: string;
convertFunction: ConvertFunction; convertFunction: ConvertFunction;
} }
export default function JsonConvertSection({ export default function JsonConvertSection({
tools,
mode, mode,
convertFunction, convertFunction,
className, className,
...props ...props
}: JsonConvertSectionProps) { }: JsonConvertSectionProps) {
const { input, setInput, debouncedInput } = tools; const [input, setInput] = useState('');
const [inputCollapsed, setInputCollapsed] = useState(false); const [debouncedInput, setDebouncedInput] = useState('');
const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml; const labels = CONVERT_LABELS[mode] ?? CONVERT_LABELS.yaml;
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 250);
return () => clearTimeout(handle);
}, [input]);
const error = useMemo(() => { const error = useMemo(() => {
return validateJson(debouncedInput); return validateJson(debouncedInput);
}, [debouncedInput]); }, [debouncedInput]);
@@ -61,40 +71,32 @@ export default function JsonConvertSection({
} }
}, [debouncedInput, error, convertFunction]); }, [debouncedInput, error, convertFunction]);
const preview = useMemo(() => buildPreview(input), [input]);
return ( return (
<div className={cn('w-full flex flex-1 min-h-0 flex-col gap-3', className)} {...props}> <div className={cn('w-full flex flex-col gap-4', className)} {...props}>
<CollapsiblePanel <TextInputArea
title="JSON 输入" placeholder={labels.inputPlaceholder}
collapsed={inputCollapsed} value={input}
onToggleCollapse={() => setInputCollapsed((v) => !v)} onChange={setInput}
preview={preview} externalError={error || runtimeError || undefined}
> showClear={true}
<TextInputArea allowCopy={true}
fill minRows={7}
borderless maxRows={14}
placeholder={labels.inputPlaceholder} onClear={() => setInput('')}
value={input} />
onChange={setInput}
externalError={error || runtimeError || undefined}
showClear={true}
allowCopy={true}
className="min-h-0 flex-1"
onClear={() => setInput('')}
/>
</CollapsiblePanel>
{result?.output && ( {result?.output ? (
<div className="flex min-h-0 flex-1"> <JsonResultPanel
<JsonResultPanel title={labels.outputLabel}
fill content={result.output}
title={labels.outputLabel} originalBytes={result.originalBytes}
content={result.output} outputBytes={result.outputBytes}
originalBytes={result.originalBytes} maxHeight="380px"
outputBytes={result.outputBytes} />
/> ) : (
</div> <EmptyPlaceholder>
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
</EmptyPlaceholder>
)} )}
</div> </div>
); );
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { ChevronRight } from 'lucide-react';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import CollapsiblePanel from './CollapsiblePanel';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface JsonDiffPanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> { export interface JsonDiffPanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
@@ -30,30 +30,62 @@ export default function JsonDiffPanel({
...props ...props
}: JsonDiffPanelProps) { }: JsonDiffPanelProps) {
return ( return (
<CollapsiblePanel <div
title={title}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
preview={preview}
className={cn( className={cn(
'flex flex-col overflow-hidden rounded-md border bg-card transition-all duration-300 ease-in-out',
error error
? 'border-destructive focus-within:ring-1 focus-within:ring-destructive focus-within:border-destructive' ? 'border-destructive focus-within:ring-1 focus-within:ring-destructive focus-within:border-destructive'
: 'border-border focus-within:ring-1 focus-within:ring-ring focus-within:border-border/80', : 'border-border focus-within:ring-1 focus-within:ring-ring focus-within:border-border/80',
className, className,
)} )}
style={{
flex: collapsed ? '0 0 36px' : '1 1 120px',
}}
{...props} {...props}
> >
<TextInputArea <button
fill type="button"
value={value} onClick={onToggleCollapse}
onChange={onChange} className={cn(
placeholder={placeholder} 'flex h-[36px] w-full cursor-pointer select-none items-center justify-between bg-muted/20 hover:bg-muted/30 transition-colors px-3 shrink-0 text-left focus:outline-none focus:bg-muted/40',
externalError={error ?? undefined} !collapsed && 'border-b border-border/60',
showClear={true} )}
allowCopy={true} >
borderless <span className="text-[11px] font-semibold text-foreground/90 shrink-0">{title}</span>
className="min-h-0 flex-1" <span
/> className={cn(
</CollapsiblePanel> 'flex-1 truncate font-mono text-xs text-muted-foreground/70 ml-3 transition-all duration-300',
collapsed ? 'opacity-100 max-w-full' : 'opacity-0 max-w-0 pointer-events-none',
)}
>
{preview}
</span>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground hover:text-foreground transition-transform duration-300',
!collapsed && 'rotate-90',
)}
/>
</button>
<div
className={cn(
'flex-1 min-h-0 flex flex-col transition-opacity duration-300',
collapsed ? 'opacity-0 pointer-events-none' : 'opacity-100',
)}
>
<TextInputArea
fill
value={value}
onChange={onChange}
placeholder={placeholder}
externalError={error ?? undefined}
showClear={true}
allowCopy={true}
borderless
className="min-h-0 flex-1"
/>
</div>
</div>
); );
} }
@@ -1,27 +1,29 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
formatJson, formatJson,
type JsonFormatOptions, type JsonFormatOptions,
type JsonFormatResult, type JsonFormatResult,
validateJson, validateJson,
} from '@/utils/jsonFormatter'; } from '@/utils/jsonFormatter';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import CollapsiblePanel from './CollapsiblePanel';
import JsonResultPanel from './JsonResultPanel'; import JsonResultPanel from './JsonResultPanel';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { buildPreview, type UseJsonToolsReturn } from '../useJsonTools';
interface JsonFormatSectionProps { export default function JsonFormatSection() {
tools: UseJsonToolsReturn; const [input, setInput] = useState('');
} const [debouncedInput, setDebouncedInput] = useState('');
export default function JsonFormatSection({ tools }: JsonFormatSectionProps) {
const { input, setInput, debouncedInput } = tools;
const [indentSize, setIndentSize] = useState<number>(2); const [indentSize, setIndentSize] = useState<number>(2);
const [sortKeys, setSortKeys] = useState(false); const [sortKeys, setSortKeys] = useState(false);
const [inputCollapsed, setInputCollapsed] = useState(false);
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
}, 250);
return () => clearTimeout(handle);
}, [input]);
const error = useMemo(() => { const error = useMemo(() => {
return validateJson(debouncedInput); return validateJson(debouncedInput);
@@ -45,11 +47,9 @@ export default function JsonFormatSection({ tools }: JsonFormatSectionProps) {
} }
}, [debouncedInput, error, indentSize, sortKeys]); }, [debouncedInput, error, indentSize, sortKeys]);
const preview = useMemo(() => buildPreview(input), [input]);
return ( return (
<div className="w-full flex flex-1 min-h-0 flex-col gap-3"> <div className="w-full flex flex-col gap-4">
<div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60 shrink-0"> <div className="flex h-10 items-center justify-between px-1.5 bg-secondary/40 rounded-xl border border-border/60">
<div className="flex gap-4 items-center w-full"> <div className="flex gap-4 items-center w-full">
<div className="flex gap-2 items-center shrink-0 select-none"> <div className="flex gap-2 items-center shrink-0 select-none">
<span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider"> <span className="text-[10px] font-bold text-muted-foreground/90 uppercase tracking-wider">
@@ -57,7 +57,7 @@ export default function JsonFormatSection({ tools }: JsonFormatSectionProps) {
</span> </span>
<SwitchButtonGroup <SwitchButtonGroup
value={indentSize} value={indentSize}
onChange={(v: number) => setIndentSize(Number(v))} onChange={(v) => setIndentSize(Number(v))}
options={[2, 4, 6, 8].map((size) => ({ value: size, label: String(size) }))} options={[2, 4, 6, 8].map((size) => ({ value: size, label: String(size) }))}
size="small" size="small"
/> />
@@ -86,36 +86,29 @@ export default function JsonFormatSection({ tools }: JsonFormatSectionProps) {
</div> </div>
</div> </div>
<CollapsiblePanel <TextInputArea
title="JSON 输入" placeholder="输入需要格式化的 JSON..."
collapsed={inputCollapsed} value={input}
onToggleCollapse={() => setInputCollapsed((v) => !v)} onChange={setInput}
preview={preview} externalError={error || runtimeError || undefined}
> showClear={true}
<TextInputArea allowCopy={true}
fill minRows={8}
borderless maxRows={15}
placeholder="输入需要格式化的 JSON..." onClear={() => setInput('')}
value={input} />
onChange={setInput}
externalError={error || runtimeError || undefined}
showClear={true}
allowCopy={true}
className="min-h-0 flex-1"
onClear={() => setInput('')}
/>
</CollapsiblePanel>
{result?.formatted && ( {result?.formatted ? (
<div className="flex min-h-0 flex-1"> <JsonResultPanel
<JsonResultPanel title="格式化结果"
fill content={result.formatted}
title="格式化结果" originalBytes={result.originalBytes}
content={result.formatted} outputBytes={result.formattedBytes}
originalBytes={result.originalBytes} />
outputBytes={result.formattedBytes} ) : (
/> <EmptyPlaceholder>
</div> {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
</EmptyPlaceholder>
)} )}
</div> </div>
); );
@@ -1,6 +1,5 @@
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import { cn } from '@/lib/utils';
export interface JsonResultPanelProps { export interface JsonResultPanelProps {
title: string; title: string;
@@ -9,11 +8,6 @@ export interface JsonResultPanelProps {
outputBytes: number; outputBytes: number;
outputSizeLabel?: string; outputSizeLabel?: string;
maxHeight?: string; maxHeight?: string;
/**
* 撑满父容器剩余高度并启用内部滚动(而非按 maxHeight 截断)。
* 用于 popup / sidepanel 等固定高度场景下与折叠输入面板共享垂直空间。
*/
fill?: boolean;
} }
export default function JsonResultPanel({ export default function JsonResultPanel({
@@ -23,15 +17,9 @@ export default function JsonResultPanel({
outputBytes, outputBytes,
outputSizeLabel = '格式化后大小', outputSizeLabel = '格式化后大小',
maxHeight = '420px', maxHeight = '420px',
fill = false,
}: JsonResultPanelProps) { }: JsonResultPanelProps) {
return ( return (
<div <div className="relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden">
className={cn(
'relative rounded-xl border border-border bg-card text-card-foreground shadow-sm overflow-hidden',
fill ? 'flex flex-col flex-1 min-h-0' : 'flex flex-col',
)}
>
<div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none"> <div className="flex h-9 items-center justify-between px-4 border-b border-border bg-muted/50 select-none">
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90"> <span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground/90">
@@ -55,11 +43,8 @@ export default function JsonResultPanel({
</div> </div>
<div <div
className={cn( className="p-4 font-mono text-xs text-foreground/90 whitespace-pre-wrap break-all overflow-y-auto leading-relaxed select-text"
'p-4 font-mono text-xs text-foreground/90 whitespace-pre-wrap break-all overflow-y-auto leading-relaxed select-text', style={{ maxHeight }}
fill && 'flex-1 min-h-0',
)}
style={fill ? undefined : { maxHeight }}
> >
{content} {content}
</div> </div>
@@ -1,62 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useEffect, useState } from 'react';
import { describe, it, expect } from 'vitest';
import JsonFormatSection from '../JsonFormatSection';
import type { UseJsonToolsReturn } from '../../useJsonTools';
function Harness({ initial = '' }: { initial?: string }) {
const [input, setInput] = useState(initial);
const [debouncedInput, setDebouncedInput] = useState(initial);
useEffect(() => {
const t = setTimeout(() => setDebouncedInput(input), 250);
return () => clearTimeout(t);
}, [input]);
const tools = { input, setInput, debouncedInput } as unknown as UseJsonToolsReturn;
return <JsonFormatSection tools={tools} />;
}
describe('JsonFormatSection', () => {
it('渲染输入面板标题与缩进/键名排序工具栏', () => {
render(<Harness />);
expect(screen.getByText('JSON 输入')).toBeInTheDocument();
expect(screen.getByText('缩进')).toBeInTheDocument();
expect(screen.getByText('键名排序')).toBeInTheDocument();
});
it('输入无效 JSON 时不渲染结果面板与占位区', async () => {
render(<Harness initial="{ invalid json " />);
await waitFor(() => {
expect(screen.queryByText('格式化结果')).not.toBeInTheDocument();
});
expect(
screen.queryByText('请修正上方 JSON 的语法错误以开启实时流式格式化'),
).not.toBeInTheDocument();
expect(screen.queryByText('输入 JSON 后点击格式化')).not.toBeInTheDocument();
});
it('输入有效 JSON 后渲染格式化结果面板', async () => {
render(<Harness initial='{"a":1,"b":2}' />);
await waitFor(() => {
expect(screen.getByText('格式化结果')).toBeInTheDocument();
});
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
expect(screen.getByText(/"b": 2/)).toBeInTheDocument();
});
it('点击标题可折叠输入面板', async () => {
const user = userEvent.setup();
render(<Harness initial='{"a":1}' />);
await waitFor(() => {
expect(screen.getByText('格式化结果')).toBeInTheDocument();
});
await user.click(screen.getByText('JSON 输入'));
const input = screen.getByPlaceholderText('输入需要格式化的 JSON...');
await waitFor(() => {
expect(input.closest('.opacity-0')).toBeInTheDocument();
});
expect(screen.getByText('{"a":1}(点击展开)')).toBeInTheDocument();
});
});
+4 -4
View File
@@ -30,13 +30,13 @@ export default function Index() {
{pageMode === 'diff' ? ( {pageMode === 'diff' ? (
<DiffWorkspace tools={tools} /> <DiffWorkspace tools={tools} />
) : pageMode === 'format' ? ( ) : pageMode === 'format' ? (
<JsonFormatSection tools={tools} /> <JsonFormatSection />
) : pageMode === 'yaml' ? ( ) : pageMode === 'yaml' ? (
<JsonConvertSection tools={tools} mode="yaml" convertFunction={yamlConvert} /> <JsonConvertSection mode="yaml" convertFunction={yamlConvert} />
) : pageMode === 'toml' ? ( ) : pageMode === 'toml' ? (
<JsonConvertSection tools={tools} mode="toml" convertFunction={tomlConvert} /> <JsonConvertSection mode="toml" convertFunction={tomlConvert} />
) : ( ) : (
<JsonConvertSection tools={tools} mode="minify" convertFunction={minifyConvert} /> <JsonConvertSection mode="minify" convertFunction={minifyConvert} />
)} )}
</div> </div>
); );
+6 -30
View File
@@ -21,11 +21,6 @@ export interface UseJsonToolsReturn {
setRightInput: (val: string) => void; setRightInput: (val: string) => void;
leftError: string | null; leftError: string | null;
rightError: string | null; rightError: string | null;
/** format/yaml/toml/minify 共享的输入内容(已持久化) */
input: string;
setInput: (val: string) => void;
/** input 防抖后的快照,用于格式化/转换计算与持久化 */
debouncedInput: string;
viewMode: ViewMode; viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void; setViewMode: (mode: ViewMode) => void;
/** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */ /** 当前生效的差异结果:宽屏为实时计算,窄屏为手动 Compare 后的结果 */
@@ -53,7 +48,7 @@ export interface UseJsonToolsReturn {
minifyConvert: ConvertFunction; minifyConvert: ConvertFunction;
} }
export const buildPreview = (raw: string): string => { const buildPreview = (raw: string): string => {
const single = raw.replace(/\s+/g, ' ').trim(); const single = raw.replace(/\s+/g, ' ').trim();
const truncated = single.length > 80 ? `${single.slice(0, 80)}` : single; const truncated = single.length > 80 ? `${single.slice(0, 80)}` : single;
return truncated ? `${truncated}(点击展开)` : '(点击展开)'; return truncated ? `${truncated}(点击展开)` : '(点击展开)';
@@ -64,34 +59,18 @@ export function useJsonTools(): UseJsonToolsReturn {
const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode); const [pageMode, setPageMode] = useStorageState('jsonTools/pageMode', 'diff', isValidPageMode);
const [persistedLeft, setPersistedLeft] = useStorageState('jsonTools/diffLeft', ''); const [leftInput, setLeftInputState] = useState('');
const [persistedRight, setPersistedRight] = useStorageState('jsonTools/diffRight', ''); const [rightInput, setRightInputState] = useState('');
const [persistedInput, setPersistedInput] = useStorageState('jsonTools/input', ''); const [debouncedLeft, setDebouncedLeft] = useState('');
const [debouncedRight, setDebouncedRight] = useState('');
const [leftInput, setLeftInputState] = useState<string>(persistedLeft);
const [rightInput, setRightInputState] = useState<string>(persistedRight);
const [input, setInput] = useState<string>(persistedInput);
const [debouncedLeft, setDebouncedLeft] = useState<string>(persistedLeft);
const [debouncedRight, setDebouncedRight] = useState<string>(persistedRight);
const [debouncedInput, setDebouncedInput] = useState<string>(persistedInput);
useEffect(() => { useEffect(() => {
const handle = setTimeout(() => { const handle = setTimeout(() => {
setDebouncedLeft(leftInput); setDebouncedLeft(leftInput);
setDebouncedRight(rightInput); setDebouncedRight(rightInput);
setPersistedLeft(leftInput);
setPersistedRight(rightInput);
}, 250); }, 250);
return () => clearTimeout(handle); return () => clearTimeout(handle);
}, [leftInput, rightInput, setPersistedLeft, setPersistedRight]); }, [leftInput, rightInput]);
useEffect(() => {
const handle = setTimeout(() => {
setDebouncedInput(input);
setPersistedInput(input);
}, 250);
return () => clearTimeout(handle);
}, [input, setPersistedInput]);
const parseState = useMemo(() => { const parseState = useMemo(() => {
const invalidMsg = '无效的 JSON 格式'; const invalidMsg = '无效的 JSON 格式';
@@ -228,9 +207,6 @@ export function useJsonTools(): UseJsonToolsReturn {
setRightInput, setRightInput,
leftError, leftError,
rightError, rightError,
input,
setInput,
debouncedInput,
viewMode: activeViewMode, viewMode: activeViewMode,
setViewMode, setViewMode,
activeResult, activeResult,
-6
View File
@@ -89,12 +89,6 @@ export interface StorageSchema {
'qrCode/urlExpanded': boolean; 'qrCode/urlExpanded': boolean;
/** JSON 工具页面当前子模式 */ /** JSON 工具页面当前子模式 */
'jsonTools/pageMode': JsonToolsPageMode; 'jsonTools/pageMode': JsonToolsPageMode;
/** JSON 工具:format/yaml/toml/minify 共享的输入内容 */
'jsonTools/input': string;
/** JSON 工具:diff 模式左侧输入内容 */
'jsonTools/diffLeft': string;
/** JSON 工具:diff 模式右侧输入内容 */
'jsonTools/diffRight': string;
/** Base64 转换器页面当前子模式 */ /** Base64 转换器页面当前子模式 */
'base64Converter/pageMode': Base64ConverterPageMode; 'base64Converter/pageMode': Base64ConverterPageMode;
/** Base64 转换器「文件」子模式当前方向 */ /** Base64 转换器「文件」子模式当前方向 */
+8 -8
View File
@@ -7,8 +7,8 @@
| 文件 | 用途 | | 文件 | 用途 |
| ------------------------ | --------------------------------------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------------------------------------- |
| `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 | | `chromeStorage.ts` | Chrome Storage API 封装:类型安全的 `StorageUtils` 类,提供 `get/set/remove` 方法 |
| `syncSnapshot.ts` | 通用 `localStorage` 快照读取(`snapshot/{key}`),用于 Router 与 `useStorageState` 首屏防闪烁 | | `syncSnapshot.ts` | 通用 `localStorage` 快照读取(`snapshot/{key}`),用于 Router 与 `useStorageState` 首屏防闪烁 |
| `themeSnapshot.ts` | 主题专用快照读写与 `document.documentElement` class 切换 | | `themeSnapshot.ts` | 主题专用快照读写与 `document.documentElement` class 切换 |
| `restrictedUrls.ts` | 受限 URL 检测(`chrome://``about:` 等),供 Storage Cleaner 等模块复用 | | `restrictedUrls.ts` | 受限 URL 检测(`chrome://``about:` 等),供 Storage Cleaner 等模块复用 |
| `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 | | `chromeTabs.ts` | Chrome Tabs API 封装:获取活动标签页、获取域名、在新标签页打开扩展页面 |
| `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) | | `clipboard.ts` | 剪贴板操作:`copyTextToClipboard`(文本)、`copyImageToClipboard`(图片) |
@@ -42,12 +42,12 @@
写入经内部 `setAll()` 完成;`localStorage.setItem` 抛错(如配额超限)时返回 `false`,并 `console.error`**不会部分提交** 写入经内部 `setAll()` 完成;`localStorage.setItem` 抛错(如配额超限)时返回 `false`,并 `console.error`**不会部分提交**
| 方法 | 写入失败返回值 | 常见失败原因 | | 方法 | 写入失败返回值 | 常见失败原因 |
| ---- | -------------- | ------------ | | -------------- | ---------------------------- | ------------------------------------------------------ |
| `save()` | `null` | 达上限、规则不存在(更新时)、`setItem` 异常 | | `save()` | `null` | 达上限、规则不存在(更新时)、`setItem` 异常 |
| `update()` | `null` | 规则不存在、`setItem` 异常 | | `update()` | `null` | 规则不存在、`setItem` 异常 |
| `deleteRule()` | `false` | 规则不存在、`setItem` 异常 | | `deleteRule()` | `false` | 规则不存在、`setItem` 异常 |
| `duplicate()` | 仍可能返回对象但数据未持久化 | 见源码:`duplicate` 未检查 `setAll` 返回值(已知缺口) | | `duplicate()` | 仍可能返回对象但数据未持久化 | 见源码:`duplicate` 未检查 `setAll` 返回值(已知缺口) |
调用方应检查返回值后再展示成功 Toast。页面层参考 `FieldList.tsx``save`/`update` 返回非空才提示「规则已保存/已更新」。 调用方应检查返回值后再展示成功 Toast。页面层参考 `FieldList.tsx``save`/`update` 返回非空才提示「规则已保存/已更新」。