refactor: replace GlobalSnackbar with sonner toast
Remove the custom 373-line GlobalSnackbar component and its Provider/Hook in favor of the already-installed sonner library. Changes: - Delete src/components/GlobalSnackbar.tsx (+ tests) - Remove SnackbarProvider from popup/App.tsx and sidepanel/App.tsx - Replace useSnackbar() calls with toast.success()/toast.error() in: - ImageUploader.tsx - useQrCode.ts - ParsePanel.tsx - LiveClock.tsx - Update test mocks to use sonner instead of GlobalSnackbar -554 lines, +43 lines (net -511 lines) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,372 +0,0 @@
|
|||||||
/**
|
|
||||||
* GlobalSnackbar - 全局 Snackbar 消息提示组件及 Provider
|
|
||||||
*
|
|
||||||
* 提供可复用的 Toast 消息提示功能,支持三种使用方式:
|
|
||||||
* 1. 作为受控组件使用:通过 props 控制显示状态
|
|
||||||
* 2. 通过 useSnackbarState Hook 使用:在组件内部自动管理状态
|
|
||||||
* 3. 通过 SnackbarProvider 和 useSnackbar Hook 使用:全局单例模式
|
|
||||||
*
|
|
||||||
* NOTE: 项目同时使用 sonner 的 toast 进行简单的一次性提示。
|
|
||||||
* 本组件适用于需要 severity 级别、Provider 上下文、自定义定位等高级场景。
|
|
||||||
* 简单场景(如复制成功、操作提示)优先使用 `import { toast } from 'sonner'`。
|
|
||||||
*
|
|
||||||
* @module GlobalSnackbar
|
|
||||||
* @version 1.1.0
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* // 方式一:受控组件
|
|
||||||
* <GlobalSnackbar
|
|
||||||
* message="操作成功"
|
|
||||||
* open={isOpen}
|
|
||||||
* onClose={() => setIsOpen(false)}
|
|
||||||
* severity="success"
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* // 方式二:Hook 方式 (局部状态)
|
|
||||||
* const { snackbarProps, showMessage } = useSnackbarState();
|
|
||||||
* showMessage('Hello!', { severity: 'info' });
|
|
||||||
*
|
|
||||||
* // 方式三:Context 方式 (全局状态)
|
|
||||||
* // 在根组件包裹 Provider
|
|
||||||
* <SnackbarProvider>
|
|
||||||
* <App />
|
|
||||||
* </SnackbarProvider>
|
|
||||||
*
|
|
||||||
* // 在子组件中使用
|
|
||||||
* const { showMessage } = useSnackbar();
|
|
||||||
* showMessage('Global Message');
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
JSX,
|
|
||||||
useState,
|
|
||||||
useRef,
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
type ReactNode,
|
|
||||||
type SyntheticEvent,
|
|
||||||
} from 'react';
|
|
||||||
import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snackbar 消息严重程度类型
|
|
||||||
* @description 决定 Alert 组件的颜色和图标
|
|
||||||
* - success: 绿色,成功提示
|
|
||||||
* - info: 蓝色,信息提示
|
|
||||||
* - warning: 橙色,警告提示
|
|
||||||
* - error: 红色,错误提示
|
|
||||||
*/
|
|
||||||
export type SnackbarSeverity = 'success' | 'info' | 'warning' | 'error';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件的属性接口
|
|
||||||
* @interface GlobalSnackbarProps
|
|
||||||
*/
|
|
||||||
export interface GlobalSnackbarProps {
|
|
||||||
/** 消息内容,要显示的提示文本 */
|
|
||||||
message: string;
|
|
||||||
/** 是否显示 Snackbar */
|
|
||||||
open: boolean;
|
|
||||||
/** 关闭回调函数 */
|
|
||||||
onClose: () => void;
|
|
||||||
/** 消息级别,影响颜色和图标样式,默认 'info' */
|
|
||||||
severity?: SnackbarSeverity;
|
|
||||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭,默认 2000 */
|
|
||||||
autoHideDuration?: number;
|
|
||||||
/** Snackbar 弹出位置,默认 { vertical: 'bottom', horizontal: 'center' } */
|
|
||||||
anchorOrigin?: {
|
|
||||||
vertical: 'top' | 'bottom';
|
|
||||||
horizontal: 'left' | 'center' | 'right';
|
|
||||||
};
|
|
||||||
/** 是否使用 Alert 组件包裹,false 则使用原生 Snackbar message,默认 true */
|
|
||||||
showAlert?: boolean;
|
|
||||||
/** 是否隐藏 Alert 图标,默认 false */
|
|
||||||
hideIcon?: boolean;
|
|
||||||
/** 自定义样式,透传给外层 Snackbar 组件 */
|
|
||||||
sx?: React.CSSProperties;
|
|
||||||
/** 自定义样式,透传给内层 Alert 组件(仅 showAlert=true 时生效) */
|
|
||||||
alertSx?: React.CSSProperties;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* showMessage 方法的选项配置
|
|
||||||
* @interface SnackbarOptions
|
|
||||||
*/
|
|
||||||
export interface SnackbarOptions {
|
|
||||||
/** 消息级别:success | info | warning | error */
|
|
||||||
severity?: SnackbarSeverity;
|
|
||||||
/** 自动隐藏时间(毫秒),设为 0 则不自动关闭 */
|
|
||||||
autoHideDuration?: number;
|
|
||||||
/** 是否隐藏 Alert 图标 */
|
|
||||||
hideIcon?: boolean;
|
|
||||||
/** 是否使用 Alert 组件包裹 */
|
|
||||||
showAlert?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbarState Hook 的返回值类型
|
|
||||||
* @interface UseSnackbarStateResult
|
|
||||||
*/
|
|
||||||
export interface UseSnackbarStateResult {
|
|
||||||
/** 传递给 GlobalSnackbar 组件的属性对象 */
|
|
||||||
snackbarProps: GlobalSnackbarProps;
|
|
||||||
/** 显示消息的方法 */
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
/** 关闭消息的方法 */
|
|
||||||
closeMessage: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件的默认属性配置
|
|
||||||
* @description 提供类型安全的默认值选择
|
|
||||||
*/
|
|
||||||
const defaultProps: Required<
|
|
||||||
Pick<
|
|
||||||
GlobalSnackbarProps,
|
|
||||||
'severity' | 'autoHideDuration' | 'anchorOrigin' | 'showAlert' | 'hideIcon'
|
|
||||||
>
|
|
||||||
> = {
|
|
||||||
severity: 'info',
|
|
||||||
autoHideDuration: 2000,
|
|
||||||
anchorOrigin: { vertical: 'bottom', horizontal: 'center' },
|
|
||||||
showAlert: true,
|
|
||||||
hideIcon: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityConfig: Record<
|
|
||||||
SnackbarSeverity,
|
|
||||||
{ icon: React.ElementType; bgClass: string; textClass: string }
|
|
||||||
> = {
|
|
||||||
success: { icon: CheckCircle, bgClass: 'bg-green-500', textClass: 'text-white' },
|
|
||||||
info: { icon: Info, bgClass: 'bg-primary/100', textClass: 'text-white' },
|
|
||||||
warning: { icon: AlertTriangle, bgClass: 'bg-amber-500', textClass: 'text-white' },
|
|
||||||
error: { icon: XCircle, bgClass: 'bg-red-500', textClass: 'text-white' },
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GlobalSnackbar 组件
|
|
||||||
*
|
|
||||||
* 全局消息提示的展示组件,支持受控和非受控两种使用模式。
|
|
||||||
*
|
|
||||||
* @param {GlobalSnackbarProps} props - 组件属性
|
|
||||||
* @returns {JSX.Element}
|
|
||||||
*/
|
|
||||||
export function GlobalSnackbar({
|
|
||||||
message,
|
|
||||||
open,
|
|
||||||
onClose,
|
|
||||||
severity = defaultProps.severity,
|
|
||||||
autoHideDuration = defaultProps.autoHideDuration,
|
|
||||||
showAlert = defaultProps.showAlert,
|
|
||||||
hideIcon = defaultProps.hideIcon,
|
|
||||||
}: GlobalSnackbarProps): JSX.Element | null {
|
|
||||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open && autoHideDuration > 0) {
|
|
||||||
timerRef.current = setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
}, autoHideDuration);
|
|
||||||
return () => {
|
|
||||||
if (timerRef.current) clearTimeout(timerRef.current);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [open, autoHideDuration, onClose]);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const config = severityConfig[severity];
|
|
||||||
const IconComponent = config.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed z-[999999] bottom-6 left-1/2 -translate-x-1/2 animate-in fade-in slide-in-from-bottom-2 duration-300">
|
|
||||||
{showAlert ? (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 px-5 py-1.5 rounded-full shadow-lg ${config.bgClass} ${config.textClass}`}
|
|
||||||
style={{ minWidth: '140px' }}
|
|
||||||
>
|
|
||||||
{!hideIcon && <IconComponent className="h-4 w-4 flex-shrink-0" />}
|
|
||||||
<span className="text-xs font-bold">{message}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="px-4 py-2 rounded-lg bg-gray-800 text-white text-sm">{message}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbarState - 消息提示的 Hook 方式
|
|
||||||
*
|
|
||||||
* 提供状态管理的 Snackbar 功能,自动处理 open、message 等状态。
|
|
||||||
* 适合在组件内部使用,无需额外的状态管理代码。
|
|
||||||
*
|
|
||||||
* @param {SnackbarOptions} [initialOptions] - 初始配置选项
|
|
||||||
* @returns {UseSnackbarStateResult} 包含 snackbarProps 和操作方法的对象
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 自动管理 Snackbar 的显示/隐藏状态
|
|
||||||
* - 支持链式调用 showMessage
|
|
||||||
* - 合并初始选项和调用时选项
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* function MyComponent() {
|
|
||||||
* const { snackbarProps, showMessage, closeMessage } = useSnackbarState({
|
|
||||||
* severity: 'info',
|
|
||||||
* autoHideDuration: 3000,
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* const handleSave = () => {
|
|
||||||
* // 业务逻辑...
|
|
||||||
* showMessage('保存成功!', { severity: 'success' });
|
|
||||||
* };
|
|
||||||
*
|
|
||||||
* return (
|
|
||||||
* <>
|
|
||||||
* <button onClick={handleSave}>保存</button>
|
|
||||||
* <GlobalSnackbar {...snackbarProps} />
|
|
||||||
* </>
|
|
||||||
* );
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useSnackbarState(initialOptions?: SnackbarOptions): UseSnackbarStateResult {
|
|
||||||
// Snackbar 显示状态
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
// 当前显示的消息内容
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
// 消息配置选项
|
|
||||||
const [options, setOptions] = useState<SnackbarOptions>(initialOptions || {});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 显示消息
|
|
||||||
*
|
|
||||||
* @param {string} newMessage - 要显示的消息文本
|
|
||||||
* @param {SnackbarOptions} [newOptions={}] - 新的配置选项
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 合并初始选项和新的调用选项
|
|
||||||
* - 新选项会覆盖初始选项
|
|
||||||
*/
|
|
||||||
const showMessage = (newMessage: string, newOptions: SnackbarOptions = {}) => {
|
|
||||||
setMessage(newMessage);
|
|
||||||
setOptions({ ...initialOptions, ...newOptions });
|
|
||||||
setOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 关闭消息
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 直接将 open 状态设置为 false
|
|
||||||
*/
|
|
||||||
const closeMessage = () => {
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClose = (_event?: SyntheticEvent | Event, reason?: string) => {
|
|
||||||
if (reason === 'clickaway') return;
|
|
||||||
closeMessage();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 传递给 GlobalSnackbar 组件的属性
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* - 组合当前状态和选项为完整的组件 props
|
|
||||||
* - onClose 使用 handleClose 包装后的版本
|
|
||||||
*/
|
|
||||||
const snackbarProps: GlobalSnackbarProps = {
|
|
||||||
message,
|
|
||||||
open,
|
|
||||||
onClose: handleClose,
|
|
||||||
severity: options.severity,
|
|
||||||
autoHideDuration: options.autoHideDuration,
|
|
||||||
hideIcon: options.hideIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
snackbarProps,
|
|
||||||
showMessage,
|
|
||||||
closeMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Context & Provider ---
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snackbar Context 的值类型定义
|
|
||||||
*/
|
|
||||||
interface SnackbarContextValue {
|
|
||||||
showMessage: (message: string, options?: SnackbarOptions) => void;
|
|
||||||
closeMessage: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SnackbarContext = createContext<SnackbarContextValue | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SnackbarProvider 组件的 props 类型
|
|
||||||
*/
|
|
||||||
interface SnackbarProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
initialOptions?: SnackbarOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SnackbarProvider 组件
|
|
||||||
*
|
|
||||||
* 全局消息提示的 Provider 组件,需要包裹在应用根组件外层。
|
|
||||||
*/
|
|
||||||
export function SnackbarProvider({ children, initialOptions }: SnackbarProviderProps): JSX.Element {
|
|
||||||
const { snackbarProps, showMessage, closeMessage } = useSnackbarState(initialOptions);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SnackbarContext.Provider value={{ showMessage, closeMessage }}>
|
|
||||||
{children}
|
|
||||||
<GlobalSnackbar {...snackbarProps} />
|
|
||||||
</SnackbarContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useSnackbar - 在子组件中获取 Snackbar 上下文的 Hook
|
|
||||||
*
|
|
||||||
* @param {SnackbarOptions} [options] - 钩子级别的默认配置(如 autoHideDuration)
|
|
||||||
* @returns {SnackbarContextValue} - 包含 showMessage 和 closeMessage 的对象
|
|
||||||
* @throws {Error} - 如果不在 SnackbarProvider 内部调用,抛出错误
|
|
||||||
*
|
|
||||||
* @description
|
|
||||||
* 选项合并策略:
|
|
||||||
* 1. 调用 showMessage 时传入的 callOptions 优先级最高
|
|
||||||
* 2. useSnackbar(options) 传入的 Hook 级别配置次之
|
|
||||||
* 3. SnackbarProvider(initialOptions) 传入的全局配置优先级最低
|
|
||||||
*/
|
|
||||||
export function useSnackbar(options?: SnackbarOptions): SnackbarContextValue {
|
|
||||||
const context = useContext(SnackbarContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useSnackbar must be used within SnackbarProvider');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 包装 showMessage 以支持 Hook 级别的 initialOptions
|
|
||||||
const wrappedShowMessage = (message: string, callOptions?: SnackbarOptions) => {
|
|
||||||
// 采用防御性编程,确保 options 和 callOptions 为空时也能正常工作
|
|
||||||
// 优先级:callOptions > options
|
|
||||||
const mergedOptions: SnackbarOptions = {
|
|
||||||
...(options || {}),
|
|
||||||
...(callOptions || {}),
|
|
||||||
};
|
|
||||||
context.showMessage(message, mergedOptions);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
...context,
|
|
||||||
showMessage: wrappedShowMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default GlobalSnackbar;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { Image, X } from 'lucide-react';
|
import { Image, X } from 'lucide-react';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { toast } from 'sonner';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
|
|
||||||
interface ImageUploaderProps {
|
interface ImageUploaderProps {
|
||||||
@@ -30,7 +30,6 @@ const ImageUploader = ({
|
|||||||
onDraggingChange,
|
onDraggingChange,
|
||||||
}: ImageUploaderProps) => {
|
}: ImageUploaderProps) => {
|
||||||
const { t } = useI18n('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
@@ -46,11 +45,8 @@ const ImageUploader = ({
|
|||||||
URL.revokeObjectURL(previewUrl);
|
URL.revokeObjectURL(previewUrl);
|
||||||
}
|
}
|
||||||
onClearFile();
|
onClearFile();
|
||||||
showMessage(t('qrCode:imageCleared'), {
|
toast.success(t('qrCode:imageCleared'));
|
||||||
severity: 'success',
|
}, [previewUrl, onClearFile, t]);
|
||||||
autoHideDuration: 1000,
|
|
||||||
});
|
|
||||||
}, [previewUrl, onClearFile, showMessage, t]);
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
@@ -90,13 +86,10 @@ const ImageUploader = ({
|
|||||||
if (file) {
|
if (file) {
|
||||||
try {
|
try {
|
||||||
handleFileChange(file);
|
handleFileChange(file);
|
||||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:imagePasted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('处理粘贴图片失败:', error);
|
console.error('处理粘贴图片失败:', error);
|
||||||
showMessage(t('qrCode:imagePasteError'), {
|
toast.error(t('qrCode:imagePasteError'));
|
||||||
severity: 'error',
|
|
||||||
autoHideDuration: 3000,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -109,7 +102,7 @@ const ImageUploader = ({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('paste', handlePaste);
|
document.removeEventListener('paste', handlePaste);
|
||||||
};
|
};
|
||||||
}, [showMessage, handleFileChange, t]);
|
}, [handleFileChange, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { act, render, renderHook, screen } from '@testing-library/react';
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
GlobalSnackbar,
|
|
||||||
type GlobalSnackbarProps,
|
|
||||||
SnackbarProvider,
|
|
||||||
useSnackbar,
|
|
||||||
useSnackbarState,
|
|
||||||
} from '@/components/GlobalSnackbar';
|
|
||||||
|
|
||||||
describe('GlobalSnackbar 组件系统', () => {
|
|
||||||
const mockOnClose = vi.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultProps: GlobalSnackbarProps = {
|
|
||||||
message: '测试消息',
|
|
||||||
open: true,
|
|
||||||
onClose: mockOnClose,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('GlobalSnackbar UI 渲染', () => {
|
|
||||||
it('应渲染消息内容', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} />);
|
|
||||||
expect(screen.getByText('测试消息')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当 showAlert 为 true 时应渲染带样式的提示', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} showAlert={true} />);
|
|
||||||
// 验证是否包含消息文本
|
|
||||||
const alertElement = screen.getByText('测试消息');
|
|
||||||
expect(alertElement).toBeInTheDocument();
|
|
||||||
// 验证父元素有正确的样式类
|
|
||||||
const parent = alertElement.parentElement;
|
|
||||||
expect(parent).toHaveClass('flex', 'items-center', 'gap-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('当 hideIcon 为 true 时不应渲染图标', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} hideIcon={true} />);
|
|
||||||
// 图标使用 lucide-react 的 svg 元素
|
|
||||||
const icon = document.querySelector('svg');
|
|
||||||
expect(icon).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应根据 severity 应用不同的样式', () => {
|
|
||||||
render(<GlobalSnackbar {...defaultProps} severity="error" />);
|
|
||||||
const message = screen.getByText('测试消息');
|
|
||||||
const parent = message.parentElement;
|
|
||||||
expect(parent).toHaveClass('bg-red-500');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useSnackbarState Hook 逻辑', () => {
|
|
||||||
it('应返回初始状态', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
|
||||||
expect(result.current.snackbarProps.message).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('showMessage 应更新状态', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('新消息');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(true);
|
|
||||||
expect(result.current.snackbarProps.message).toBe('新消息');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closeMessage 应关闭消息', () => {
|
|
||||||
const { result } = renderHook(() => useSnackbarState());
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('消息');
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
result.current.closeMessage();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.snackbarProps.open).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useSnackbar Context Hook 优先级', () => {
|
|
||||||
it('优先级验证: Call Options > Hook Options > Provider Options', () => {
|
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<SnackbarProvider initialOptions={{ severity: 'info' }}>{children}</SnackbarProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useSnackbar({ severity: 'warning' }), { wrapper });
|
|
||||||
|
|
||||||
// 1. 测试 Hook Options 覆盖 Provider Options
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('消息 1');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('消息 1')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// 2. 测试 Call Options 覆盖 Hook Options
|
|
||||||
act(() => {
|
|
||||||
result.current.showMessage('消息 2', { severity: 'error' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('消息 2')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,10 +13,11 @@ const mockRevokeObjectURL = vi.fn();
|
|||||||
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
Object.defineProperty(window.URL, 'createObjectURL', { value: mockCreateObjectURL });
|
||||||
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
Object.defineProperty(window.URL, 'revokeObjectURL', { value: mockRevokeObjectURL });
|
||||||
|
|
||||||
vi.mock('@/components/GlobalSnackbar', () => ({
|
vi.mock('sonner', () => ({
|
||||||
useSnackbar: () => ({
|
toast: {
|
||||||
showMessage: vi.fn(),
|
success: vi.fn(),
|
||||||
}),
|
error: vi.fn(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('ImageUploader 组件', () => {
|
describe('ImageUploader 组件', () => {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import { RouterProvider } from '@/providers/RouterProvider';
|
import { RouterProvider } from '@/providers/RouterProvider';
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
|
||||||
import type { PageType } from '@/types/storage';
|
import type { PageType } from '@/types/storage';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
@@ -29,11 +28,7 @@ describe('RouterContainer 组件', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const renderWithProvider = (ui: React.ReactElement) => {
|
const renderWithProvider = (ui: React.ReactElement) => {
|
||||||
return render(
|
return render(<RouterProvider>{ui}</RouterProvider>);
|
||||||
<SnackbarProvider>
|
|
||||||
<RouterProvider>{ui}</RouterProvider>
|
|
||||||
</SnackbarProvider>,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('渲染测试', () => {
|
describe('渲染测试', () => {
|
||||||
@@ -74,11 +69,7 @@ describe('RouterContainer 组件', () => {
|
|||||||
const { rerender } = renderWithProvider(<RouterContainer />);
|
const { rerender } = renderWithProvider(<RouterContainer />);
|
||||||
|
|
||||||
mockRouterValue.currentPage = 'timestamp';
|
mockRouterValue.currentPage = 'timestamp';
|
||||||
rerender(
|
rerender(<RouterProvider>{<RouterContainer />}</RouterProvider>);
|
||||||
<SnackbarProvider>
|
|
||||||
<RouterProvider>{<RouterContainer />}</RouterProvider>
|
|
||||||
</SnackbarProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const box = document.querySelector('.page-transition-enter');
|
const box = document.querySelector('.page-transition-enter');
|
||||||
expect(box).toBeInTheDocument();
|
expect(box).toBeInTheDocument();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import RouterProvider from '@/providers/RouterProvider';
|
|||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
|
||||||
import { getEntryPointType } from '@/config/features';
|
import { getEntryPointType } from '@/config/features';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
@@ -30,14 +29,12 @@ export default function App() {
|
|||||||
visiblePagesKey={routerConfig.visiblePagesKey}
|
visiblePagesKey={routerConfig.visiblePagesKey}
|
||||||
pageOrderKey={routerConfig.pageOrderKey}
|
pageOrderKey={routerConfig.pageOrderKey}
|
||||||
>
|
>
|
||||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
||||||
<div className="app flex flex-col w-[400px] max-w-[400px] min-w-[400px] h-[600px] min-h-[600px] overflow-hidden bg-background sm:w-screen sm:max-w-none sm:min-w-0 sm:h-screen sm:min-h-0">
|
<TopBar />
|
||||||
<TopBar />
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<RouterContainer />
|
||||||
<RouterContainer />
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</div>
|
||||||
</div>
|
|
||||||
</SnackbarProvider>
|
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import RouterProvider from '@/providers/RouterProvider';
|
|||||||
import TopBar from '@/components/TopBar';
|
import TopBar from '@/components/TopBar';
|
||||||
import RouterContainer from '@/components/RouterContainer';
|
import RouterContainer from '@/components/RouterContainer';
|
||||||
import ErrorBoundary from '@/components/ErrorBoundary';
|
import ErrorBoundary from '@/components/ErrorBoundary';
|
||||||
import { SnackbarProvider } from '@/components/GlobalSnackbar';
|
|
||||||
import { MessageAction, sendMessage } from '@/utils/messages';
|
import { MessageAction, sendMessage } from '@/utils/messages';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -16,14 +15,12 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
<RouterProvider defaultRoute="dashboard" syncKey="app/sidepanelRoute">
|
||||||
<SnackbarProvider initialOptions={{ autoHideDuration: 1500 }}>
|
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
||||||
<div className="app flex flex-col h-screen w-full overflow-hidden">
|
<TopBar />
|
||||||
<TopBar />
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<RouterContainer />
|
||||||
<RouterContainer />
|
</ErrorBoundary>
|
||||||
</ErrorBoundary>
|
</div>
|
||||||
</div>
|
|
||||||
</SnackbarProvider>
|
|
||||||
</RouterProvider>
|
</RouterProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
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 { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
import { useQrCodeContext } from '../contexts/QrCodeContext';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -9,7 +9,6 @@ import { cn } from '@/lib/utils';
|
|||||||
|
|
||||||
export default function ParsePanel() {
|
export default function ParsePanel() {
|
||||||
const { t } = useI18n('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
|
const { parserState, setParserState, handleFileChange, handleClearFile } = useQrCodeContext();
|
||||||
|
|
||||||
// 全局粘贴事件监听
|
// 全局粘贴事件监听
|
||||||
@@ -24,7 +23,7 @@ export default function ParsePanel() {
|
|||||||
const file = items[i].getAsFile();
|
const file = items[i].getAsFile();
|
||||||
if (file) {
|
if (file) {
|
||||||
handleFileChange(file);
|
handleFileChange(file);
|
||||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:imagePasted'));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -38,14 +37,14 @@ export default function ParsePanel() {
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
const file = new File([blob], 'pasted-image.png', { type: blob.type });
|
||||||
handleFileChange(file);
|
handleFileChange(file);
|
||||||
showMessage(t('qrCode:imagePasted'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:imagePasted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('处理 Base64 图片失败:', error);
|
console.error('处理 Base64 图片失败:', error);
|
||||||
showMessage(t('qrCode:imagePasteError'), { severity: 'error', autoHideDuration: 3000 });
|
toast.error(t('qrCode:imagePasteError'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleFileChange, showMessage, t],
|
[handleFileChange, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
import QRious from 'qrious';
|
import QRious from 'qrious';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
import { toast } from 'sonner';
|
||||||
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
import { parseQrCodeFromFile } from '@/utils/qrCodeParser';
|
||||||
import { useContextMenuData } from '@/utils/useContextMenuData';
|
import { useContextMenuData } from '@/utils/useContextMenuData';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
@@ -10,7 +10,6 @@ import type { QrCodeGeneratorState, QrCodeMode, QrCodeParserState } from '../typ
|
|||||||
|
|
||||||
export function useQrCode(): QrCodeContextValue {
|
export function useQrCode(): QrCodeContextValue {
|
||||||
const { t } = useI18n('qrCode');
|
const { t } = useI18n('qrCode');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
|
|
||||||
const [mode, setMode] = useState<QrCodeMode>('generate');
|
const [mode, setMode] = useState<QrCodeMode>('generate');
|
||||||
|
|
||||||
@@ -89,22 +88,22 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
|
|
||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
|
setParserState((prev) => ({ ...prev, decodedResult: result.data! }));
|
||||||
showMessage(t('qrCode:parseSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:parseSuccess'));
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error || t('qrCode:noQrDetected');
|
const errorMsg = result.error || t('qrCode:noQrDetected');
|
||||||
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||||
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
toast.error(errorMsg);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('解析二维码失败:', error);
|
console.error('解析二维码失败:', error);
|
||||||
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
|
const errorMsg = error instanceof Error ? error.message : t('qrCode:parseError');
|
||||||
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
setParserState((prev) => ({ ...prev, parseError: errorMsg }));
|
||||||
showMessage(errorMsg, { severity: 'error', autoHideDuration: 3000 });
|
toast.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setParserState((prev) => ({ ...prev, parsing: false }));
|
setParserState((prev) => ({ ...prev, parsing: false }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t, showMessage],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const downloadQrCode = useCallback(() => {
|
const downloadQrCode = useCallback(() => {
|
||||||
@@ -114,8 +113,8 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
link.href = qrCodeDataUrl;
|
link.href = qrCodeDataUrl;
|
||||||
link.download = 'qrcode.png';
|
link.download = 'qrcode.png';
|
||||||
link.click();
|
link.click();
|
||||||
showMessage(t('qrCode:qrCodeDownloadSuccess'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:qrCodeDownloadSuccess'));
|
||||||
}, [qrCodeDataUrl, showMessage, t]);
|
}, [qrCodeDataUrl, t]);
|
||||||
|
|
||||||
const copyQrCode = useCallback(async () => {
|
const copyQrCode = useCallback(async () => {
|
||||||
if (!qrCodeDataUrl) return;
|
if (!qrCodeDataUrl) return;
|
||||||
@@ -130,12 +129,12 @@ export function useQrCode(): QrCodeContextValue {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
showMessage(t('qrCode:qrCodeCopySuccess'), { severity: 'success', autoHideDuration: 1000 });
|
toast.success(t('qrCode:qrCodeCopySuccess'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('复制二维码失败:', error);
|
console.error('复制二维码失败:', error);
|
||||||
showMessage(t('qrCode:copyError'), { severity: 'error', autoHideDuration: 3000 });
|
toast.error(t('qrCode:copyError'));
|
||||||
}
|
}
|
||||||
}, [qrCodeDataUrl, showMessage, t]);
|
}, [qrCodeDataUrl, t]);
|
||||||
|
|
||||||
const handleFileChange = useCallback(
|
const handleFileChange = useCallback(
|
||||||
(file: File) => {
|
(file: File) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
import { useSnackbar } from '@/components/GlobalSnackbar';
|
|
||||||
import type { UnitType } from './constants';
|
import type { UnitType } from './constants';
|
||||||
import { useI18n } from '@/utils/chromeI18n';
|
import { useI18n } from '@/utils/chromeI18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -13,7 +13,6 @@ interface LiveClockProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
|
|
||||||
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
|
const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClockProps) => {
|
||||||
const { t } = useI18n('timestamp');
|
const { t } = useI18n('timestamp');
|
||||||
const { showMessage } = useSnackbar();
|
|
||||||
const onUseNowRef = useRef(onUseNow);
|
const onUseNowRef = useRef(onUseNow);
|
||||||
|
|
||||||
const [currentDisplay, setCurrentDisplay] = useState(() => {
|
const [currentDisplay, setCurrentDisplay] = useState(() => {
|
||||||
@@ -45,8 +44,8 @@ const LiveClock = React.memo(({ unit, onUseNow, className, ...props }: LiveClock
|
|||||||
|
|
||||||
const handleUseNow = useCallback(() => {
|
const handleUseNow = useCallback(() => {
|
||||||
onUseNowRef.current(currentDisplay.rawTime);
|
onUseNowRef.current(currentDisplay.rawTime);
|
||||||
showMessage?.(t('timestamp:usedSuccess'), { severity: 'success' });
|
toast.success(t('timestamp:usedSuccess'));
|
||||||
}, [currentDisplay.rawTime, showMessage, t]);
|
}, [currentDisplay.rawTime, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user