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:
雨霖铃
2026-05-29 20:47:16 +08:00
parent c2e54b34cb
commit 2727e60025
10 changed files with 43 additions and 556 deletions
-372
View File
@@ -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;
+6 -13
View File
@@ -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();
+6 -9
View File
@@ -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>
); );
} }
+6 -9
View File
@@ -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>
); );
} }
+5 -6
View File
@@ -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(() => {
+10 -11
View File
@@ -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) => {
+3 -4
View 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