feat: 添加 EmptyPlaceholder 组件并更新相关页面

- 新增 EmptyPlaceholder 组件,用于统一处理空状态提示,提升用户体验。
- 更新多个页面(JsonTools、Jwt、QrCode、Timestamp)以使用 EmptyPlaceholder 组件,简化空状态的实现。
- 更新 README.md,添加 EmptyPlaceholder 组件的说明。
This commit is contained in:
雨霖铃
2026-06-19 10:16:59 +08:00
parent 3ab7194501
commit ca0143f3bd
11 changed files with 115 additions and 42 deletions
+30
View File
@@ -0,0 +1,30 @@
import React from 'react';
import { cn } from '@/lib/utils';
export interface EmptyPlaceholderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
messageClassName?: string;
}
const CONTAINER_CLASSES =
'rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center select-none p-8 min-h-[120px]';
const MESSAGE_CLASSES =
'text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed';
export default function EmptyPlaceholder({
children,
className,
messageClassName,
...props
}: EmptyPlaceholderProps) {
return (
<div className={cn(CONTAINER_CLASSES, className)} {...props}>
{typeof children === 'string' || typeof children === 'number' ? (
<p className={cn(MESSAGE_CLASSES, messageClassName)}>{children}</p>
) : (
children
)}
</div>
);
}
+1
View File
@@ -8,6 +8,7 @@
| ----------------------- | ------------------------------------------------------------------------------ | | ----------------------- | ------------------------------------------------------------------------------ |
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 | | `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 | | `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 |
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 | | `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard``toast` 反馈 | | `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard``toast` 反馈 |
| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 | | `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 |
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
describe('EmptyPlaceholder 组件', () => {
it('应渲染字符串提示文本', () => {
render(<EmptyPlaceholder>{'请输入内容'}</EmptyPlaceholder>);
expect(screen.getByText('请输入内容')).toBeInTheDocument();
});
it('应应用规范空状态容器样式', () => {
const { container } = render(<EmptyPlaceholder>{'提示'}</EmptyPlaceholder>);
const placeholder = container.firstChild;
expect(placeholder).toHaveClass(
'rounded-xl',
'bg-muted/30',
'border-dashed',
'border-border/80',
'min-h-[120px]',
);
});
it('应支持 className 自定义容器样式', () => {
const { container } = render(
<EmptyPlaceholder className="flex-1 min-h-[320px]">{'提示'}</EmptyPlaceholder>,
);
expect(container.firstChild).toHaveClass('flex-1', 'min-h-[320px]');
});
it('应支持 messageClassName 自定义文本样式', () => {
render(<EmptyPlaceholder messageClassName="text-sm max-w-none">{'提示'}</EmptyPlaceholder>);
expect(screen.getByText('提示')).toHaveClass('text-sm', 'max-w-none');
});
it('应支持 ReactNode 类型的 children', () => {
render(
<EmptyPlaceholder>
<span data-testid="custom-content"></span>
</EmptyPlaceholder>,
);
expect(screen.getByTestId('custom-content')).toBeInTheDocument();
});
});
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { validateJson } from '@/utils/jsonFormatter'; import { validateJson } from '@/utils/jsonFormatter';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -132,11 +133,9 @@ export default function JsonConvertSection({
</div> </div>
</div> </div>
) : ( ) : (
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none"> <EmptyPlaceholder>
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint} {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
</p> </EmptyPlaceholder>
</div>
)} )}
</div> </div>
); );
@@ -7,6 +7,7 @@ import {
} from '@/utils/jsonFormatter'; } from '@/utils/jsonFormatter';
import { formatBytes } from '@/utils/format'; import { formatBytes } from '@/utils/format';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
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 { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -148,12 +149,9 @@ export default function JsonFormatSection() {
</div> </div>
</div> </div>
) : ( ) : (
/* 空状态指示引导区 */ <EmptyPlaceholder>
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[120px] select-none">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'} {error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
</p> </EmptyPlaceholder>
</div>
)} )}
</div> </div>
); );
+3 -4
View File
@@ -4,6 +4,7 @@ import DiffNavigator from './components/DiffNavigator';
import JsonFormatSection from './components/JsonFormatSection'; import JsonFormatSection from './components/JsonFormatSection';
import JsonConvertSection from './components/JsonConvertSection'; import JsonConvertSection from './components/JsonConvertSection';
import SwitchButtonGroup from '@/components/SwitchButtonGroup'; import SwitchButtonGroup from '@/components/SwitchButtonGroup';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import { useJsonTools } from './useJsonTools'; import { useJsonTools } from './useJsonTools';
import type { JsonToolsPageMode } from '@/types/storage'; import type { JsonToolsPageMode } from '@/types/storage';
import type { ViewMode } from './types'; import type { ViewMode } from './types';
@@ -95,13 +96,11 @@ export default function Index() {
<DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} /> <DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} />
</div> </div>
) : ( ) : (
<div className="p-8 rounded-xl bg-muted/30 border border-dashed border-border/80 text-center flex flex-col items-center justify-center min-h-[140px]"> <EmptyPlaceholder className="min-h-[140px]" messageClassName="max-w-[260px]">
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed">
{leftError || rightError {leftError || rightError
? '请修正上方 JSON 的语法错误以开启实时流式比对' ? '请修正上方 JSON 的语法错误以开启实时流式比对'
: '输入两侧 JSON 后点击比较'} : '输入两侧 JSON 后点击比较'}
</p> </EmptyPlaceholder>
</div>
)} )}
</div> </div>
) : pageMode === 'format' ? ( ) : pageMode === 'format' ? (
+2 -3
View File
@@ -1,5 +1,6 @@
import TextInputArea from '@/components/TextInputArea'; import TextInputArea from '@/components/TextInputArea';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import JwtSection from './JwtSection'; import JwtSection from './JwtSection';
import { useJwt } from './useJwt'; import { useJwt } from './useJwt';
@@ -57,9 +58,7 @@ export default function Index() {
)} )}
{result?.error && ( {result?.error && (
<div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center"> <EmptyPlaceholder className="p-6 min-h-0">{'无效的 JSON 格式'}</EmptyPlaceholder>
<p className="text-xs font-semibold text-muted-foreground/80">{'无效的 JSON 格式'}</p>
</div>
)} )}
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { Copy, Download } from 'lucide-react'; import { Copy, Download } from 'lucide-react';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -26,17 +27,13 @@ const QrCodePreview = ({
// 空状态下的虚线骨架屏 // 空状态下的虚线骨架屏
if (!qrCodeDataUrl) { if (!qrCodeDataUrl) {
return ( return (
<div <EmptyPlaceholder
className={cn( className={cn('min-h-[200px] p-4', className)}
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40', messageClassName="text-sm text-muted-foreground max-w-none"
className,
)}
{...props} {...props}
> >
<p className="text-sm text-muted-foreground text-center">
{placeholderText || '二维码将显示在这里'} {placeholderText || '二维码将显示在这里'}
</p> </EmptyPlaceholder>
</div>
); );
} }
+6 -3
View File
@@ -5,6 +5,7 @@ import { CopyButton } from '@/components/CopyButton';
import type { UnitType } from '../constants'; import type { UnitType } from '../constants';
import { msToUnit } from '../constants'; import { msToUnit } from '../constants';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> { interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
unit: UnitType; unit: UnitType;
@@ -40,17 +41,19 @@ export default function LiveClock({ unit, onUseNow, className, ...props }: LiveC
{text} {text}
</span> </span>
<button <Button
type="button" type="button"
onClick={() => { onClick={() => {
onUseNow(rawTime); onUseNow(rawTime);
toast.success('已使用当前时间戳'); toast.success('已使用当前时间戳');
}} }}
title={'填充到下方'} title={'填充到下方'}
className="flex h-7 w-7 items-center justify-center rounded-md border border-input bg-background text-muted-foreground shadow-sm hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
> >
<Clock className="w-3.5 h-3.5" /> <Clock className="w-3.5 h-3.5" />
</button> </Button>
<CopyButton text={text} tooltip={'复制时间戳'} className="h-7 w-7 rounded-md border" /> <CopyButton text={text} tooltip={'复制时间戳'} className="h-7 w-7 rounded-md border" />
</div> </div>
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { CopyButton } from '@/components/CopyButton'; import { CopyButton } from '@/components/CopyButton';
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> { interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -16,15 +17,13 @@ export default function ResultView({
if (!result) { if (!result) {
if (!showEmptyPlaceholder) return null; if (!showEmptyPlaceholder) return null;
return ( return (
<div <EmptyPlaceholder
className={cn( className={cn('flex-1 min-h-[320px]', className)}
'flex-1 flex items-center justify-center text-sm font-medium border border-dashed border-border/60 rounded-xl py-12 px-4 text-center text-muted-foreground bg-muted/20 min-h-[320px]', messageClassName="text-sm font-medium text-muted-foreground max-w-none"
className,
)}
{...props} {...props}
> >
{'请输入并点击转换'} {'请输入并点击转换'}
</div> </EmptyPlaceholder>
); );
} }
@@ -41,7 +40,9 @@ export default function ResultView({
<CopyButton <CopyButton
text={result} text={result}
tooltip={'复制结果'} tooltip={'复制结果'}
className="h-8 w-8 rounded-md shrink-0 border" variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
/> />
</div> </div>
</div> </div>
-2
View File
@@ -11,12 +11,10 @@ export type ZoneType = (typeof ZONES)[number];
const DIVISORS: Record<UnitType, number> = { ms: 1, s: 1000 }; const DIVISORS: Record<UnitType, number> = { ms: 1, s: 1000 };
/** Convert milliseconds to display value based on unit. */
export function msToUnit(ms: number, unit: UnitType): number { export function msToUnit(ms: number, unit: UnitType): number {
return Math.floor(ms / DIVISORS[unit]); return Math.floor(ms / DIVISORS[unit]);
} }
/** Build a dayjs object from a numeric timestamp according to unit. */
export function dayjsFromTimestamp(ts: number, unit: UnitType): Dayjs { export function dayjsFromTimestamp(ts: number, unit: UnitType): Dayjs {
return unit === 'ms' ? dayjs(ts) : dayjs.unix(ts); return unit === 'ms' ? dayjs(ts) : dayjs.unix(ts);
} }