Merge branch 'develop'
This commit is contained in:
@@ -57,11 +57,7 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||
className={cn(className, copied && 'text-emerald-500')}
|
||||
{...props}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-[1.2em] w-[1.2em]" />
|
||||
) : (
|
||||
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||
)}
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,9 +30,7 @@ export function ErrorFallback({
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center',
|
||||
isApp
|
||||
? 'mt-16 mx-auto max-w-md'
|
||||
: 'flex-1 p-6 min-h-[300px] animate-in fade-in zoom-in-95 duration-200',
|
||||
isApp ? 'mt-16 mx-auto max-w-md' : 'flex-1 p-6 min-h-[300px] fade-in-zoom-95',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -57,7 +55,11 @@ export function ErrorFallback({
|
||||
<h3 className="text-base font-semibold text-foreground mb-1.5">{title}</h3>
|
||||
)}
|
||||
|
||||
<p className={cn('text-muted-foreground', isApp ? 'text-sm mb-6' : 'text-xs mb-5')}>
|
||||
<p
|
||||
className={
|
||||
isApp ? 'text-sm text-muted-foreground mb-6' : 'text-xs text-muted-foreground mb-5'
|
||||
}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
|
||||
@@ -83,7 +85,7 @@ export function ErrorFallback({
|
||||
variant="destructive"
|
||||
size={isApp ? 'default' : 'sm'}
|
||||
onClick={onAction}
|
||||
className={cn(isApp ? 'rounded-lg font-bold shadow-sm' : 'font-medium shadow-sm')}
|
||||
className={isApp ? 'rounded-lg font-bold shadow-sm' : 'font-medium shadow-sm'}
|
||||
>
|
||||
<RefreshCw className={isApp ? 'mr-2 h-4 w-4' : 'mr-1.5 h-3.5 w-3.5'} />
|
||||
{actionLabel}
|
||||
|
||||
@@ -46,7 +46,7 @@ function ToolPageSkeleton() {
|
||||
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
<div className="w-20 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
<div className="flex-1" />
|
||||
<div className="w-22 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
<div className="w-24 h-9 bg-muted rounded-lg animate-pulse" />
|
||||
</div>
|
||||
|
||||
{/* 结果区域 */}
|
||||
|
||||
+14
-14
@@ -4,20 +4,20 @@
|
||||
|
||||
## 组件列表
|
||||
|
||||
| 组件 | 用途 |
|
||||
| ----------------------- | ------------------------------------------------------------------------------ |
|
||||
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
||||
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
||||
| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 |
|
||||
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
|
||||
| `CopyButton.tsx` | 一键复制按钮,支持复制成功状态动画,封装 `copyTextToClipboard` 和 `toast` 反馈 |
|
||||
| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 |
|
||||
| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 |
|
||||
| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 |
|
||||
| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 |
|
||||
| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 |
|
||||
| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 |
|
||||
| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard` 和 `tool` 两种变体,用于 Suspense fallback |
|
||||
| 组件 | 用途 |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `RouterContainer.tsx` | 路由容器,根据当前路由动态渲染对应页面组件,集成错误边界和骨架屏 |
|
||||
| `SwitchButtonGroup.tsx` | 通用切换按钮组,支持 `small/medium/large` 三种尺寸,用于页面子模式切换 |
|
||||
| `EmptyPlaceholder.tsx` | 虚线边框空状态占位,统一工具页「暂无结果」提示样式 |
|
||||
| `TextInputArea.tsx` | 增强文本输入区域,支持校验规则、工具栏操作、字符计数、清空 |
|
||||
| `CopyButton.tsx` | 一键复制按钮:复制成功后 1.5s 内切换为 Check 图标并应用 `text-emerald-500`;空内容/失败时 `toast` 提示 |
|
||||
| `ImageUploader.tsx` | 图片上传组件,支持拖拽上传、文件选择和预览 |
|
||||
| `QrCodePreview.tsx` | 二维码预览组件,展示生成的二维码图片,提供复制和下载操作 |
|
||||
| `DecodeResultPaper.tsx` | Base64 解码结果展示面板,显示 MIME 类型、文件大小、文件名输入和下载按钮 |
|
||||
| `GlobalSnackbar.tsx` | 全局消息提示组件 + Context Provider,支持受控/Hook/全局单例三种使用方式 |
|
||||
| `ErrorBoundary.tsx` | 全局错误边界(类组件),捕获子组件树 JS 错误并展示友好错误页面 |
|
||||
| `PageErrorBoundary.tsx` | 页面级错误边界,适配 shadcn 暗黑模式,支持 `resetKey` 自动恢复 |
|
||||
| `PageSkeleton.tsx` | 页面骨架屏,提供 `dashboard` 和 `tool` 两种变体,用于 Suspense fallback |
|
||||
|
||||
## 使用约定
|
||||
|
||||
|
||||
@@ -1,54 +1,73 @@
|
||||
import { FEATURES, getEntryPointType } from '@/config/features';
|
||||
import { getFeatureByKey } from '@/config/features';
|
||||
import { loadPage } from '@/config/pageLoaders/index';
|
||||
import { useRouter } from '@/providers/RouterProvider';
|
||||
import { Suspense } from 'react';
|
||||
import type { PageType } from '@/types/storage';
|
||||
import { type ComponentType, useEffect, useState } from 'react';
|
||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||
import PageSkeleton from '@/components/PageSkeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
const entryPointType = getEntryPointType();
|
||||
function LoadedPage({ pageKey }: { pageKey: PageType }) {
|
||||
const [Page, setPage] = useState<ComponentType | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
loadPage(pageKey)
|
||||
.then((mod) => {
|
||||
if (!cancelled) {
|
||||
setPage(() => mod.default);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Router Page Load Error]', err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pageKey]);
|
||||
|
||||
if (!Page) {
|
||||
return <PageSkeleton variant={pageKey === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||
}
|
||||
|
||||
return <Page />;
|
||||
}
|
||||
|
||||
export default function RouterContainer() {
|
||||
const { currentPage, isLoaded } = useRouter();
|
||||
const { currentPage } = useRouter();
|
||||
|
||||
const animationClass =
|
||||
currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||
|
||||
if (!isLoaded) {
|
||||
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||
}
|
||||
|
||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||
const MatchedComponent = currentFeature?.components?.[entryPointType];
|
||||
const currentFeature = getFeatureByKey(currentPage);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={currentPage}
|
||||
className={cn(
|
||||
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||
'scrollbar-gutter-stable motion-reduce:transition-none',
|
||||
'motion-reduce:transition-none',
|
||||
animationClass,
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||
>
|
||||
<PageErrorBoundary resetKey={currentPage}>
|
||||
{MatchedComponent ? (
|
||||
<MatchedComponent />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center animate-in fade-in duration-300">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">页面未找到</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||
{`该功能在当前运行环境(${entryPointType})下不可用或已被移除。`}
|
||||
</p>
|
||||
<PageErrorBoundary resetKey={currentPage}>
|
||||
{currentFeature ? (
|
||||
<LoadedPage key={currentPage} pageKey={currentPage} />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center fade-in-300">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive mb-4">
|
||||
<AlertTriangle className="h-6 w-6" />
|
||||
</div>
|
||||
)}
|
||||
</PageErrorBoundary>
|
||||
</Suspense>
|
||||
<h3 className="text-sm font-semibold text-foreground">页面未找到</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 max-w-[240px]">
|
||||
该功能不存在或已被移除。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PageErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,7 @@ const SIZE_CLASSES = {
|
||||
large: 'text-base h-11 px-4 py-2 rounded-lg',
|
||||
} as const;
|
||||
|
||||
const SELECTED_CLASSES =
|
||||
'bg-background text-foreground shadow-sm font-semibold animate-in fade-in-50 zoom-in-95 duration-150';
|
||||
const SELECTED_CLASSES = 'bg-background text-foreground shadow-sm font-semibold fade-in-zoom-95';
|
||||
const UNSELECTED_CLASSES = 'hover:bg-background/50 hover:text-foreground/80';
|
||||
|
||||
export default function SwitchButtonGroup<T extends string | number = string>({
|
||||
|
||||
@@ -267,7 +267,13 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
|
||||
<div className="flex items-center gap-1.5 ml-auto shrink-0">
|
||||
{allowCopy && value && (
|
||||
<CopyButton text={value} tooltip="复制内容" size="sm" className="h-7 w-7 p-1" />
|
||||
<CopyButton
|
||||
text={value}
|
||||
tooltip="复制内容"
|
||||
variant="ghost"
|
||||
size="iconSm"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
/>
|
||||
)}
|
||||
{showClear && value && !disabled && !readOnly && (
|
||||
<Button
|
||||
@@ -275,8 +281,8 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
onClick={handleClear}
|
||||
aria-label="清空"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
size="iconSm"
|
||||
className="text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -287,7 +293,7 @@ const TextInputArea = forwardRef<HTMLTextAreaElement, TextInputAreaProps>((props
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<p className="text-xs font-medium text-destructive px-0.5 animate-in fade-in slide-in-from-top-1 duration-150">
|
||||
<p className="text-xs font-medium text-destructive px-0.5 fade-in-slide-top-1">
|
||||
{displayError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -9,12 +9,13 @@ const mockRouterValue = {
|
||||
currentPage: 'dashboard' as PageType,
|
||||
visiblePages: ['dashboard', 'timestamp'] as PageType[],
|
||||
pageOrder: ['timestamp'] as PageType[],
|
||||
isLoaded: true,
|
||||
navigateTo: vi.fn(),
|
||||
syncNavigation: vi.fn(),
|
||||
goHome: vi.fn(),
|
||||
setVisiblePages: vi.fn(),
|
||||
setPageOrder: vi.fn(),
|
||||
recentlyUsedTools: [] as PageType[],
|
||||
isLoaded: true,
|
||||
};
|
||||
|
||||
vi.mock('@/providers/RouterProvider', () => ({
|
||||
@@ -32,16 +33,7 @@ describe('RouterContainer 组件', () => {
|
||||
};
|
||||
|
||||
describe('渲染测试', () => {
|
||||
it('isLoaded 为 false 时应渲染骨架屏', () => {
|
||||
mockRouterValue.isLoaded = false;
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
// 骨架屏使用 animate-pulse 类
|
||||
const skeletons = container.querySelectorAll('.animate-pulse');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('isLoaded 为 true 时应渲染页面内容', () => {
|
||||
mockRouterValue.isLoaded = true;
|
||||
it('mount 后应直接渲染页面结构(不等待 storage 加载)', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
expect(container.querySelector('.page-transition-dashboard')).toBeInTheDocument();
|
||||
@@ -77,8 +69,7 @@ describe('RouterContainer 组件', () => {
|
||||
});
|
||||
|
||||
describe('页面级错误隔离', () => {
|
||||
it('PageErrorBoundary 应包裹在 Suspense 内层', () => {
|
||||
mockRouterValue.isLoaded = true;
|
||||
it('PageErrorBoundary 应包裹页面内容', () => {
|
||||
mockRouterValue.currentPage = 'dashboard';
|
||||
const { container } = renderWithProvider(<RouterContainer />);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ const buttonVariants = cva(
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
iconSm: 'h-7 w-7',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -16,7 +16,7 @@ const Checkbox = React.forwardRef<
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className={cn('grid place-content-center text-current')}>
|
||||
<CheckboxPrimitive.Indicator className="grid place-content-center text-current">
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
|
||||
@@ -18,7 +18,7 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'fixed inset-0 z-50 bg-black/80 dark:bg-black/60 transition-opacity duration-200 data-[state=closed]:opacity-0 data-[state=open]:opacity-100',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -41,7 +41,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-white dark:bg-gray-900 p-6 shadow-lg transition-all duration-200 data-[state=closed]:scale-95 data-[state=open]:scale-100 data-[state=closed]:opacity-0 data-[state=open]:opacity-100 sm:rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -65,7 +65,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md transition-all duration-200 data-[state=closed]:scale-95 data-[state=open]:scale-100 data-[state=closed]:opacity-0 data-[state=open]:opacity-100',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
|
||||
Reference in New Issue
Block a user