refactor: 重构 CopyButton 与 RouterContainer 组件,移除 GlobalSnackbar 并统一使用 sonner toast 和 cn 工具函数
This commit is contained in:
+48
-60
@@ -1,43 +1,25 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Copy, Check } from 'lucide-react';
|
import { Check, Copy } from 'lucide-react';
|
||||||
import { copyTextToClipboard } from '@/utils/clipboard';
|
import { copyTextToClipboard } from '@/utils/clipboard';
|
||||||
import type { SnackbarOptions } from '@/components/GlobalSnackbar';
|
import { cn } from '@/lib/utils'; // 1. 必须使用 cn 工具函数
|
||||||
|
import { toast } from 'sonner'; // 2. 推荐使用 shadcn 默认的全局 toast
|
||||||
|
|
||||||
/**
|
// 3. 继承原生按钮属性,允许外部自由扩展 className、variant 等
|
||||||
* 复制按钮组件属性
|
interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
* @param text 要复制的文本
|
|
||||||
* @param tooltip 提示信息
|
|
||||||
* @param size 按钮大小
|
|
||||||
* @param color 按钮颜色
|
|
||||||
* @param style 自定义样式
|
|
||||||
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
|
||||||
*/
|
|
||||||
interface CopyButtonProps {
|
|
||||||
text: string;
|
text: string;
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
size?: 'small' | 'medium' | 'large';
|
size?: 'small' | 'medium' | 'large';
|
||||||
color?: 'primary' | 'secondary' | 'success' | 'error' | 'info' | 'warning' | string;
|
// 移除复杂的自定义颜色变体,交由 Tailwind 类名或 shadcn 的 variant 解决
|
||||||
style?: React.CSSProperties;
|
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
|
||||||
showMessage?: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 复制按钮组件
|
|
||||||
* @param text 要复制的文本
|
|
||||||
* @param tooltip 提示信息
|
|
||||||
* @param size 按钮大小
|
|
||||||
* @param color 按钮颜色
|
|
||||||
* @param style 自定义样式
|
|
||||||
* @param showMessage 消息提示函数,用于显示复制成功或失败的消息
|
|
||||||
* @returns 复制按钮组件
|
|
||||||
*/
|
|
||||||
export const CopyButton: React.FC<CopyButtonProps> = ({
|
export const CopyButton: React.FC<CopyButtonProps> = ({
|
||||||
text,
|
text,
|
||||||
tooltip = '复制',
|
tooltip = '复制',
|
||||||
size = 'small',
|
size = 'small',
|
||||||
color = 'primary',
|
variant = 'ghost',
|
||||||
style,
|
className,
|
||||||
showMessage,
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -48,55 +30,61 @@ export const CopyButton: React.FC<CopyButtonProps> = ({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCopy = async () => {
|
const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (text) {
|
e.stopPropagation(); // 基础组件防冒泡,避免触发父级点击事件
|
||||||
const success = await copyTextToClipboard(text);
|
|
||||||
if (success) {
|
if (!text) {
|
||||||
showMessage?.('复制成功', { severity: 'success' });
|
toast.error('无内容可复制');
|
||||||
setCopied(true);
|
return;
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
}
|
||||||
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
|
||||||
} else {
|
const success = await copyTextToClipboard(text);
|
||||||
showMessage?.('复制失败', { severity: 'error' });
|
if (success) {
|
||||||
}
|
toast.success('复制成功');
|
||||||
|
setCopied(true);
|
||||||
|
if (timerRef.current) clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => setCopied(false), 1500);
|
||||||
} else {
|
} else {
|
||||||
showMessage?.('无内容可复制', { severity: 'error' });
|
toast.error('复制失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 4. 将控制尺寸的类名标准化
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
small: 'h-8 w-8',
|
small: 'h-8 w-8 text-xs',
|
||||||
medium: 'h-10 w-10',
|
medium: 'h-10 w-10 text-sm',
|
||||||
large: 'h-12 w-12',
|
large: 'h-12 w-12 text-base',
|
||||||
};
|
};
|
||||||
|
|
||||||
const iconSize = size === 'small' ? 14 : size === 'medium' ? 16 : 18;
|
// 5. 映射 shadcn 的底层通用 Variant 类名
|
||||||
|
const variantClasses = {
|
||||||
const colorClasses: Record<string, string> = {
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
primary: 'text-primary hover:bg-primary/10',
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
secondary: 'text-foreground hover:bg-muted',
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
success: 'text-green-600 hover:bg-green-500/10',
|
outline:
|
||||||
error: 'text-red-600 hover:bg-red-500/10',
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
info: 'text-primary hover:bg-primary/10',
|
|
||||||
warning: 'text-amber-600 hover:bg-amber-50',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorClass = colorClasses[color] || `text-[${color}] hover:bg-muted`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
title={tooltip}
|
title={tooltip}
|
||||||
style={style}
|
// 6. 使用 cn() 合并类名,并完美支持暗黑模式的语义化变量 (destructive/muted等)
|
||||||
className={`${sizeClasses[size]} rounded-md flex items-center justify-center transition-all ${
|
className={cn(
|
||||||
copied ? 'text-green-600 bg-green-50' : colorClass
|
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||||
} bg-background shadow-sm hover:shadow-md`}
|
sizeClasses[size],
|
||||||
|
copied
|
||||||
|
? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' // 兼顾暗黑模式的成功色
|
||||||
|
: variantClasses[variant],
|
||||||
|
className, // 允许外部直接传入 text-red-500 等覆盖样式
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check style={{ width: iconSize, height: iconSize }} />
|
<Check className="h-[1.2em] w-[1.2em] animate-in fade-in zoom-in-75 duration-200" />
|
||||||
) : (
|
) : (
|
||||||
<Copy style={{ width: iconSize, height: iconSize }} />
|
<Copy className="h-[1.2em] w-[1.2em]" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { useRouter } from '@/providers/RouterProvider';
|
|||||||
import { Suspense, useMemo } from 'react';
|
import { Suspense, useMemo } from 'react';
|
||||||
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
import PageErrorBoundary from '@/components/PageErrorBoundary';
|
||||||
import PageSkeleton from '@/components/PageSkeleton';
|
import PageSkeleton from '@/components/PageSkeleton';
|
||||||
|
import { cn } from '@/lib/utils'; // 1. 引入标准的 shadcn 工具函数
|
||||||
|
import { AlertTriangle } from 'lucide-react'; // 用于标准的 404 异常展示
|
||||||
|
|
||||||
export default function RouterContainer() {
|
export default function RouterContainer() {
|
||||||
const { currentPage, isLoaded } = useRouter();
|
const { currentPage, isLoaded } = useRouter();
|
||||||
|
|
||||||
|
// 2. 稳定的动态动画类名映射
|
||||||
const animationClass = useMemo(() => {
|
const animationClass = useMemo(() => {
|
||||||
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
return currentPage === 'dashboard' ? 'page-transition-dashboard' : 'page-transition-enter';
|
||||||
}, [currentPage]);
|
}, [currentPage]);
|
||||||
@@ -15,22 +18,47 @@ export default function RouterContainer() {
|
|||||||
return getEntryPointType();
|
return getEntryPointType();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 骨架屏加载状态守卫
|
||||||
if (!isLoaded) {
|
if (!isLoaded) {
|
||||||
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
return <PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 严格的路由查找与类型安全的组件分发
|
||||||
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
const currentFeature = FEATURES.find((f) => f.key === currentPage);
|
||||||
const Component = currentFeature ? currentFeature.components[entryPointType] : null;
|
const MatchedComponent = currentFeature?.components?.[entryPointType];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={currentPage} // Trigger animation on navigation
|
key={currentPage} // 保持原有通过重新挂载触发动画的精简特性
|
||||||
className={`${animationClass} flex-1 overflow-y-auto overflow-x-hidden scrollbar-gutter-stable flex flex-col`}
|
className={cn(
|
||||||
|
'flex-1 flex flex-col overflow-x-hidden overflow-y-auto',
|
||||||
|
'scrollbar-gutter-stable motion-reduce:transition-none', // 当系统开启“减弱动态效果”时,自动优雅降级,防止眩晕
|
||||||
|
animationClass,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
fallback={<PageSkeleton variant={currentPage === 'dashboard' ? 'dashboard' : 'tool'} />}
|
||||||
>
|
>
|
||||||
<PageErrorBoundary resetKey={currentPage}>{Component && <Component />}</PageErrorBoundary>
|
<PageErrorBoundary resetKey={currentPage}>
|
||||||
|
{/*
|
||||||
|
4. 路由防御拦截:
|
||||||
|
如果组件存在则正常流式渲染,如果由于版本更迭或非法路径导致找不到对应组件,
|
||||||
|
渲染一个优雅且符合 shadcn 风格的中性 404 提示页,而不是死白屏。
|
||||||
|
*/}
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageErrorBoundary>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import QrCodePreview from '@/components/QrCodePreview';
|
import QrCodePreview from '@/components/QrCodePreview';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
|
||||||
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
import { useLazyTranslation } from '@/utils/useLazyTranslation';
|
||||||
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||||
|
|
||||||
export default function GeneratePanel() {
|
export default function GeneratePanel() {
|
||||||
const { t } = useLazyTranslation('qrCode');
|
const { t } = useLazyTranslation('qrCode');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
|
const { generatorState, setTextToEncode, downloadQrCode, copyQrCode } = useQrCodeContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -21,7 +19,6 @@ export default function GeneratePanel() {
|
|||||||
showClear
|
showClear
|
||||||
allowCopy
|
allowCopy
|
||||||
externalError={generatorState.inputError}
|
externalError={generatorState.inputError}
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useCallback } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import TextInputArea from '@/components/TextInputArea';
|
import TextInputArea from '@/components/TextInputArea';
|
||||||
import ImageUploader from '@/components/ImageUploader';
|
import ImageUploader from '@/components/ImageUploader';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { useSnackbar } from '@/components/GlobalSnackbar';
|
||||||
@@ -77,7 +77,6 @@ export default function ParsePanel() {
|
|||||||
allowCopy
|
allowCopy
|
||||||
placeholder=""
|
placeholder=""
|
||||||
externalError={parserState.parseError}
|
externalError={parserState.parseError}
|
||||||
showMessage={showMessage}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user