新增 错误提示显示错误详情;

新增 错误提示现在在鼠标悬停时不会消失;
This commit is contained in:
2025-09-14 04:35:11 +08:00
parent fd35325c52
commit 50124d1acb
4 changed files with 130 additions and 26 deletions

View File

@@ -1,15 +1,21 @@
import React from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { cn } from '../utils/cn'; import { cn } from '../utils/cn';
import { X } from 'lucide-react'; import { X, ChevronDown, ChevronUp } from 'lucide-react';
export interface ToastProps { export interface ToastProps {
id: string; id: string;
message: string; message: string;
type: 'success' | 'error' | 'warning' | 'info'; type: 'success' | 'error' | 'warning' | 'info';
details?: string;
onClose: (id: string) => void; onClose: (id: string) => void;
onHoverChange?: (hovered: boolean) => void;
} }
export const Toast: React.FC<ToastProps> = ({ id, message, type, onClose }) => { export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClose, onHoverChange }) => {
const [showDetails, setShowDetails] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const getTypeStyles = () => { const getTypeStyles = () => {
switch (type) { switch (type) {
case 'success': case 'success':
@@ -25,21 +31,81 @@ export const Toast: React.FC<ToastProps> = ({ 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 ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-between p-4 rounded-lg shadow-lg min-w-[300px] max-w-md transition-all duration-300 transform', 'flex flex-col rounded-lg shadow-lg min-w-[300px] max-w-md transition-all duration-300 transform',
getTypeStyles(), getTypeStyles(),
'animate-in slide-in-from-top-full duration-300' 'animate-in slide-in-from-top-full duration-300'
)} )}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
> >
<span className="text-sm font-medium">{message}</span> <div className="flex items-start justify-between p-4">
<button <div className="flex-1">
onClick={() => onClose(id)} <span className="text-sm font-medium">{message}</span>
className="ml-4 hover:bg-black/10 rounded-full p-1 transition-colors" {details && (
> <button
<X className="h-4 w-4" /> onClick={() => setShowDetails(!showDetails)}
</button> className="mt-2 flex items-center text-xs opacity-80 hover:opacity-100 transition-opacity"
>
{showDetails ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
</>
)}
</button>
)}
</div>
<button
onClick={() => onClose(id)}
className="ml-4 hover:bg-black/10 rounded-full p-1 transition-colors"
>
<X className="h-4 w-4" />
</button>
</div>
{details && showDetails && (
<div className="px-4 pb-4">
<div className="text-xs opacity-90 whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{details}
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -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'; import { Toast } from './Toast';
type ToastType = 'success' | 'error' | 'warning' | 'info'; type ToastType = 'success' | 'error' | 'warning' | 'info';
@@ -8,10 +8,11 @@ export interface ToastMessage {
message: string; message: string;
type: ToastType; type: ToastType;
duration?: number; duration?: number;
details?: string;
} }
export interface ToastContextType { 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; removeToast: (id: string) => void;
} }
@@ -19,14 +20,21 @@ const ToastContext = createContext<ToastContextType | undefined>(undefined);
type ToastAction = type ToastAction =
| { type: 'ADD_TOAST'; payload: ToastMessage } | { 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[] => { const toastReducer = (state: ToastMessage[], action: ToastAction): ToastMessage[] => {
switch (action.type) { switch (action.type) {
case 'ADD_TOAST': case 'ADD_TOAST':
return [...state, action.payload]; return [...state, { ...action.payload, hovered: false }];
case 'REMOVE_TOAST': case 'REMOVE_TOAST':
return state.filter(toast => toast.id !== action.payload); 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: default:
return state; return state;
} }
@@ -34,28 +42,52 @@ const toastReducer = (state: ToastMessage[], action: ToastAction): ToastMessage[
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, dispatch] = useReducer(toastReducer, []); const [toasts, dispatch] = useReducer(toastReducer, []);
const hoverTimeouts = useRef<Record<string, NodeJS.Timeout>>({});
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(); 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) => { const removeToast = (id: string) => {
dispatch({ type: 'REMOVE_TOAST', payload: id }); 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(() => { useEffect(() => {
const timers = toasts.map(toast => { const timers = toasts.map(toast => {
if (toast.duration === 0) return; // 0 means persistent // Clear any existing timeout for this toast
return setTimeout(() => { 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); removeToast(toast.id);
}, toast.duration); }, toast.duration);
return { id: toast.id, timeout };
}); });
// Cleanup function
return () => { return () => {
timers.forEach(timer => { timers.forEach(timer => {
if (timer) clearTimeout(timer); if (timer) clearTimeout(timer.timeout);
}); });
}; };
}, [toasts]); }, [toasts]);
@@ -70,7 +102,9 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
id={toast.id} id={toast.id}
message={toast.message} message={toast.message}
type={toast.type} type={toast.type}
details={toast.details}
onClose={removeToast} onClose={removeToast}
onHoverChange={(hovered) => setToastHovered(toast.id, hovered)}
/> />
))} ))}
</div> </div>

View File

@@ -71,7 +71,9 @@ export const useImageGeneration = () => {
}, },
onError: (error) => { onError: (error) => {
console.error('生成失败:', 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); setIsGenerating(false);
} }
}); });
@@ -260,7 +262,9 @@ export const useImageEditing = () => {
}, },
onError: (error) => { onError: (error) => {
console.error('编辑失败:', 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); setIsGenerating(false);
} }
}); });

View File

@@ -73,7 +73,7 @@ export class GeminiService {
if (error instanceof Error && error.message) { if (error instanceof Error && error.message) {
throw error; 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) { if (error instanceof Error && error.message) {
throw error; 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) { if (error instanceof Error && error.message) {
throw error; throw error;
} }
throw new Error('分割图像失败。请重试。'); throw new Error(`分割图像失败: ${error instanceof Error ? error.message : '未知错误'}`);
} }
} }