feat: 添加 EmptyPlaceholder 组件并更新相关页面
- 新增 EmptyPlaceholder 组件,用于统一处理空状态提示,提升用户体验。 - 更新多个页面(JsonTools、Jwt、QrCode、Timestamp)以使用 EmptyPlaceholder 组件,简化空状态的实现。 - 更新 README.md,添加 EmptyPlaceholder 组件的说明。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
| ----------------------- | ------------------------------------------------------------------------------ |
|
||||
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
||||
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
||||
| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 |
|
||||
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
|
||||
| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` 和 `toast` 反馈 |
|
||||
| `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 { formatBytes } from '@/utils/format';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { validateJson } from '@/utils/jsonFormatter';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -132,11 +133,9 @@ export default function JsonConvertSection({
|
||||
</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">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
|
||||
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyPlaceholder>
|
||||
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : labels.emptyHint}
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@/utils/jsonFormatter';
|
||||
import { formatBytes } from '@/utils/format';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -148,12 +149,9 @@ export default function JsonFormatSection() {
|
||||
</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">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[240px] leading-relaxed">
|
||||
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyPlaceholder>
|
||||
{error ? '请修正上方 JSON 的语法错误以开启实时流式格式化' : '输入 JSON 后点击格式化'}
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import DiffNavigator from './components/DiffNavigator';
|
||||
import JsonFormatSection from './components/JsonFormatSection';
|
||||
import JsonConvertSection from './components/JsonConvertSection';
|
||||
import SwitchButtonGroup from '@/components/SwitchButtonGroup';
|
||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||
import { useJsonTools } from './useJsonTools';
|
||||
import type { JsonToolsPageMode } from '@/types/storage';
|
||||
import type { ViewMode } from './types';
|
||||
@@ -95,13 +96,11 @@ export default function Index() {
|
||||
<DiffResult result={diffResult} viewMode={viewMode} activePath={activePath} />
|
||||
</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]">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80 tracking-wide max-w-[260px] leading-relaxed">
|
||||
{leftError || rightError
|
||||
? '请修正上方 JSON 的语法错误以开启实时流式比对'
|
||||
: '输入两侧 JSON 后点击比较'}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyPlaceholder className="min-h-[140px]" messageClassName="max-w-[260px]">
|
||||
{leftError || rightError
|
||||
? '请修正上方 JSON 的语法错误以开启实时流式比对'
|
||||
: '输入两侧 JSON 后点击比较'}
|
||||
</EmptyPlaceholder>
|
||||
)}
|
||||
</div>
|
||||
) : pageMode === 'format' ? (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import TextInputArea from '@/components/TextInputArea';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||
import JwtSection from './JwtSection';
|
||||
import { useJwt } from './useJwt';
|
||||
|
||||
@@ -57,9 +58,7 @@ export default function Index() {
|
||||
)}
|
||||
|
||||
{result?.error && (
|
||||
<div className="p-6 rounded-xl bg-muted/30 border border-dashed border-border text-center">
|
||||
<p className="text-xs font-semibold text-muted-foreground/80">{'无效的 JSON 格式'}</p>
|
||||
</div>
|
||||
<EmptyPlaceholder className="p-6 min-h-0">{'无效的 JSON 格式'}</EmptyPlaceholder>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Copy, Download } from 'lucide-react';
|
||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
@@ -26,17 +27,13 @@ const QrCodePreview = ({
|
||||
// 空状态下的虚线骨架屏
|
||||
if (!qrCodeDataUrl) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col justify-center items-center min-h-[200px] border border-dashed border-input rounded-xl p-4 bg-muted/40',
|
||||
className,
|
||||
)}
|
||||
<EmptyPlaceholder
|
||||
className={cn('min-h-[200px] p-4', className)}
|
||||
messageClassName="text-sm text-muted-foreground max-w-none"
|
||||
{...props}
|
||||
>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{placeholderText || '二维码将显示在这里'}
|
||||
</p>
|
||||
</div>
|
||||
{placeholderText || '二维码将显示在这里'}
|
||||
</EmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CopyButton } from '@/components/CopyButton';
|
||||
import type { UnitType } from '../constants';
|
||||
import { msToUnit } from '../constants';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
unit: UnitType;
|
||||
@@ -40,17 +41,19 @@ export default function LiveClock({ unit, onUseNow, className, ...props }: LiveC
|
||||
{text}
|
||||
</span>
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onUseNow(rawTime);
|
||||
toast.success('已使用当前时间戳');
|
||||
}}
|
||||
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" />
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<CopyButton text={text} tooltip={'复制时间戳'} className="h-7 w-7 rounded-md border" />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { CopyButton } from '@/components/CopyButton';
|
||||
import EmptyPlaceholder from '@/components/EmptyPlaceholder';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ResultViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@@ -16,15 +17,13 @@ export default function ResultView({
|
||||
if (!result) {
|
||||
if (!showEmptyPlaceholder) return null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'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]',
|
||||
className,
|
||||
)}
|
||||
<EmptyPlaceholder
|
||||
className={cn('flex-1 min-h-[320px]', className)}
|
||||
messageClassName="text-sm font-medium text-muted-foreground max-w-none"
|
||||
{...props}
|
||||
>
|
||||
{'请输入并点击转换'}
|
||||
</div>
|
||||
</EmptyPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +40,9 @@ export default function ResultView({
|
||||
<CopyButton
|
||||
text={result}
|
||||
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>
|
||||
|
||||
@@ -11,12 +11,10 @@ export type ZoneType = (typeof ZONES)[number];
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return unit === 'ms' ? dayjs(ts) : dayjs.unix(ts);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user