diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 093d2d3..8e66ee3 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -1,15 +1,21 @@ -import React from 'react'; +import React, { useState, useRef, useEffect } from 'react'; import { cn } from '../utils/cn'; -import { X } from 'lucide-react'; +import { X, ChevronDown, ChevronUp } from 'lucide-react'; export interface ToastProps { id: string; message: string; type: 'success' | 'error' | 'warning' | 'info'; + details?: string; onClose: (id: string) => void; + onHoverChange?: (hovered: boolean) => void; } -export const Toast: React.FC = ({ id, message, type, onClose }) => { +export const Toast: React.FC = ({ id, message, type, details, onClose, onHoverChange }) => { + const [showDetails, setShowDetails] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const hoverTimeoutRef = useRef(null); + const getTypeStyles = () => { switch (type) { case 'success': @@ -25,21 +31,81 @@ export const Toast: React.FC = ({ id, message, type, onClose }) => { } }; + const handleMouseEnter = () => { + // Clear any existing timeout + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + hoverTimeoutRef.current = null; + } + + setIsHovered(true); + onHoverChange?.(true); + }; + + const handleMouseLeave = () => { + // Set a timeout to mark as not hovered after 1 second + hoverTimeoutRef.current = setTimeout(() => { + setIsHovered(false); + onHoverChange?.(false); + }, 1000); + }; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + }; + }, []); + return (
- {message} - +
+
+ {message} + {details && ( + + )} +
+ +
+ + {details && showDetails && ( +
+
+ {details} +
+
+ )}
); }; \ No newline at end of file diff --git a/src/components/ToastContext.tsx b/src/components/ToastContext.tsx index cdb056a..36e5d5c 100644 --- a/src/components/ToastContext.tsx +++ b/src/components/ToastContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useReducer, useState, useEffect } from 'react'; +import React, { createContext, useContext, useReducer, useState, useEffect, useRef } from 'react'; import { Toast } from './Toast'; type ToastType = 'success' | 'error' | 'warning' | 'info'; @@ -8,10 +8,11 @@ export interface ToastMessage { message: string; type: ToastType; duration?: number; + details?: string; } export interface ToastContextType { - addToast: (message: string, type: ToastType, duration?: number) => void; + addToast: (message: string, type: ToastType, duration?: number, details?: string) => void; removeToast: (id: string) => void; } @@ -19,14 +20,21 @@ const ToastContext = createContext(undefined); type ToastAction = | { type: 'ADD_TOAST'; payload: ToastMessage } - | { type: 'REMOVE_TOAST'; payload: string }; + | { type: 'REMOVE_TOAST'; payload: string } + | { type: 'SET_HOVERED_TOAST', payload: { id: string, hovered: boolean } }; const toastReducer = (state: ToastMessage[], action: ToastAction): ToastMessage[] => { switch (action.type) { case 'ADD_TOAST': - return [...state, action.payload]; + return [...state, { ...action.payload, hovered: false }]; case 'REMOVE_TOAST': return state.filter(toast => toast.id !== action.payload); + case 'SET_HOVERED_TOAST': + return state.map(toast => + toast.id === action.payload.id + ? { ...toast, hovered: action.payload.hovered } + : toast + ); default: return state; } @@ -34,28 +42,52 @@ const toastReducer = (state: ToastMessage[], action: ToastAction): ToastMessage[ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [toasts, dispatch] = useReducer(toastReducer, []); + const hoverTimeouts = useRef>({}); - const addToast = (message: string, type: ToastType, duration: number = 5000) => { + const addToast = (message: string, type: ToastType, duration: number = 5000, details?: string) => { const id = Date.now().toString(); - dispatch({ type: 'ADD_TOAST', payload: { id, message, type, duration } }); + dispatch({ type: 'ADD_TOAST', payload: { id, message, type, duration, details } }); }; const removeToast = (id: string) => { dispatch({ type: 'REMOVE_TOAST', payload: id }); }; - // Auto remove toasts after duration + const setToastHovered = (id: string, hovered: boolean) => { + dispatch({ type: 'SET_HOVERED_TOAST', payload: { id, hovered } }); + }; + + // Auto remove toasts after duration, but respect hover state useEffect(() => { const timers = toasts.map(toast => { - if (toast.duration === 0) return; // 0 means persistent - return setTimeout(() => { + // Clear any existing timeout for this toast + if (hoverTimeouts.current[toast.id]) { + clearTimeout(hoverTimeouts.current[toast.id]); + delete hoverTimeouts.current[toast.id]; + } + + // If toast is hovered, don't set a timer + if (toast.hovered) { + return null; + } + + // If duration is 0, it's persistent + if (toast.duration === 0) { + return null; + } + + // Set timeout to remove toast + const timeout = setTimeout(() => { removeToast(toast.id); }, toast.duration); + + return { id: toast.id, timeout }; }); + // Cleanup function return () => { timers.forEach(timer => { - if (timer) clearTimeout(timer); + if (timer) clearTimeout(timer.timeout); }); }; }, [toasts]); @@ -70,7 +102,9 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre id={toast.id} message={toast.message} type={toast.type} + details={toast.details} onClose={removeToast} + onHoverChange={(hovered) => setToastHovered(toast.id, hovered)} /> ))} diff --git a/src/hooks/useImageGeneration.ts b/src/hooks/useImageGeneration.ts index 07ed4ea..7bda117 100644 --- a/src/hooks/useImageGeneration.ts +++ b/src/hooks/useImageGeneration.ts @@ -71,7 +71,9 @@ export const useImageGeneration = () => { }, onError: (error) => { console.error('生成失败:', error); - addToast('图像生成失败,请重试', 'error'); + const errorMessage = error instanceof Error ? error.message : '未知错误'; + const errorDetails = error instanceof Error ? error.stack : undefined; + addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails); setIsGenerating(false); } }); @@ -260,7 +262,9 @@ export const useImageEditing = () => { }, onError: (error) => { console.error('编辑失败:', error); - addToast('图像编辑失败,请重试', 'error'); + const errorMessage = error instanceof Error ? error.message : '未知错误'; + const errorDetails = error instanceof Error ? error.stack : undefined; + addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails); setIsGenerating(false); } }); diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index 3def898..07cbfa2 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -73,7 +73,7 @@ export class GeminiService { if (error instanceof Error && error.message) { throw error; } - throw new Error('生成图像失败。请重试。'); + throw new Error(`生成图像失败: ${error instanceof Error ? error.message : '未知错误'}`); } } @@ -141,7 +141,7 @@ export class GeminiService { if (error instanceof Error && error.message) { throw error; } - throw new Error('编辑图像失败。请重试。'); + throw new Error(`编辑图像失败: ${error instanceof Error ? error.message : '未知错误'}`); } } @@ -190,7 +190,7 @@ export class GeminiService { if (error instanceof Error && error.message) { throw error; } - throw new Error('分割图像失败。请重试。'); + throw new Error(`分割图像失败: ${error instanceof Error ? error.message : '未知错误'}`); } }