You've already forked Nano-Banana-AI-Image-Editor
新增 全局错误toast提示;
This commit is contained in:
@@ -7,6 +7,7 @@ import { ImageCanvas } from './components/ImageCanvas';
|
||||
import { HistoryPanel } from './components/HistoryPanel';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import { useAppStore } from './store/useAppStore';
|
||||
import { ToastProvider } from './components/ToastContext';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -59,7 +60,9 @@ function AppContent() {
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContent />
|
||||
<ToastProvider>
|
||||
<AppContent />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
45
src/components/Toast.tsx
Normal file
45
src/components/Toast.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export interface ToastProps {
|
||||
id: string;
|
||||
message: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({ id, message, type, onClose }) => {
|
||||
const getTypeStyles = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-green-500 text-white';
|
||||
case 'error':
|
||||
return 'bg-red-500 text-white';
|
||||
case 'warning':
|
||||
return 'bg-yellow-500 text-white';
|
||||
case 'info':
|
||||
return 'bg-blue-500 text-white';
|
||||
default:
|
||||
return 'bg-gray-500 text-white';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-4 rounded-lg shadow-lg min-w-[300px] max-w-md transition-all duration-300 transform',
|
||||
getTypeStyles(),
|
||||
'animate-in slide-in-from-top-full duration-300'
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
87
src/components/ToastContext.tsx
Normal file
87
src/components/ToastContext.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React, { createContext, useContext, useReducer, useState, useEffect } from 'react';
|
||||
import { Toast } from './Toast';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface ToastContextType {
|
||||
addToast: (message: string, type: ToastType, duration?: number) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
type ToastAction =
|
||||
| { type: 'ADD_TOAST'; payload: ToastMessage }
|
||||
| { type: 'REMOVE_TOAST'; payload: string };
|
||||
|
||||
const toastReducer = (state: ToastMessage[], action: ToastAction): ToastMessage[] => {
|
||||
switch (action.type) {
|
||||
case 'ADD_TOAST':
|
||||
return [...state, action.payload];
|
||||
case 'REMOVE_TOAST':
|
||||
return state.filter(toast => toast.id !== action.payload);
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, dispatch] = useReducer(toastReducer, []);
|
||||
|
||||
const addToast = (message: string, type: ToastType, duration: number = 5000) => {
|
||||
const id = Date.now().toString();
|
||||
dispatch({ type: 'ADD_TOAST', payload: { id, message, type, duration } });
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
dispatch({ type: 'REMOVE_TOAST', payload: id });
|
||||
};
|
||||
|
||||
// Auto remove toasts after duration
|
||||
useEffect(() => {
|
||||
const timers = toasts.map(toast => {
|
||||
if (toast.duration === 0) return; // 0 means persistent
|
||||
return setTimeout(() => {
|
||||
removeToast(toast.id);
|
||||
}, toast.duration);
|
||||
});
|
||||
|
||||
return () => {
|
||||
timers.forEach(timer => {
|
||||
if (timer) clearTimeout(timer);
|
||||
});
|
||||
};
|
||||
}, [toasts]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast, removeToast }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
id={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={removeToast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -3,9 +3,11 @@ import { geminiService, GenerationRequest, EditRequest } from '../services/gemin
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
import { Generation, Edit, Asset } from '../types';
|
||||
import { useToast } from '../components/ToastContext';
|
||||
|
||||
export const useImageGeneration = () => {
|
||||
const { addGeneration, setIsGenerating, setCanvasImage, setCurrentProject, currentProject } = useAppStore();
|
||||
const { addToast } = useToast();
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async (request: GenerationRequest) => {
|
||||
@@ -35,15 +37,7 @@ export const useImageGeneration = () => {
|
||||
seed: request.seed,
|
||||
temperature: request.temperature
|
||||
},
|
||||
sourceAssets: request.referenceImage ? [{
|
||||
id: generateId(),
|
||||
type: 'original',
|
||||
url: `data:image/png;base64,${request.referenceImages[0]}`,
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum: request.referenceImages[0].slice(0, 32)
|
||||
}] : request.referenceImages ? request.referenceImages.map((img, index) => ({
|
||||
sourceAssets: request.referenceImages ? request.referenceImages.map((img, index) => ({
|
||||
id: generateId(),
|
||||
type: 'original' as const,
|
||||
url: `data:image/png;base64,${img}`,
|
||||
@@ -77,6 +71,7 @@ export const useImageGeneration = () => {
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('生成失败:', error);
|
||||
addToast('图像生成失败,请重试', 'error');
|
||||
setIsGenerating(false);
|
||||
}
|
||||
});
|
||||
@@ -99,8 +94,11 @@ export const useImageEditing = () => {
|
||||
selectedGenerationId,
|
||||
currentProject,
|
||||
seed,
|
||||
temperature
|
||||
temperature,
|
||||
uploadedImages
|
||||
} = useAppStore();
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (instruction: string) => {
|
||||
@@ -174,12 +172,12 @@ export const useImageEditing = () => {
|
||||
// 绘制带透明度的遮罩叠加
|
||||
maskedCtx.globalCompositeOperation = 'source-over';
|
||||
maskedCtx.globalAlpha = 0.4;
|
||||
maskedCtx.fillStyle = '#A855F7';
|
||||
maskedCtx.fillStyle = '#A855F7';
|
||||
|
||||
brushStrokes.forEach(stroke => {
|
||||
if (stroke.points.length >= 4) {
|
||||
maskedCtx.lineWidth = stroke.brushSize;
|
||||
maskedCtx.strokeStyle = '#A855F7';
|
||||
maskedCtx.strokeStyle = '#A855F7';
|
||||
maskedCtx.lineCap = 'round';
|
||||
maskedCtx.lineJoin = 'round';
|
||||
maskedCtx.beginPath();
|
||||
@@ -262,6 +260,7 @@ export const useImageEditing = () => {
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('编辑失败:', error);
|
||||
addToast('图像编辑失败,请重试', 'error');
|
||||
setIsGenerating(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,27 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Toast animations */
|
||||
@keyframes slide-in-from-top-full {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation-duration: 300ms;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.slide-in-from-top-full {
|
||||
animation-name: slide-in-from-top-full;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -47,17 +47,32 @@ export class GeminiService {
|
||||
contents,
|
||||
});
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0];
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。');
|
||||
}
|
||||
}
|
||||
|
||||
const images: string[] = [];
|
||||
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
images.push(part.inlineData.data);
|
||||
// 检查响应是否存在以及是否有内容
|
||||
if (response.candidates && response.candidates.length > 0 &&
|
||||
response.candidates[0].content && response.candidates[0].content.parts) {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
images.push(part.inlineData.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
} catch (error) {
|
||||
console.error('生成图像时出错:', error);
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('生成图像失败。请重试。');
|
||||
}
|
||||
}
|
||||
@@ -100,17 +115,32 @@ export class GeminiService {
|
||||
contents,
|
||||
});
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0];
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。');
|
||||
}
|
||||
}
|
||||
|
||||
const images: string[] = [];
|
||||
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
images.push(part.inlineData.data);
|
||||
// 检查响应是否存在以及是否有内容
|
||||
if (response.candidates && response.candidates.length > 0 &&
|
||||
response.candidates[0].content && response.candidates[0].content.parts) {
|
||||
for (const part of response.candidates[0].content.parts) {
|
||||
if (part.inlineData) {
|
||||
images.push(part.inlineData.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
} catch (error) {
|
||||
console.error('编辑图像时出错:', error);
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('编辑图像失败。请重试。');
|
||||
}
|
||||
}
|
||||
@@ -145,10 +175,21 @@ export class GeminiService {
|
||||
contents: prompt,
|
||||
});
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0];
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。');
|
||||
}
|
||||
}
|
||||
|
||||
const responseText = response.candidates[0].content.parts[0].text;
|
||||
return JSON.parse(responseText);
|
||||
} catch (error) {
|
||||
console.error('分割图像时出错:', error);
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('分割图像失败。请重试。');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user