You've already forked Nano-Banana-AI-Image-Editor
新增 错误提示显示错误详情;
新增 错误提示现在在鼠标悬停时不会消失;
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 : '未知错误'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user