You've already forked Nano-Banana-AI-Image-Editor
功能性整合
This commit is contained in:
26
jest.config.ts
Normal file
26
jest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'jsdom',
|
||||
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
|
||||
moduleNameMapper: {
|
||||
'\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
'^@/(.*)$': '<rootDir>/src/$1'
|
||||
},
|
||||
testMatch: [
|
||||
'<rootDir>/src/__tests__/**/*.{ts,tsx}',
|
||||
'<rootDir>/src/**/*.{spec,test}.{ts,tsx}'
|
||||
],
|
||||
transform: {
|
||||
'^.+\.(ts|tsx)$': 'ts-jest'
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/main.tsx',
|
||||
'!src/vite-env.d.ts'
|
||||
]
|
||||
};
|
||||
|
||||
export default config;
|
||||
5644
package-lock.json
generated
5644
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@@ -12,7 +12,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.16.0",
|
||||
@@ -36,6 +38,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -44,8 +50,11 @@
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest-environment-jsdom": "^30.1.2",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.4.3",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
|
||||
68
src/App.tsx
68
src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cn } from './utils/cn';
|
||||
import { Header } from './components/Header';
|
||||
@@ -22,7 +22,10 @@ const queryClient = new QueryClient({
|
||||
function AppContent() {
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const { showPromptPanel, setShowPromptPanel, setShowHistory } = useAppStore();
|
||||
const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore();
|
||||
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null);
|
||||
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
|
||||
|
||||
// 在挂载时初始化IndexedDB并清理base64数据
|
||||
useEffect(() => {
|
||||
@@ -72,15 +75,28 @@ function AppContent() {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 控制预览窗口的显示和隐藏动画
|
||||
useEffect(() => {
|
||||
if (hoveredImage) {
|
||||
// 延迟一小段时间后设置为可见,以触发动画
|
||||
const timer = setTimeout(() => {
|
||||
setIsPreviewVisible(true);
|
||||
}, 10);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsPreviewVisible(false);
|
||||
}
|
||||
}, [hoveredImage]);
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
|
||||
<div className="card card-lg rounded-none">
|
||||
<Header />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden p-4 gap-4">
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out", !showPromptPanel && "w-8")}>
|
||||
<div className="h-full card card-lg">
|
||||
<div className="flex-1 flex overflow-hidden p-4 gap-4 relative">
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showPromptPanel ? "w-72" : "w-8")}>
|
||||
<div className={cn("h-full", showPromptPanel ? "card card-lg" : "")}>
|
||||
<PromptComposer />
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,12 +105,48 @@ function AppContent() {
|
||||
<ImageCanvas />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-full card card-lg">
|
||||
<HistoryPanel />
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showHistory ? "w-72" : "w-8")}>
|
||||
<div className={cn("h-full", showHistory ? "card card-lg" : "")}>
|
||||
<HistoryPanel setHoveredImage={setHoveredImage} setPreviewPosition={setPreviewPosition} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 悬浮预览 */}
|
||||
{hoveredImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl border border-gray-300 overflow-hidden max-w-2xl max-h-[80vh] flex flex-col transition-all duration-200 ease-out"
|
||||
style={{
|
||||
transform: isPreviewVisible ? 'scale(1)' : 'scale(0.8)',
|
||||
opacity: isPreviewVisible ? 1 : 0,
|
||||
transformOrigin: previewPosition ? `${previewPosition.x}px ${previewPosition.y}px` : 'center'
|
||||
}}
|
||||
>
|
||||
<div className="bg-gray-900 text-white text-sm p-3 truncate font-medium">
|
||||
{hoveredImage.title}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<img
|
||||
src={hoveredImage.url}
|
||||
alt="预览"
|
||||
className="max-w-full max-h-[60vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* 图像信息 */}
|
||||
<div className="p-3 bg-gray-50 border-t border-gray-200 text-sm">
|
||||
{hoveredImage.width && hoveredImage.height && (
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>尺寸:</span>
|
||||
<span className="text-gray-800 font-medium">{hoveredImage.width} × {hoveredImage.height}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Button } from './ui/Button';
|
||||
import { History, Download, Image as ImageIcon, Trash2 } from 'lucide-react';
|
||||
@@ -9,7 +9,10 @@ import { useIndexedDBListener } from '../hooks/useIndexedDBListener';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import zhCN from 'react-day-picker/dist/locale/zh-CN';
|
||||
|
||||
export const HistoryPanel: React.FC = () => {
|
||||
export const HistoryPanel: React.FC<{
|
||||
setHoveredImage: (image: {url: string, title: string, width?: number, height?: number} | null) => void,
|
||||
setPreviewPosition?: (position: {x: number, y: number} | null) => void
|
||||
}> = ({ setHoveredImage, setPreviewPosition }) => {
|
||||
const {
|
||||
currentProject,
|
||||
canvasImage,
|
||||
@@ -21,14 +24,15 @@ export const HistoryPanel: React.FC = () => {
|
||||
setShowHistory,
|
||||
setCanvasImage,
|
||||
selectedTool,
|
||||
deleteGeneration,
|
||||
deleteEdit,
|
||||
deleteGenerations,
|
||||
deleteEdits
|
||||
removeGeneration,
|
||||
removeEdit
|
||||
} = useAppStore();
|
||||
|
||||
const { getBlob } = useAppStore.getState();
|
||||
|
||||
// 使用自定义hook获取IndexedDB记录
|
||||
const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener();
|
||||
|
||||
const [previewModal, setPreviewModal] = React.useState<{
|
||||
open: boolean;
|
||||
imageUrl: string;
|
||||
@@ -57,17 +61,19 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 存储从Blob URL解码的图像数据
|
||||
const [decodedImages, setDecodedImages] = useState<Record<string, string>>({});
|
||||
|
||||
// 使用自定义hook获取IndexedDB记录
|
||||
const { generations: dbGenerations, edits: dbEdits, loading, error, refresh } = useIndexedDBListener();
|
||||
|
||||
// 跟踪当前悬停的记录
|
||||
const [hoveredRecord, setHoveredRecord] = useState<{type: 'generation' | 'edit', id: string} | null>(null);
|
||||
|
||||
// 筛选和搜索状态
|
||||
const [startDate, setStartDate] = useState<string>(() => {
|
||||
// 初始化时默认显示今天的记录
|
||||
const today = new Date();
|
||||
return today.toISOString().split('T')[0]; // 默认为今天
|
||||
return today.toISOString().split('T')[0];
|
||||
});
|
||||
const [endDate, setEndDate] = useState<string>(() => {
|
||||
// 初始化时默认显示今天的记录
|
||||
const today = new Date();
|
||||
return today.toISOString().split('T')[0]; // 默认为今天
|
||||
return today.toISOString().split('T')[0];
|
||||
});
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [showDatePicker, setShowDatePicker] = useState(false); // 控制日期选择器的显示
|
||||
@@ -80,12 +86,110 @@ export const HistoryPanel: React.FC = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20; // 减少每页显示的项目数
|
||||
|
||||
// 悬浮预览状态
|
||||
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null);
|
||||
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number}>({x: 0, y: 0});
|
||||
// 获取当前图像尺寸
|
||||
const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 当currentProject为空时,使用dbGenerations和dbEdits作为备选
|
||||
const displayGenerations = currentProject ? currentProject.generations : dbGenerations;
|
||||
const displayEdits = currentProject ? currentProject.edits : dbEdits;
|
||||
|
||||
const generations = currentProject?.generations || [];
|
||||
const edits = currentProject?.edits || [];
|
||||
// 筛选记录的函数
|
||||
const filterRecords = useCallback((records: any[], isGeneration: boolean) => {
|
||||
return records.filter(record => {
|
||||
// 日期筛选 - 检查记录日期是否在筛选范围内
|
||||
const recordDate = new Date(record.timestamp);
|
||||
const recordDateStr = recordDate.toISOString().split('T')[0]; // 获取日期部分 YYYY-MM-DD
|
||||
|
||||
// 检查是否在日期范围内
|
||||
if (startDate && recordDateStr < startDate) return false;
|
||||
if (endDate && recordDateStr > endDate) return false;
|
||||
|
||||
// 搜索词筛选
|
||||
if (searchTerm) {
|
||||
if (isGeneration) {
|
||||
// 生成记录按提示词搜索
|
||||
return record.prompt.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
} else {
|
||||
// 编辑记录按指令搜索
|
||||
return record.instruction.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [startDate, endDate, searchTerm]);
|
||||
|
||||
// 筛选后的记录
|
||||
const filteredGenerations = useMemo(() => filterRecords(displayGenerations, true), [displayGenerations, filterRecords]);
|
||||
const filteredEdits = useMemo(() => filterRecords(displayEdits, false), [displayEdits, filterRecords]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (canvasImage) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = canvasImage;
|
||||
} else {
|
||||
setImageDimensions(null);
|
||||
}
|
||||
}, [canvasImage]);
|
||||
|
||||
// 当项目变化时,解码Blob图像
|
||||
useEffect(() => {
|
||||
const decodeBlobImages = async () => {
|
||||
const newDecodedImages: Record<string, string> = {};
|
||||
|
||||
// 解码生成记录的输出图像
|
||||
for (const gen of displayGenerations) {
|
||||
if (Array.isArray(gen.outputAssetsBlobUrls)) {
|
||||
for (const blobUrl of gen.outputAssetsBlobUrls) {
|
||||
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
|
||||
const blob = getBlob(blobUrl);
|
||||
if (blob) {
|
||||
// 使用Promise来处理FileReader的异步操作
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
newDecodedImages[blobUrl] = dataUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解码编辑记录的输出图像
|
||||
for (const edit of displayEdits) {
|
||||
if (Array.isArray(edit.outputAssetsBlobUrls)) {
|
||||
for (const blobUrl of edit.outputAssetsBlobUrls) {
|
||||
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
|
||||
const blob = getBlob(blobUrl);
|
||||
if (blob) {
|
||||
// 使用Promise来处理FileReader的异步操作
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
newDecodedImages[blobUrl] = dataUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newDecodedImages).length > 0) {
|
||||
setDecodedImages(prev => ({ ...prev, ...newDecodedImages }));
|
||||
}
|
||||
};
|
||||
|
||||
// 将异步操作包装在立即执行的函数中
|
||||
(async () => {
|
||||
await decodeBlobImages();
|
||||
})();
|
||||
}, [displayGenerations, displayEdits, getBlob, decodedImages]);
|
||||
|
||||
// 获取上传后的图片链接
|
||||
const getUploadedImageUrl = (generationOrEdit: any, index: number) => {
|
||||
@@ -99,22 +203,33 @@ export const HistoryPanel: React.FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取当前图像尺寸
|
||||
const [imageDimensions, setImageDimensions] = React.useState<{ width: number; height: number } | null>(null);
|
||||
// 加载和错误状态显示
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-gray-200 p-6 flex flex-col h-full">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<History className="h-5 w-5 text-gray-400" />
|
||||
<h3 className="text-sm font-medium text-gray-300">历史记录和变体</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowHistory(!showHistory)}
|
||||
className="h-6 w-6 rounded-full card"
|
||||
title="隐藏历史面板"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-yellow-400 mx-auto mb-4"></div>
|
||||
<p className="text-sm">正在加载历史记录...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (canvasImage) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setImageDimensions({ width: img.width, height: img.height });
|
||||
};
|
||||
img.src = canvasImage;
|
||||
} else {
|
||||
setImageDimensions(null);
|
||||
}
|
||||
}, [canvasImage]);
|
||||
|
||||
// 错误处理显示
|
||||
if (error) {
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-gray-200 p-6 flex flex-col h-full">
|
||||
@@ -147,86 +262,6 @@ export const HistoryPanel: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 筛选记录的函数
|
||||
const filterRecords = (records: any[], isGeneration: boolean) => {
|
||||
return records.filter(record => {
|
||||
// 日期筛选 - 修复日期比较逻辑
|
||||
const recordDate = new Date(record.timestamp);
|
||||
const recordDateStr = recordDate.toISOString().split('T')[0]; // 获取日期部分 YYYY-MM-DD
|
||||
|
||||
if (startDate && recordDateStr < startDate) return false;
|
||||
if (endDate && recordDateStr > endDate) return false;
|
||||
|
||||
// 搜索词筛选
|
||||
if (searchTerm) {
|
||||
if (isGeneration) {
|
||||
// 生成记录按提示词搜索
|
||||
return record.prompt.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
} else {
|
||||
// 编辑记录按指令搜索
|
||||
return record.instruction.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
// 筛选后的记录
|
||||
const filteredGenerations = filterRecords(dbGenerations, true);
|
||||
const filteredEdits = filterRecords(dbEdits, false);
|
||||
|
||||
// 当项目变化时,解码Blob图像
|
||||
useEffect(() => {
|
||||
const decodeBlobImages = async () => {
|
||||
const newDecodedImages: Record<string, string> = {};
|
||||
|
||||
// 解码生成记录的输出图像
|
||||
for (const gen of generations) {
|
||||
if (Array.isArray(gen.outputAssetsBlobUrls)) {
|
||||
for (const blobUrl of gen.outputAssetsBlobUrls) {
|
||||
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
|
||||
const blob = getBlob(blobUrl);
|
||||
if (blob) {
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
newDecodedImages[blobUrl] = dataUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解码编辑记录的输出图像
|
||||
for (const edit of edits) {
|
||||
if (Array.isArray(edit.outputAssetsBlobUrls)) {
|
||||
for (const blobUrl of edit.outputAssetsBlobUrls) {
|
||||
if (!decodedImages[blobUrl] && blobUrl.startsWith('blob:')) {
|
||||
const blob = getBlob(blobUrl);
|
||||
if (blob) {
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
newDecodedImages[blobUrl] = dataUrl;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(newDecodedImages).length > 0) {
|
||||
setDecodedImages(prev => ({ ...prev, ...newDecodedImages }));
|
||||
}
|
||||
};
|
||||
|
||||
decodeBlobImages();
|
||||
}, [generations, edits, getBlob, decodedImages]);
|
||||
|
||||
if (!showHistory) {
|
||||
return (
|
||||
@@ -304,7 +339,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
`${dateRange.from.toLocaleDateString()} - ${dateRange.to.toLocaleDateString()}`
|
||||
)
|
||||
) : (
|
||||
"选择日期范围"
|
||||
"今天"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -475,6 +510,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
size="sm"
|
||||
className="text-xs p-1.5 rounded-l-none h-7 border-gray-200 text-gray-600 hover:bg-gray-100 card"
|
||||
onClick={() => {
|
||||
// 重置为显示今天的记录
|
||||
const today = new Date(new Date().setHours(0, 0, 0, 0));
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
setStartDate(todayStr);
|
||||
@@ -533,13 +569,21 @@ export const HistoryPanel: React.FC = () => {
|
||||
{/* 显示生成记录 */}
|
||||
{(() => {
|
||||
const sortedGenerations = [...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp);
|
||||
// 合并所有记录并排序,然后分页
|
||||
const allRecords = [...sortedGenerations, ...[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp)]
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedGenerations = sortedGenerations.slice(startIndex, endIndex);
|
||||
const paginatedRecords = allRecords.slice(startIndex, endIndex);
|
||||
|
||||
// 只显示当前页的生成记录
|
||||
const paginatedGenerations = paginatedRecords.filter(record =>
|
||||
sortedGenerations.some(gen => gen.id === record.id)
|
||||
);
|
||||
|
||||
return paginatedGenerations.map((generation, index) => {
|
||||
// 计算全局索引用于显示编号
|
||||
const globalIndex = startIndex + index;
|
||||
const globalIndex = allRecords.findIndex(record => record.id === generation.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -561,6 +605,9 @@ export const HistoryPanel: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
// 设置当前悬停的记录
|
||||
setHoveredRecord({type: 'generation', id: generation.id});
|
||||
|
||||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||||
let imageUrl = getUploadedImageUrl(generation, 0);
|
||||
if (!imageUrl && generation.outputAssets && generation.outputAssets.length > 0) {
|
||||
@@ -576,49 +623,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
width: img.width,
|
||||
height: img.height
|
||||
});
|
||||
|
||||
// 计算预览位置,确保不超出屏幕边界
|
||||
const previewWidth = 300; // 减小预览窗口大小
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
|
||||
// 获取HistoryPanel的位置
|
||||
const historyPanel = document.querySelector('.w-72.bg-white.p-4');
|
||||
const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
|
||||
// 计算相对于HistoryPanel的位置
|
||||
let x = e.clientX - panelRect.left + offsetX;
|
||||
let y = e.clientY - panelRect.top + offsetY;
|
||||
|
||||
|
||||
|
||||
// 确保预览窗口不会超出右边界
|
||||
if (x + previewWidth > window.innerWidth) {
|
||||
x = window.innerWidth - previewWidth - 10;
|
||||
// 传递鼠标位置信息给App组件
|
||||
if (setPreviewPosition) {
|
||||
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出下边界
|
||||
if (y + previewHeight > window.innerHeight) {
|
||||
y = window.innerHeight - previewHeight - 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出左边界
|
||||
if (x < 0) {
|
||||
x = 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出上边界
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
// 添加额外的安全边界检查
|
||||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||||
|
||||
setPreviewPosition({x, y});
|
||||
};
|
||||
img.onerror = (error) => {
|
||||
console.error('图像加载失败:', error);
|
||||
@@ -629,92 +637,25 @@ export const HistoryPanel: React.FC = () => {
|
||||
width: 0,
|
||||
height: 0
|
||||
});
|
||||
|
||||
// 计算预览位置
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
|
||||
let x = e.clientX + offsetX;
|
||||
let y = e.clientY + offsetY;
|
||||
|
||||
// 确保预览窗口不会超出右边界
|
||||
if (x + previewWidth > window.innerWidth) {
|
||||
x = window.innerWidth - previewWidth - 10;
|
||||
// 传递鼠标位置信息给App组件
|
||||
if (setPreviewPosition) {
|
||||
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出下边界
|
||||
if (y + previewHeight > window.innerHeight) {
|
||||
y = window.innerHeight - previewHeight - 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出左边界
|
||||
if (x < 0) {
|
||||
x = 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出上边界
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
// 添加额外的安全边界检查
|
||||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||||
|
||||
setPreviewPosition({x, y});
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// 调整预览位置以避免被遮挡
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
|
||||
// 获取HistoryPanel的位置
|
||||
const historyPanel = document.querySelector('.w-72.bg-white.p-4');
|
||||
const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
|
||||
// 计算相对于HistoryPanel的位置
|
||||
let x = e.clientX - panelRect.left + offsetX;
|
||||
let y = e.clientY - panelRect.top + offsetY;
|
||||
|
||||
// 确保预览窗口不会超出右边界
|
||||
if (x + previewWidth > (historyPanel ? historyPanel.clientWidth : window.innerWidth)) {
|
||||
x = (historyPanel ? historyPanel.clientWidth : window.innerWidth) - previewWidth - 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出下边界
|
||||
if (y + previewHeight > (historyPanel ? historyPanel.clientHeight : window.innerHeight)) {
|
||||
y = (historyPanel ? historyPanel.clientHeight : window.innerHeight) - previewHeight - 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出左边界
|
||||
if (x < 0) {
|
||||
x = 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出上边界
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
// 添加额外的安全边界检查
|
||||
const maxWidth = historyPanel ? historyPanel.clientWidth : window.innerWidth;
|
||||
const maxHeight = historyPanel ? historyPanel.clientHeight : window.innerHeight;
|
||||
x = Math.max(10, Math.min(x, maxWidth - previewWidth - 10));
|
||||
y = Math.max(10, Math.min(y, maxHeight - previewHeight - 10));
|
||||
|
||||
setPreviewPosition({x, y});
|
||||
onMouseMove={() => {
|
||||
// 不需要处理鼠标移动事件,因为预览现在在页面中心显示
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// 清除当前悬停的记录
|
||||
setHoveredRecord(null);
|
||||
|
||||
setHoveredImage(null);
|
||||
if (setPreviewPosition) {
|
||||
setPreviewPosition(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
@@ -738,22 +679,56 @@ export const HistoryPanel: React.FC = () => {
|
||||
G{globalIndex + 1}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="absolute top-1 right-1 bg-red-500/80 text-white p-1 rounded-full hover:bg-red-600 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteConfirm({
|
||||
open: true,
|
||||
ids: [generation.id],
|
||||
type: 'generation',
|
||||
count: 1
|
||||
});
|
||||
}}
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
{/* 悬停时显示的按钮 */}
|
||||
{hoveredRecord && hoveredRecord.type === 'generation' && hoveredRecord.id === generation.id && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center gap-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 下载图像
|
||||
const imageUrl = getUploadedImageUrl(generation, 0) ||
|
||||
(generation.outputAssets && generation.outputAssets.length > 0 && generation.outputAssets[0].url ? generation.outputAssets[0].url : null);
|
||||
if (imageUrl) {
|
||||
// 使用Promise来处理异步操作
|
||||
fetch(imageUrl)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像失败:', error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
title="下载图像"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-red-500/90 hover:bg-red-500 text-white rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 删除记录
|
||||
removeGeneration(generation.id);
|
||||
}}
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -762,16 +737,21 @@ export const HistoryPanel: React.FC = () => {
|
||||
{/* 显示编辑记录 */}
|
||||
{(() => {
|
||||
const sortedEdits = [...filteredEdits].sort((a, b) => b.timestamp - a.timestamp);
|
||||
// 使用之前计算的相同记录列表以确保编号一致
|
||||
const allRecords = [...[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp), ...sortedEdits]
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedEdits = sortedEdits.slice(startIndex, endIndex);
|
||||
const paginatedRecords = allRecords.slice(startIndex, endIndex);
|
||||
|
||||
// 计算生成记录的数量,用于编辑记录的编号
|
||||
const generationCount = [...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp).length;
|
||||
// 只显示当前页的编辑记录
|
||||
const paginatedEdits = paginatedRecords.filter(record =>
|
||||
sortedEdits.some(edit => edit.id === record.id)
|
||||
);
|
||||
|
||||
return paginatedEdits.map((edit, index) => {
|
||||
// 计算全局索引用于显示编号
|
||||
const globalIndex = startIndex + index;
|
||||
const globalIndex = allRecords.findIndex(record => record.id === edit.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -794,6 +774,9 @@ export const HistoryPanel: React.FC = () => {
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
// 设置当前悬停的记录
|
||||
setHoveredRecord({type: 'edit', id: edit.id});
|
||||
|
||||
// 优先使用上传后的远程链接,如果没有则使用原始链接
|
||||
let imageUrl = getUploadedImageUrl(edit, 0);
|
||||
if (!imageUrl && edit.outputAssets && edit.outputAssets.length > 0) {
|
||||
@@ -810,42 +793,10 @@ export const HistoryPanel: React.FC = () => {
|
||||
width: img.width,
|
||||
height: img.height
|
||||
});
|
||||
|
||||
// 计算预览位置,确保不超出屏幕边界
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
|
||||
let x = e.clientX + offsetX;
|
||||
let y = e.clientY + offsetY;
|
||||
|
||||
// 确保预览窗口不会超出右边界
|
||||
if (x + previewWidth > window.innerWidth) {
|
||||
x = window.innerWidth - previewWidth - 10;
|
||||
// 传递鼠标位置信息给App组件
|
||||
if (setPreviewPosition) {
|
||||
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出下边界
|
||||
if (y + previewHeight > window.innerHeight) {
|
||||
y = window.innerHeight - previewHeight - 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出左边界
|
||||
if (x < 0) {
|
||||
x = 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出上边界
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
// 添加额外的安全边界检查
|
||||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||||
|
||||
setPreviewPosition({x, y});
|
||||
};
|
||||
img.onerror = (error) => {
|
||||
console.error('图像加载失败:', error);
|
||||
@@ -856,88 +807,25 @@ export const HistoryPanel: React.FC = () => {
|
||||
width: 0,
|
||||
height: 0
|
||||
});
|
||||
|
||||
// 计算预览位置
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
// 获取HistoryPanel的位置信息
|
||||
const historyPanel = e.currentTarget.closest('.w-72');
|
||||
const panelRect = historyPanel ? historyPanel.getBoundingClientRect() : { left: 0, top: 0 };
|
||||
|
||||
// 计算相对于整个视窗的位置
|
||||
let x = e.clientX + offsetX;
|
||||
let y = e.clientY + offsetY;
|
||||
|
||||
// 确保预览窗口不会超出右边界
|
||||
if (x + previewWidth > window.innerWidth) {
|
||||
x = window.innerWidth - previewWidth - 10;
|
||||
// 传递鼠标位置信息给App组件
|
||||
if (setPreviewPosition) {
|
||||
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出下边界
|
||||
if (y + previewHeight > window.innerHeight) {
|
||||
y = window.innerHeight - previewHeight - 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出左边界
|
||||
if (x < 0) {
|
||||
x = 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出上边界
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
// 添加额外的安全边界检查
|
||||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||||
|
||||
setPreviewPosition({x, y});
|
||||
};
|
||||
img.src = imageUrl;
|
||||
}
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
// 调整预览位置以避免被遮挡
|
||||
const previewWidth = 300;
|
||||
const previewHeight = 300;
|
||||
const offsetX = 10;
|
||||
const offsetY = 10;
|
||||
|
||||
let x = e.clientX + offsetX;
|
||||
let y = e.clientY + offsetY;
|
||||
|
||||
// 确保预览窗口不会超出右边界
|
||||
if (x + previewWidth > window.innerWidth) {
|
||||
x = window.innerWidth - previewWidth - 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出下边界
|
||||
if (y + previewHeight > window.innerHeight) {
|
||||
y = window.innerHeight - previewHeight - 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出左边界
|
||||
if (x < 0) {
|
||||
x = 10;
|
||||
}
|
||||
|
||||
// 确保预览窗口不会超出上边界
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
// 添加额外的安全边界检查
|
||||
x = Math.max(10, Math.min(x, window.innerWidth - previewWidth - 10));
|
||||
y = Math.max(10, Math.min(y, window.innerHeight - previewHeight - 10));
|
||||
|
||||
setPreviewPosition({x, y});
|
||||
onMouseMove={() => {
|
||||
// 不需要处理鼠标移动事件,因为预览现在在页面中心显示
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
// 清除当前悬停的记录
|
||||
setHoveredRecord(null);
|
||||
|
||||
setHoveredImage(null);
|
||||
if (setPreviewPosition) {
|
||||
setPreviewPosition(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
@@ -961,22 +849,56 @@ export const HistoryPanel: React.FC = () => {
|
||||
E{globalIndex + 1}
|
||||
</div>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<button
|
||||
className="absolute top-1 right-1 bg-red-500/80 text-white p-1 rounded-full hover:bg-red-600 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteConfirm({
|
||||
open: true,
|
||||
ids: [edit.id],
|
||||
type: 'edit',
|
||||
count: 1
|
||||
});
|
||||
}}
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
{/* 悬停时显示的按钮 */}
|
||||
{hoveredRecord && hoveredRecord.type === 'edit' && hoveredRecord.id === edit.id && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center gap-2 opacity-0 hover:opacity-100 transition-opacity duration-200">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-white/90 hover:bg-white text-gray-700 rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 下载图像
|
||||
const imageUrl = getUploadedImageUrl(edit, 0) ||
|
||||
(edit.outputAssets && edit.outputAssets.length > 0 && edit.outputAssets[0].url ? edit.outputAssets[0].url : null);
|
||||
if (imageUrl) {
|
||||
// 使用Promise来处理异步操作
|
||||
fetch(imageUrl)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像失败:', error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
title="下载图像"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 bg-red-500/90 hover:bg-red-500 text-white rounded-full shadow-md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 删除记录
|
||||
removeEdit(edit.id);
|
||||
}}
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -987,7 +909,13 @@ export const HistoryPanel: React.FC = () => {
|
||||
|
||||
{/* 分页控件 */}
|
||||
{(() => {
|
||||
const totalItems = filteredGenerations.length + filteredEdits.length;
|
||||
// 合并所有记录并排序,然后计算分页
|
||||
const allRecords = [
|
||||
...[...filteredGenerations].sort((a, b) => b.timestamp - a.timestamp),
|
||||
...[...filteredEdits].sort((a, b) => b.timestamp - a.timestamp)
|
||||
].sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
const totalItems = allRecords.length;
|
||||
const totalPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
// 只在有多页时显示分页控件
|
||||
@@ -1143,7 +1071,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
// 获取上传后的远程链接(如果存在)
|
||||
// 参考图像在uploadResults中从索引outputAssets.length开始
|
||||
// 但由于gen可能是轻量级记录,我们需要从dbGenerations中获取完整的记录
|
||||
const fullGen = dbGenerations.find(g => g.id === gen.id) || gen;
|
||||
const fullGen = dbGenerations.find(item => item.id === gen.id) || gen;
|
||||
const outputAssetsCount = fullGen.outputAssets?.length || 0;
|
||||
|
||||
const uploadedUrl = gen.uploadResults && gen.uploadResults[outputAssetsCount + index] && gen.uploadResults[outputAssetsCount + index].success
|
||||
@@ -1185,7 +1113,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
} else if (selectedEdit) {
|
||||
const parentGen = filteredGenerations.find(g => g.id === selectedEdit.parentGenerationId) || dbGenerations.find(g => g.id === selectedEdit.parentGenerationId);
|
||||
const parentGen = filteredGenerations.find(item => item.id === selectedEdit.parentGenerationId) || dbGenerations.find(item => item.id === selectedEdit.parentGenerationId);
|
||||
return (
|
||||
<div className="space-y-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="space-y-2.5 text-xs text-gray-700">
|
||||
@@ -1243,7 +1171,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
<div className="pt-2 border-t border-gray-200">
|
||||
<h5 className="text-xs font-medium text-gray-500 mb-2">原始生成</h5>
|
||||
<div className="text-xs text-gray-600">
|
||||
基于: G{dbGenerations.findIndex(g => g.id === parentGen.id) + 1}
|
||||
基于: G{dbGenerations.findIndex(item => item.id === parentGen.id) + 1}
|
||||
</div>
|
||||
{/* 显示原始生成的参考图像 */}
|
||||
{parentGen.sourceAssets && parentGen.sourceAssets.length > 0 && (
|
||||
@@ -1346,24 +1274,19 @@ export const HistoryPanel: React.FC = () => {
|
||||
onClick={() => {
|
||||
// 执行删除操作
|
||||
if (deleteConfirm.type === 'generation') {
|
||||
deleteConfirm.ids.forEach(id => deleteGeneration(id));
|
||||
deleteConfirm.ids.forEach(id => removeGeneration(id));
|
||||
} else if (deleteConfirm.type === 'edit') {
|
||||
deleteConfirm.ids.forEach(id => deleteEdit(id));
|
||||
deleteConfirm.ids.forEach(id => removeEdit(id));
|
||||
} else {
|
||||
// 多选删除
|
||||
const genIds = deleteConfirm.ids.filter(id =>
|
||||
filteredGenerations.some(g => g.id === id)
|
||||
);
|
||||
const editIds = deleteConfirm.ids.filter(id =>
|
||||
filteredEdits.some(e => e.id === id)
|
||||
);
|
||||
|
||||
if (genIds.length > 0) {
|
||||
deleteGenerations(genIds);
|
||||
}
|
||||
if (editIds.length > 0) {
|
||||
deleteEdits(editIds);
|
||||
}
|
||||
deleteConfirm.ids.forEach(id => {
|
||||
// 检查是生成记录还是编辑记录
|
||||
if (filteredGenerations.some(gen => gen.id === id)) {
|
||||
removeGeneration(id);
|
||||
} else if (filteredEdits.some(edit => edit.id === id)) {
|
||||
removeEdit(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
@@ -1378,36 +1301,7 @@ export const HistoryPanel: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬浮预览 */}
|
||||
{hoveredImage && (
|
||||
<div
|
||||
className="absolute z-[9999] shadow-2xl border border-gray-300 rounded-lg overflow-hidden bg-white backdrop-blur-sm pointer-events-none"
|
||||
style={{
|
||||
left: `${previewPosition.x}px`,
|
||||
top: `${previewPosition.y}px`,
|
||||
maxWidth: '200px', // 减小最大宽度
|
||||
maxHeight: '200px'
|
||||
}}
|
||||
>
|
||||
<div className="bg-gray-900 text-white text-xs p-2 truncate font-medium">
|
||||
{hoveredImage.title}
|
||||
</div>
|
||||
<img
|
||||
src={hoveredImage.url}
|
||||
alt="预览"
|
||||
className="w-full h-auto max-h-[150px] object-contain"
|
||||
/>
|
||||
{/* 图像信息 */}
|
||||
<div className="p-2 bg-gray-50 border-t border-gray-200 text-xs">
|
||||
{hoveredImage.width && hoveredImage.height && (
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>尺寸:</span>
|
||||
<span className="text-gray-800">{hoveredImage.width} × {hoveredImage.height}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration
|
||||
import { Upload, Wand2, Edit3, MousePointer, HelpCircle, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
|
||||
import { blobToBase64, urlToBlob } from '../utils/imageUtils';
|
||||
import { PromptHints } from './PromptHints';
|
||||
import { PromptSuggestions } from './PromptSuggestions';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export const PromptComposer: React.FC = () => {
|
||||
@@ -38,6 +39,7 @@ export const PromptComposer: React.FC = () => {
|
||||
const { generate, cancelGeneration } = useImageGeneration();
|
||||
const { edit, cancelEdit } = useImageEditing();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showPromptSuggestions, setShowPromptSuggestions] = useState(true);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [showHintsModal, setShowHintsModal] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
@@ -337,8 +339,8 @@ export const PromptComposer: React.FC = () => {
|
||||
selectedTool === 'generate'
|
||||
? '描述您想要创建的内容...'
|
||||
: '描述您想要的修改...'
|
||||
}
|
||||
className="min-h-[120px] resize-none text-sm rounded-xl"
|
||||
}
|
||||
className="min-h-[180px] resize-none text-sm rounded-xl"
|
||||
/>
|
||||
|
||||
{/* 提示质量指示器 */}
|
||||
@@ -386,6 +388,29 @@ export const PromptComposer: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 常用提示词 */}
|
||||
<div className="pt-3 border-t border-gray-100 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowPromptSuggestions(!showPromptSuggestions)}
|
||||
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||
>
|
||||
{showPromptSuggestions ? <ChevronDown className="h-4 w-4 mr-1.5" /> : <ChevronRight className="h-4 w-4 mr-1.5" />}
|
||||
常用提示词
|
||||
</button>
|
||||
|
||||
{showPromptSuggestions && (
|
||||
<div className="mt-4 animate-in slide-down duration-300">
|
||||
<PromptSuggestions
|
||||
onWordSelect={(word) => {
|
||||
setCurrentPrompt(currentPrompt ? `${currentPrompt}, ${word}` : word);
|
||||
}}
|
||||
minFrequency={3}
|
||||
showTitle={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高级控制 */}
|
||||
<div className="pt-3 border-t border-gray-100 flex-shrink-0">
|
||||
<button
|
||||
|
||||
@@ -11,7 +11,8 @@ interface WordFrequency {
|
||||
export const PromptSuggestions: React.FC<{
|
||||
onWordSelect?: (word: string) => void;
|
||||
minFrequency?: number;
|
||||
}> = ({ onWordSelect, minFrequency = 3 }) => {
|
||||
showTitle?: boolean;
|
||||
}> = ({ onWordSelect, minFrequency = 3, showTitle = true }) => {
|
||||
const { currentProject } = useAppStore();
|
||||
const [frequentWords, setFrequentWords] = useState<WordFrequency[]>([]);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
@@ -79,19 +80,21 @@ export const PromptSuggestions: React.FC<{
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">常用提示词</h3>
|
||||
{frequentWords.length > 20 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showTitle && (
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">常用提示词</h3>
|
||||
{frequentWords.length > 20 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => setShowAll(!showAll)}
|
||||
>
|
||||
{showAll ? '收起' : '展开'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{displayWords.map(({ word, count }) => (
|
||||
@@ -110,7 +113,7 @@ export const PromptSuggestions: React.FC<{
|
||||
))}
|
||||
</div>
|
||||
|
||||
{frequentWords.length > 20 && (
|
||||
{showTitle && frequentWords.length > 20 && (
|
||||
<div className="mt-2 text-xs text-gray-500 text-center">
|
||||
共 {frequentWords.length} 个常用提示词
|
||||
</div>
|
||||
@@ -6,7 +6,6 @@ export const useIndexedDBListener = () => {
|
||||
const [edits, setEdits] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const loadRecords = async () => {
|
||||
@@ -55,23 +54,10 @@ export const useIndexedDBListener = () => {
|
||||
|
||||
initAndLoad();
|
||||
|
||||
// 设置定时器定期检查新记录
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (isMountedRef.current) {
|
||||
loadRecords();
|
||||
}
|
||||
}, 3000); // 每3秒检查一次
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
// 标记组件已卸载
|
||||
isMountedRef.current = false;
|
||||
|
||||
// 清除定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -109,8 +109,10 @@ interface AppState {
|
||||
setTemperature: (temp: number) => void;
|
||||
setSeed: (seed: number | null) => void;
|
||||
|
||||
addGeneration: (generation: Generation) => void;
|
||||
addEdit: (edit: Edit) => void;
|
||||
addGeneration: (generation) => void;
|
||||
addEdit: (edit) => void;
|
||||
removeGeneration: (id: string) => void;
|
||||
removeEdit: (id: string) => void;
|
||||
selectGeneration: (id: string | null) => void;
|
||||
selectEdit: (id: string | null) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
@@ -119,12 +121,6 @@ interface AppState {
|
||||
|
||||
setSelectedTool: (tool: 'generate' | 'edit' | 'mask') => void;
|
||||
|
||||
// 删除历史记录
|
||||
deleteGeneration: (id: string) => void;
|
||||
deleteEdit: (id: string) => void;
|
||||
deleteGenerations: (ids: string[]) => void;
|
||||
deleteEdits: (ids: string[]) => void;
|
||||
|
||||
// Blob存储操作
|
||||
addBlob: (blob: Blob) => string;
|
||||
getBlob: (url: string) => Blob | undefined;
|
||||
@@ -651,239 +647,95 @@ export const useAppStore = create<AppState>()(
|
||||
});
|
||||
},
|
||||
|
||||
// 删除单个生成记录
|
||||
deleteGeneration: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 找到要删除的记录
|
||||
const generationToDelete = state.currentProject.generations.find(gen => gen.id === id);
|
||||
if (!generationToDelete) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
|
||||
// 收集生成记录中的Blob URLs
|
||||
generationToDelete.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
generationToDelete.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 从IndexedDB中删除记录
|
||||
indexedDBService.deleteGeneration(id).catch(err => {
|
||||
console.error('从IndexedDB删除生成记录失败:', err);
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 如果删除的是当前选中的记录,清除选择
|
||||
let selectedGenerationId = state.selectedGenerationId;
|
||||
if (selectedGenerationId === id) {
|
||||
selectedGenerationId = null;
|
||||
}
|
||||
|
||||
// 更新项目中的生成记录列表
|
||||
const updatedGenerations = state.currentProject.generations.filter(gen => gen.id !== id);
|
||||
|
||||
return {
|
||||
currentProject: {
|
||||
...state.currentProject,
|
||||
generations: updatedGenerations,
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
selectedGenerationId
|
||||
};
|
||||
}),
|
||||
|
||||
// 删除单个编辑记录
|
||||
deleteEdit: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 找到要删除的记录
|
||||
const editToDelete = state.currentProject.edits.find(edit => edit.id === id);
|
||||
if (!editToDelete) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
|
||||
// 收集编辑记录中的Blob URLs
|
||||
if (editToDelete.maskReferenceAssetBlobUrl && editToDelete.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(editToDelete.maskReferenceAssetBlobUrl);
|
||||
}
|
||||
editToDelete.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 从IndexedDB中删除记录
|
||||
indexedDBService.deleteEdit(id).catch(err => {
|
||||
console.error('从IndexedDB删除编辑记录失败:', err);
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 如果删除的是当前选中的记录,清除选择
|
||||
let selectedEditId = state.selectedEditId;
|
||||
if (selectedEditId === id) {
|
||||
selectedEditId = null;
|
||||
}
|
||||
|
||||
// 更新项目中的编辑记录列表
|
||||
const updatedEdits = state.currentProject.edits.filter(edit => edit.id !== id);
|
||||
|
||||
return {
|
||||
currentProject: {
|
||||
...state.currentProject,
|
||||
edits: updatedEdits,
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
selectedEditId
|
||||
};
|
||||
}),
|
||||
|
||||
// 批量删除生成记录
|
||||
deleteGenerations: (ids) => set((state) => {
|
||||
// 删除生成记录
|
||||
removeGeneration: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const generationToRemove = state.currentProject.generations.find(gen => gen.id === id);
|
||||
|
||||
// 收集所有要删除记录中的Blob URLs
|
||||
state.currentProject.generations.forEach(gen => {
|
||||
if (ids.includes(gen.id)) {
|
||||
gen.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
gen.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 从IndexedDB中批量删除记录
|
||||
indexedDBService.deleteGenerations(ids).catch(err => {
|
||||
console.error('从IndexedDB批量删除生成记录失败:', err);
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 如果删除的是当前选中的记录,清除选择
|
||||
let selectedGenerationId = state.selectedGenerationId;
|
||||
if (selectedGenerationId && ids.includes(selectedGenerationId)) {
|
||||
selectedGenerationId = null;
|
||||
}
|
||||
|
||||
// 更新项目中的生成记录列表
|
||||
const updatedGenerations = state.currentProject.generations.filter(gen => !ids.includes(gen.id));
|
||||
|
||||
return {
|
||||
currentProject: {
|
||||
...state.currentProject,
|
||||
generations: updatedGenerations,
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
selectedGenerationId
|
||||
};
|
||||
}),
|
||||
|
||||
// 批量删除编辑记录
|
||||
deleteEdits: (ids) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
|
||||
// 收集所有要删除记录中的Blob URLs
|
||||
state.currentProject.edits.forEach(edit => {
|
||||
if (ids.includes(edit.id)) {
|
||||
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
|
||||
if (generationToRemove) {
|
||||
// 收集要删除的生成记录中的Blob URLs
|
||||
generationToRemove.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
edit.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
generationToRemove.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 从IndexedDB中批量删除记录
|
||||
indexedDBService.deleteEdits(ids).catch(err => {
|
||||
console.error('从IndexedDB批量删除编辑记录失败:', err);
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 如果删除的是当前选中的记录,清除选择
|
||||
let selectedEditId = state.selectedEditId;
|
||||
if (selectedEditId && ids.includes(selectedEditId)) {
|
||||
selectedEditId = null;
|
||||
}
|
||||
|
||||
// 更新项目中的编辑记录列表
|
||||
const updatedEdits = state.currentProject.edits.filter(edit => !ids.includes(edit.id));
|
||||
// 从项目中移除生成记录
|
||||
const updatedProject = {
|
||||
...state.currentProject,
|
||||
generations: state.currentProject.generations.filter(gen => gen.id !== id),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
currentProject: {
|
||||
...state.currentProject,
|
||||
edits: updatedEdits,
|
||||
updatedAt: Date.now()
|
||||
},
|
||||
selectedEditId
|
||||
currentProject: updatedProject
|
||||
};
|
||||
}),
|
||||
|
||||
// 删除编辑记录
|
||||
removeEdit: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const editToRemove = state.currentProject.edits.find(edit => edit.id === id);
|
||||
|
||||
if (editToRemove) {
|
||||
// 收集要删除的编辑记录中的Blob URLs
|
||||
if (editToRemove.maskReferenceAssetBlobUrl && editToRemove.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(editToRemove.maskReferenceAssetBlobUrl);
|
||||
}
|
||||
editToRemove.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 从项目中移除编辑记录
|
||||
const updatedProject = {
|
||||
...state.currentProject,
|
||||
edits: state.currentProject.edits.filter(edit => edit.id !== id),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
})
|
||||
}),
|
||||
|
||||
15
tsconfig.test.json
Normal file
15
tsconfig.test.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true,
|
||||
"types": [
|
||||
"jest",
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"src/__tests__/**/*"
|
||||
]
|
||||
}
|
||||
164
v1/src/App.tsx
164
v1/src/App.tsx
@@ -1,164 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { cn } from './utils/cn';
|
||||
import { Header } from './components/Header';
|
||||
import { PromptComposer } from './components/PromptComposer';
|
||||
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';
|
||||
import * as indexedDBService from './services/indexedDBService';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 5 * 60 * 1000, // 5分钟
|
||||
retry: 2,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function AppContent() {
|
||||
useKeyboardShortcuts();
|
||||
|
||||
const { showPromptPanel, setShowPromptPanel, showHistory, setShowHistory } = useAppStore();
|
||||
const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null);
|
||||
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
|
||||
|
||||
// 在挂载时初始化IndexedDB并清理base64数据
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
await indexedDBService.initDB();
|
||||
// 清理已有的base64数据
|
||||
await indexedDBService.cleanupBase64Data();
|
||||
} catch (err) {
|
||||
console.error('初始化IndexedDB或清理base64数据失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
// 在挂载时设置移动设备默认值
|
||||
React.useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
if (isMobile) {
|
||||
setShowPromptPanel(false);
|
||||
setShowHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, [setShowPromptPanel, setShowHistory]);
|
||||
|
||||
// 定期清理旧的历史记录
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
useAppStore.getState().cleanupOldHistory();
|
||||
}, 30000); // 每30秒清理一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 定期清理未使用的Blob URL
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
useAppStore.getState().scheduleBlobCleanup();
|
||||
}, 60000); // 每分钟清理一次
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 控制预览窗口的显示和隐藏动画
|
||||
useEffect(() => {
|
||||
if (hoveredImage) {
|
||||
// 延迟一小段时间后设置为可见,以触发动画
|
||||
const timer = setTimeout(() => {
|
||||
setIsPreviewVisible(true);
|
||||
}, 10);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setIsPreviewVisible(false);
|
||||
}
|
||||
}, [hoveredImage]);
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
|
||||
<div className="card card-lg rounded-none">
|
||||
<Header />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden p-4 gap-4 relative">
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showPromptPanel ? "w-72" : "w-8")}>
|
||||
<div className={cn("h-full", showPromptPanel ? "card card-lg" : "")}>
|
||||
<PromptComposer />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="h-full card card-lg">
|
||||
<ImageCanvas />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showHistory ? "w-72" : "w-8")}>
|
||||
<div className={cn("h-full", showHistory ? "card card-lg" : "")}>
|
||||
<HistoryPanel setHoveredImage={setHoveredImage} setPreviewPosition={setPreviewPosition} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 悬浮预览 */}
|
||||
{hoveredImage && (
|
||||
<div
|
||||
className="fixed inset-0 z-[99999] flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-2xl border border-gray-300 overflow-hidden max-w-2xl max-h-[80vh] flex flex-col transition-all duration-200 ease-out"
|
||||
style={{
|
||||
transform: isPreviewVisible ? 'scale(1)' : 'scale(0.8)',
|
||||
opacity: isPreviewVisible ? 1 : 0,
|
||||
transformOrigin: previewPosition ? `${previewPosition.x}px ${previewPosition.y}px` : 'center'
|
||||
}}
|
||||
>
|
||||
<div className="bg-gray-900 text-white text-sm p-3 truncate font-medium">
|
||||
{hoveredImage.title}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<img
|
||||
src={hoveredImage.url}
|
||||
alt="预览"
|
||||
className="max-w-full max-h-[60vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* 图像信息 */}
|
||||
<div className="p-3 bg-gray-50 border-t border-gray-200 text-sm">
|
||||
{hoveredImage.width && hoveredImage.height && (
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>尺寸:</span>
|
||||
<span className="text-gray-800 font-medium">{hoveredImage.width} × {hoveredImage.height}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<AppContent />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -1,36 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/Button';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { InfoModal } from './InfoModal';
|
||||
|
||||
export const Header: React.FC = () => {
|
||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="h-12 bg-white flex items-center justify-between px-3 rounded-t-xl">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-yellow-50">
|
||||
<div className="text-lg">🍌</div>
|
||||
</div>
|
||||
<h1 className="text-base font-medium text-gray-800 hidden sm:block">
|
||||
Nano Banana
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowInfoModal(true)}
|
||||
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,629 +0,0 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Stage, Layer, Image as KonvaImage, Line } from 'react-konva';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Button } from './ui/Button';
|
||||
import { ZoomIn, ZoomOut, RotateCcw, Download, Eye, EyeOff, Eraser } from 'lucide-react';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export const ImageCanvas: React.FC = () => {
|
||||
const {
|
||||
canvasImage,
|
||||
canvasZoom,
|
||||
setCanvasZoom,
|
||||
canvasPan,
|
||||
setCanvasPan,
|
||||
brushStrokes,
|
||||
addBrushStroke,
|
||||
clearBrushStrokes,
|
||||
showMasks,
|
||||
setShowMasks,
|
||||
selectedTool,
|
||||
isGenerating,
|
||||
brushSize,
|
||||
setBrushSize,
|
||||
showHistory,
|
||||
showPromptPanel
|
||||
} = useAppStore();
|
||||
|
||||
const stageRef = useRef<any>(null);
|
||||
const [image, setImage] = useState<HTMLImageElement | null>(null);
|
||||
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
|
||||
const [isDrawing, setIsDrawing] = useState(false);
|
||||
const [currentStroke, setCurrentStroke] = useState<number[]>([]);
|
||||
|
||||
const handleZoom = useCallback((delta: number) => {
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
const currentZoom = stage.scaleX();
|
||||
const newZoom = Math.max(0.1, Math.min(3, currentZoom + delta));
|
||||
|
||||
// 先更新React状态以确保Konva Image组件使用正确的缩放值
|
||||
setCanvasZoom(newZoom);
|
||||
|
||||
// 使用setTimeout确保DOM已更新后再设置Stage
|
||||
setTimeout(() => {
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
// 直接通过stageRef控制Stage
|
||||
stage.scale({ x: newZoom, y: newZoom });
|
||||
stage.batchDraw();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 加载图像
|
||||
useEffect(() => {
|
||||
let img: HTMLImageElement | null = null;
|
||||
|
||||
if (canvasImage) {
|
||||
console.log('开始加载图像,URL:', canvasImage);
|
||||
|
||||
img = new window.Image();
|
||||
let isCancelled = false;
|
||||
|
||||
img.onload = () => {
|
||||
// 检查是否已取消
|
||||
if (isCancelled) {
|
||||
console.log('图像加载被取消');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('图像加载成功,尺寸:', img.width, 'x', img.height);
|
||||
setImage(img);
|
||||
|
||||
// 只在图像首次加载时自动适应画布
|
||||
if (!isCancelled && img) {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const padding = isMobile ? 0.9 : 0.8;
|
||||
|
||||
const scaleX = (stageSize.width * padding) / img.width;
|
||||
const scaleY = (stageSize.height * padding) / img.height;
|
||||
|
||||
const maxZoom = isMobile ? 0.3 : 0.8;
|
||||
const optimalZoom = Math.min(scaleX, scaleY, maxZoom);
|
||||
|
||||
// 立即更新React状态以确保Konva Image组件使用正确的缩放值
|
||||
setCanvasZoom(optimalZoom);
|
||||
setCanvasPan({ x: 0, y: 0 });
|
||||
|
||||
// 使用setTimeout确保DOM已更新后再设置Stage
|
||||
setTimeout(() => {
|
||||
if (!isCancelled && img) {
|
||||
// 直接设置缩放,但保持Stage居中
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
stage.scale({ x: optimalZoom, y: optimalZoom });
|
||||
// 重置Stage位置以确保居中
|
||||
stage.position({ x: 0, y: 0 });
|
||||
stage.batchDraw();
|
||||
}
|
||||
|
||||
console.log('图像自动适应画布完成,缩放:', optimalZoom);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
if (!isCancelled) {
|
||||
console.error('图像加载失败:', error);
|
||||
console.error('图像URL:', canvasImage);
|
||||
|
||||
// 检查是否是Blob URL
|
||||
if (canvasImage.startsWith('blob:')) {
|
||||
console.log('正在检查Blob URL是否有效...');
|
||||
|
||||
// 检查Blob URL是否仍然有效
|
||||
fetch(canvasImage)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
console.error('Blob URL无法访问:', response.status, response.statusText);
|
||||
} else {
|
||||
console.log('Blob URL可以访问');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('检查Blob URL时出错:', err);
|
||||
// 尝试从AppStore重新获取Blob
|
||||
import('../store/useAppStore').then((module) => {
|
||||
const useAppStore = module.useAppStore;
|
||||
const blob = useAppStore.getState().getBlob(canvasImage);
|
||||
if (blob) {
|
||||
console.log('从AppStore找到Blob,尝试重新创建URL...');
|
||||
// 重新创建Blob URL并重试加载
|
||||
const newUrl = URL.createObjectURL(blob);
|
||||
console.log('创建新的Blob URL:', newUrl);
|
||||
// 更新canvasImage为新的URL
|
||||
useAppStore.getState().setCanvasImage(newUrl);
|
||||
} else {
|
||||
console.error('AppStore中未找到Blob');
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('导入AppStore时出错:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
img.src = canvasImage;
|
||||
} else {
|
||||
console.log('没有图像需要加载');
|
||||
// 当没有图像时,清理之前的图像对象
|
||||
if (image) {
|
||||
// 清理图像对象以释放内存
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = '';
|
||||
}
|
||||
setImage(null);
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
console.log('清理图像加载资源');
|
||||
// 取消图像加载
|
||||
if (img) {
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
// 清理图像源以释放内存
|
||||
img.src = '';
|
||||
}
|
||||
|
||||
// 清理之前的图像对象
|
||||
if (image) {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
image.src = '';
|
||||
}
|
||||
};
|
||||
}, [canvasImage]); // 只依赖canvasImage,避免其他依赖引起循环
|
||||
|
||||
// 处理舞台大小调整
|
||||
useEffect(() => {
|
||||
const updateSize = () => {
|
||||
const container = document.getElementById('canvas-container');
|
||||
if (container) {
|
||||
setStageSize({
|
||||
width: container.offsetWidth,
|
||||
height: container.offsetHeight
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
updateSize();
|
||||
window.addEventListener('resize', updateSize);
|
||||
return () => window.removeEventListener('resize', updateSize);
|
||||
}, []);
|
||||
|
||||
// 监听面板状态变化以调整画布大小
|
||||
useEffect(() => {
|
||||
// 使用 setTimeout 确保 DOM 已更新
|
||||
const timer = setTimeout(() => {
|
||||
const container = document.getElementById('canvas-container');
|
||||
if (container) {
|
||||
setStageSize({
|
||||
width: container.offsetWidth,
|
||||
height: container.offsetHeight
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [showPromptPanel, showHistory]);
|
||||
|
||||
// 处理鼠标滚轮缩放
|
||||
useEffect(() => {
|
||||
const container = document.getElementById('canvas-container');
|
||||
if (!container) return;
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
handleZoom(delta);
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheel);
|
||||
}, [canvasZoom]);
|
||||
|
||||
const handleMouseDown = (e: any) => {
|
||||
if (selectedTool !== 'mask' || !image) return;
|
||||
|
||||
setIsDrawing(true);
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getPointerPosition();
|
||||
|
||||
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
|
||||
const relativePos = stage.getRelativePointerPosition();
|
||||
|
||||
// 计算图像在舞台上的边界
|
||||
const imageX = (stageSize.width / canvasZoom - image.width) / 2;
|
||||
const imageY = (stageSize.height / canvasZoom - image.height) / 2;
|
||||
|
||||
// 转换为相对于图像的坐标
|
||||
const relativeX = relativePos.x - imageX;
|
||||
const relativeY = relativePos.y - imageY;
|
||||
|
||||
// 检查点击是否在图像边界内
|
||||
if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) {
|
||||
setCurrentStroke([relativeX, relativeY]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: any) => {
|
||||
if (!isDrawing || selectedTool !== 'mask' || !image) return;
|
||||
|
||||
const stage = e.target.getStage();
|
||||
const pos = stage.getPointerPosition();
|
||||
|
||||
// 使用 Konva 的 getRelativePointerPosition 获取准确坐标
|
||||
const relativePos = stage.getRelativePointerPosition();
|
||||
|
||||
// 计算图像在舞台上的边界
|
||||
const imageX = (stageSize.width / canvasZoom - image.width) / 2;
|
||||
const imageY = (stageSize.height / canvasZoom - image.height) / 2;
|
||||
|
||||
// 转换为相对于图像的坐标
|
||||
const relativeX = relativePos.x - imageX;
|
||||
const relativeY = relativePos.y - imageY;
|
||||
|
||||
// 检查是否在图像边界内
|
||||
if (relativeX >= 0 && relativeX <= image.width && relativeY >= 0 && relativeY <= image.height) {
|
||||
setCurrentStroke([...currentStroke, relativeX, relativeY]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDrawing || currentStroke.length < 4) {
|
||||
setIsDrawing(false);
|
||||
setCurrentStroke([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDrawing(false);
|
||||
addBrushStroke({
|
||||
id: `stroke-${Date.now()}`,
|
||||
points: currentStroke,
|
||||
brushSize,
|
||||
});
|
||||
setCurrentStroke([]);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (image) {
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const padding = isMobile ? 0.9 : 0.8;
|
||||
const scaleX = (stageSize.width * padding) / image.width;
|
||||
const scaleY = (stageSize.height * padding) / image.height;
|
||||
const maxZoom = isMobile ? 0.3 : 0.8;
|
||||
const optimalZoom = Math.min(scaleX, scaleY, maxZoom);
|
||||
|
||||
// 同时更新React状态以确保Konva Image组件使用正确的缩放值
|
||||
setCanvasZoom(optimalZoom);
|
||||
setCanvasPan({ x: 0, y: 0 });
|
||||
|
||||
// 使用setTimeout确保DOM已更新后再设置Stage
|
||||
setTimeout(() => {
|
||||
// 直接通过stageRef控制Stage
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
stage.scale({ x: optimalZoom, y: optimalZoom });
|
||||
stage.position({ x: 0, y: 0 });
|
||||
stage.batchDraw();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
// 直接下载当前画布内容
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
try {
|
||||
// 使用Konva的toDataURL方法获取画布内容
|
||||
const dataURL = stage.toDataURL();
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.href = dataURL;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
console.log('画布内容下载成功');
|
||||
} catch (error) {
|
||||
console.error('下载画布内容时出错:', error);
|
||||
|
||||
// 如果Konva下载失败,回退到下载原始图像
|
||||
if (canvasImage) {
|
||||
// 处理不同类型的URL
|
||||
if (canvasImage.startsWith('data:')) {
|
||||
// base64格式
|
||||
const link = document.createElement('a');
|
||||
link.href = canvasImage;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else if (canvasImage.startsWith('blob:')) {
|
||||
// Blob URL格式
|
||||
fetch(canvasImage)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// 清理创建的URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载Blob图像时出错:', error);
|
||||
});
|
||||
} else {
|
||||
// 普通URL格式
|
||||
fetch(canvasImage)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// 清理创建的URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像时出错:', error);
|
||||
// 如果fetch失败,尝试直接下载
|
||||
const link = document.createElement('a');
|
||||
link.href = canvasImage;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('Stage未初始化,无法下载画布内容');
|
||||
|
||||
// 回退到下载原始图像
|
||||
if (canvasImage) {
|
||||
// 处理不同类型的URL
|
||||
if (canvasImage.startsWith('data:')) {
|
||||
// base64格式
|
||||
const link = document.createElement('a');
|
||||
link.href = canvasImage;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else if (canvasImage.startsWith('blob:')) {
|
||||
// Blob URL格式
|
||||
fetch(canvasImage)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// 清理创建的URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载Blob图像时出错:', error);
|
||||
});
|
||||
} else {
|
||||
// 普通URL格式
|
||||
fetch(canvasImage)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// 清理创建的URL
|
||||
setTimeout(() => URL.revokeObjectURL(url), 100);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('下载图像时出错:', error);
|
||||
// 如果fetch失败,尝试直接下载
|
||||
const link = document.createElement('a');
|
||||
link.href = canvasImage;
|
||||
link.download = `nano-banana-${Date.now()}.png`;
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 工具栏 */}
|
||||
|
||||
|
||||
{/* 画布区域 */}
|
||||
<div
|
||||
id="canvas-container"
|
||||
className="flex-1 relative overflow-hidden bg-gray-100 rounded-lg"
|
||||
>
|
||||
{!image && !isGenerating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-0">
|
||||
<div className="text-center max-w-xs">
|
||||
<div className="text-5xl mb-3">🍌</div>
|
||||
<h2 className="text-lg font-medium text-gray-400 mb-1">
|
||||
Nano Banana AI
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{selectedTool === 'generate'
|
||||
? '在提示框中描述您想要创建的内容'
|
||||
: '上传图像开始编辑'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGenerating && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gray-900/40 z-50 backdrop-blur-sm rounded-lg animate-in fade-in duration-300">
|
||||
<div className="text-center bg-white/90 rounded-xl p-6 card-lg backdrop-blur-sm animate-in scale-in duration-200">
|
||||
<div className="animate-spin rounded-full h-10 w-10 border-2 border-yellow-400 border-t-transparent mx-auto mb-3" />
|
||||
<p className="text-gray-700 text-sm font-medium">正在创建图像...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Stage
|
||||
ref={stageRef}
|
||||
width={stageSize.width}
|
||||
height={stageSize.height}
|
||||
draggable={selectedTool !== 'mask'}
|
||||
onDragEnd={(e) => {
|
||||
// 通过stageRef直接获取和设置位置
|
||||
const stage = stageRef.current;
|
||||
if (stage) {
|
||||
const scale = stage.scaleX();
|
||||
setCanvasPan({
|
||||
x: stage.x() / scale,
|
||||
y: stage.y() / scale
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMousemove={handleMouseMove}
|
||||
onMouseup={handleMouseUp}
|
||||
style={{
|
||||
cursor: selectedTool === 'mask' ? 'crosshair' : 'default',
|
||||
zIndex: 10
|
||||
}}
|
||||
>
|
||||
<Layer>
|
||||
{image && (
|
||||
<KonvaImage
|
||||
image={image}
|
||||
x={(stageSize.width / canvasZoom - image.width) / 2}
|
||||
y={(stageSize.height / canvasZoom - image.height) / 2}
|
||||
onRender={() => {
|
||||
console.log('KonvaImage组件渲染完成');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 画笔描边 */}
|
||||
{showMasks && brushStrokes.map((stroke) => (
|
||||
<Line
|
||||
key={stroke.id}
|
||||
points={stroke.points}
|
||||
stroke="#A855F7"
|
||||
strokeWidth={stroke.brushSize}
|
||||
tension={0.5}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
globalCompositeOperation="source-over"
|
||||
opacity={0.6}
|
||||
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
|
||||
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 正在绘制的当前描边 */}
|
||||
{isDrawing && currentStroke.length > 2 && (
|
||||
<Line
|
||||
points={currentStroke}
|
||||
stroke="#A855F7"
|
||||
strokeWidth={brushSize}
|
||||
tension={0.5}
|
||||
lineCap="round"
|
||||
lineJoin="round"
|
||||
globalCompositeOperation="source-over"
|
||||
opacity={0.6}
|
||||
x={(stageSize.width / canvasZoom - (image?.width || 0)) / 2}
|
||||
y={(stageSize.height / canvasZoom - (image?.height || 0)) / 2}
|
||||
/>
|
||||
)}
|
||||
</Layer>
|
||||
</Stage>
|
||||
|
||||
{/* 悬浮操作按钮 */}
|
||||
{image && !isGenerating && (
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-white/80 backdrop-blur-sm rounded-full card border border-gray-200 px-3 py-2 flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleZoom(-0.1)}
|
||||
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500 min-w-[40px] text-center">
|
||||
{Math.round(canvasZoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleZoom(0.1)}
|
||||
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-px h-6 bg-gray-200"></div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
className="h-8 w-8 p-0 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-full card"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 状态栏 */}
|
||||
<div className="p-2 border-t border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
{brushStrokes.length > 0 && (
|
||||
<span className="text-yellow-400">{brushStrokes.length} 个描边</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<span className="text-xs text-gray-500">
|
||||
© 2025 Mark Fulton
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
interface ImagePreviewModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
imageUrl: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
imageUrl,
|
||||
title,
|
||||
description
|
||||
}) => {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50 animate-in fade-in duration-200" />
|
||||
<Dialog.Content className="fixed inset-0 m-auto w-full max-w-2xl h-fit max-h-[90vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-900">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{description && (
|
||||
<p className="text-sm text-gray-600">{description}</p>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-100 rounded-lg p-4">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={title}
|
||||
className="w-full h-auto rounded-lg border border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X, ExternalLink, Lightbulb, Download } from 'lucide-react';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
interface InfoModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const InfoModal: React.FC<InfoModalProps> = ({ open, onOpenChange }) => {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 z-50 animate-in fade-in duration-200" />
|
||||
<Dialog.Content className="fixed inset-0 m-auto w-full max-w-4xl h-fit max-h-[90vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-900">
|
||||
关于 Nano Banana AI 图像编辑器
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3 text-sm text-gray-700">
|
||||
<p>
|
||||
由{' '}
|
||||
<a
|
||||
href="https://markfulton.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-yellow-600 hover:text-yellow-700 transition-colors font-semibold"
|
||||
>
|
||||
Mark Fulton
|
||||
<ExternalLink className="h-3 w-3 inline ml-1" />
|
||||
</a>
|
||||
开发
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="p-4 bg-gradient-to-br from-purple-100 to-indigo-100 rounded-lg border border-purple-200">
|
||||
<div className="flex items-center mb-3">
|
||||
<Lightbulb className="h-5 w-5 text-purple-600 mr-2" />
|
||||
<h4 className="text-sm font-semibold text-purple-700">
|
||||
学习构建AI应用和其他解决方案
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-4">
|
||||
学习编写像这样的应用程序,掌握AI自动化,构建智能代理,并创建推动实际业务成果的前沿解决方案。
|
||||
</p>
|
||||
<a
|
||||
href="https://www.reinventing.ai/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-purple-500 to-indigo-500 hover:from-purple-600 hover:to-indigo-600 text-white rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
加入AI加速器计划
|
||||
<ExternalLink className="h-4 w-4 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gradient-to-br from-yellow-100 to-orange-100 rounded-lg border border-yellow-200">
|
||||
<div className="flex items-center mb-3">
|
||||
<Download className="h-5 w-5 text-yellow-600 mr-2" />
|
||||
<h4 className="text-sm font-semibold text-yellow-700">
|
||||
获取此应用程序的副本
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 mb-4">
|
||||
通过加入Vibe Coding is Life Skool社区获取此应用程序的副本。现场构建会话、应用程序项目、资源等,这是网络上最好的氛围编码社区。
|
||||
</p>
|
||||
<a
|
||||
href="https://www.skool.com/vibe-coding-is-life/about?ref=456537abaf37491cbcc6976f3c26af41"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center px-4 py-2 bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600 text-white rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
加入Vibe Coding is Life社区
|
||||
<ExternalLink className="h-4 w-4 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 键盘快捷键 */}
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-3">键盘快捷键</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">生成图像</span>
|
||||
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">⌘ + Enter</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">重新生成</span>
|
||||
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">⇧ + R</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">编辑模式</span>
|
||||
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">E</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">历史记录</span>
|
||||
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">H</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">切换面板</span>
|
||||
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">P</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm text-gray-600">中断生成</span>
|
||||
<span className="font-mono bg-gray-200 px-2 py-1 rounded text-xs">Esc</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
|
||||
export const MaskOverlay: React.FC = () => {
|
||||
const { selectedMask, showMasks } = useAppStore();
|
||||
|
||||
if (!showMasks || !selectedMask) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{/* Marching ants effect */}
|
||||
<div
|
||||
className="absolute border-2 border-yellow-400 animate-pulse"
|
||||
style={{
|
||||
left: selectedMask.bounds.x,
|
||||
top: selectedMask.bounds.y,
|
||||
width: selectedMask.bounds.width,
|
||||
height: selectedMask.bounds.height,
|
||||
borderStyle: 'dashed',
|
||||
animationDuration: '1s'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mask overlay */}
|
||||
<div
|
||||
className="absolute bg-yellow-400/20"
|
||||
style={{
|
||||
left: selectedMask.bounds.x,
|
||||
top: selectedMask.bounds.y,
|
||||
width: selectedMask.bounds.width,
|
||||
height: selectedMask.bounds.height,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,483 +0,0 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Textarea } from './ui/Textarea';
|
||||
import { Button } from './ui/Button';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
|
||||
import { Upload, Wand2, Edit3, MousePointer, HelpCircle, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
|
||||
import { blobToBase64, urlToBlob } from '../utils/imageUtils';
|
||||
import { PromptHints } from './PromptHints';
|
||||
import { PromptSuggestions } from './PromptSuggestions';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
export const PromptComposer: React.FC = () => {
|
||||
const {
|
||||
currentPrompt,
|
||||
setCurrentPrompt,
|
||||
selectedTool,
|
||||
setSelectedTool,
|
||||
temperature,
|
||||
setTemperature,
|
||||
seed,
|
||||
setSeed,
|
||||
isGenerating,
|
||||
uploadedImages,
|
||||
addUploadedImage,
|
||||
removeUploadedImage,
|
||||
clearUploadedImages,
|
||||
editReferenceImages,
|
||||
addEditReferenceImage,
|
||||
removeEditReferenceImage,
|
||||
clearEditReferenceImages,
|
||||
canvasImage,
|
||||
setCanvasImage,
|
||||
showPromptPanel,
|
||||
setShowPromptPanel,
|
||||
clearBrushStrokes,
|
||||
addBlob
|
||||
} = useAppStore();
|
||||
|
||||
const { generate, cancelGeneration } = useImageGeneration();
|
||||
const { edit, cancelEdit } = useImageEditing();
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
||||
const [showHintsModal, setShowHintsModal] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!currentPrompt.trim()) return;
|
||||
|
||||
if (selectedTool === 'generate') {
|
||||
// 将上传的图像转换为Blob对象
|
||||
const referenceImageBlobs: Blob[] = [];
|
||||
for (const img of uploadedImages) {
|
||||
if (img.startsWith('data:')) {
|
||||
// 从base64数据创建Blob
|
||||
const base64 = img.split('base64,')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = img.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
|
||||
} else if (img.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob
|
||||
const { getBlob } = useAppStore.getState();
|
||||
const blob = getBlob(img);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
}
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
try {
|
||||
const blob = await urlToBlob(img);
|
||||
referenceImageBlobs.push(blob);
|
||||
} catch (error) {
|
||||
console.warn('无法获取参考图像:', img, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
|
||||
edit(currentPrompt);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
try {
|
||||
// 直接使用Blob创建URL
|
||||
const blobUrl = addBlob(file);
|
||||
|
||||
if (selectedTool === 'generate') {
|
||||
// 添加到参考图像(最多2张)
|
||||
if (uploadedImages.length < 2) {
|
||||
addUploadedImage(blobUrl);
|
||||
}
|
||||
} else if (selectedTool === 'edit') {
|
||||
// 编辑模式下,添加到单独的编辑参考图像(最多2张)
|
||||
if (editReferenceImages.length < 2) {
|
||||
addEditReferenceImage(blobUrl);
|
||||
}
|
||||
// 如果没有画布图像,则设置为画布图像
|
||||
if (!canvasImage) {
|
||||
setCanvasImage(blobUrl);
|
||||
}
|
||||
} else if (selectedTool === 'mask') {
|
||||
// 遮罩模式下,立即设置为画布图像
|
||||
clearUploadedImages();
|
||||
addUploadedImage(blobUrl);
|
||||
setCanvasImage(blobUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileInputChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
await handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
await handleFileUpload(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSession = () => {
|
||||
setCurrentPrompt('');
|
||||
clearUploadedImages();
|
||||
clearEditReferenceImages();
|
||||
clearBrushStrokes();
|
||||
setCanvasImage(null);
|
||||
setSeed(null);
|
||||
setTemperature(0.7);
|
||||
setShowClearConfirm(false);
|
||||
};
|
||||
|
||||
const tools = [
|
||||
{ id: 'generate', icon: Wand2, label: '生成', description: '从文本创建' },
|
||||
{ id: 'edit', icon: Edit3, label: '编辑', description: '修改现有图像' },
|
||||
{ id: 'mask', icon: MousePointer, label: '选择', description: '点击选择' },
|
||||
] as const;
|
||||
|
||||
if (!showPromptPanel) {
|
||||
return (
|
||||
<div className="w-8 bg-white flex flex-col items-center justify-center rounded-l-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setShowPromptPanel(true)}
|
||||
className="w-6 h-16 bg-gray-100 hover:bg-gray-200 rounded-r-lg flex items-center justify-center transition-all duration-300 ease-in-out group"
|
||||
title="显示提示面板"
|
||||
>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
<div className="w-1 h-1 bg-gray-500 group-hover:bg-gray-400 rounded-full transition-colors duration-200"></div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-72 h-full bg-white p-5 flex flex-col overflow-y-auto space-y-5">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">模式</h3>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowHintsModal(true)}
|
||||
className="h-7 w-7 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPromptPanel(false)}
|
||||
className="h-7 w-7 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full card"
|
||||
title="隐藏面板"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2.5">
|
||||
{tools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => setSelectedTool(tool.id)}
|
||||
className={cn(
|
||||
'flex flex-col items-center p-3 rounded-xl border transition-all duration-200 hover:scale-105',
|
||||
selectedTool === tool.id
|
||||
? 'bg-yellow-50 border-yellow-300 text-yellow-700 shadow-sm'
|
||||
: 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-yellow-50 hover:border-yellow-300 hover:text-yellow-700'
|
||||
)}
|
||||
>
|
||||
<tool.icon className="h-5 w-5 mb-1.5" />
|
||||
<span className="text-xs font-medium">{tool.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文件上传 */}
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"border-2 border-dashed rounded-xl p-5 text-center transition-colors",
|
||||
isDragOver
|
||||
? "border-yellow-400 bg-yellow-400/10"
|
||||
: "border-gray-300 hover:border-yellow-400"
|
||||
)}
|
||||
>
|
||||
<label className="text-sm font-semibold text-gray-700 mb-2 block">
|
||||
{selectedTool === 'generate' ? '参考图像' : selectedTool === 'edit' ? '样式参考' : '上传图像'}
|
||||
</label>
|
||||
{selectedTool === 'mask' && (
|
||||
<p className="text-xs text-gray-500 mb-3">使用遮罩编辑图像</p>
|
||||
)}
|
||||
{selectedTool === 'generate' && (
|
||||
<p className="text-xs text-gray-500 mb-3">可选,最多2张图像</p>
|
||||
)}
|
||||
{selectedTool === 'edit' && (
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
{canvasImage ? '可选样式参考,最多2张图像' : '上传要编辑的图像,最多2张图像'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<Upload className={cn("h-7 w-7", isDragOver ? "text-yellow-500" : "text-gray-400")} />
|
||||
<div>
|
||||
<p className={cn(
|
||||
"text-sm font-medium",
|
||||
isDragOver ? "text-yellow-700" : "text-gray-600"
|
||||
)}>
|
||||
{isDragOver ? "释放文件以上传" : "拖拽或点击上传"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
支持 JPG, PNG, GIF
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="mt-1 card"
|
||||
disabled={
|
||||
(selectedTool === 'generate' && uploadedImages.length >= 2) ||
|
||||
(selectedTool === 'edit' && editReferenceImages.length >= 2)
|
||||
}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
||||
选择文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show uploaded images preview */}
|
||||
{((selectedTool === 'generate' && uploadedImages.length > 0) ||
|
||||
(selectedTool === 'edit' && editReferenceImages.length > 0)) && (
|
||||
<div className="space-y-2.5">
|
||||
{(selectedTool === 'generate' ? uploadedImages : editReferenceImages).map((image, index) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={image}
|
||||
alt={`参考图像 ${index + 1}`}
|
||||
className="w-full h-20 object-cover rounded-lg border-2 border-gray-200"
|
||||
/>
|
||||
<button
|
||||
onClick={() => selectedTool === 'generate' ? removeUploadedImage(index) : removeEditReferenceImage(index)}
|
||||
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1.5 transition-colors shadow-md hover:bg-red-600"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div className="absolute bottom-2 left-2 bg-black/60 text-white text-xs px-2 py-1 rounded-lg">
|
||||
参考 {index + 1}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提示输入 */}
|
||||
<div className="flex-grow space-y-3">
|
||||
<label className="text-xs font-semibold text-gray-500 block uppercase tracking-wide">
|
||||
{selectedTool === 'generate' ? '提示词' : '编辑指令'}
|
||||
</label>
|
||||
<Textarea
|
||||
value={currentPrompt}
|
||||
onChange={(e) => setCurrentPrompt(e.target.value)}
|
||||
placeholder={
|
||||
selectedTool === 'generate'
|
||||
? '描述您想要创建的内容...'
|
||||
: '描述您想要的修改...'
|
||||
}
|
||||
className="min-h-[180px] resize-none text-sm rounded-xl"
|
||||
/>
|
||||
|
||||
{/* 常用提示词 */}
|
||||
<PromptSuggestions
|
||||
onWordSelect={(word) => {
|
||||
setCurrentPrompt(currentPrompt ? `${currentPrompt}, ${word}` : word);
|
||||
}}
|
||||
minFrequency={3}
|
||||
/>
|
||||
|
||||
{/* 提示质量指示器 */}
|
||||
<button
|
||||
onClick={() => setShowHintsModal(true)}
|
||||
className="flex items-center text-xs hover:text-gray-700 transition-colors group"
|
||||
>
|
||||
{currentPrompt.length < 20 ? (
|
||||
<HelpCircle className="h-4 w-4 mr-2 text-red-400 group-hover:text-red-500" />
|
||||
) : (
|
||||
<div className={cn(
|
||||
'h-2.5 w-2.5 rounded-full mr-2',
|
||||
currentPrompt.length < 50 ? 'bg-yellow-400' : 'bg-green-400'
|
||||
)} />
|
||||
)}
|
||||
<span className="text-gray-500 group-hover:text-gray-700">
|
||||
{currentPrompt.length < 20 ? '添加更多细节' :
|
||||
currentPrompt.length < 50 ? '细节良好' : '细节优秀'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 生成按钮 */}
|
||||
<div className="flex-shrink-0">
|
||||
{isGenerating ? (
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => selectedTool === 'generate' ? cancelGeneration() : cancelEdit()}
|
||||
className="flex-1 h-14 text-base font-semibold bg-red-500 hover:bg-red-600 rounded-xl card"
|
||||
>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2" />
|
||||
中断
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleGenerate}
|
||||
disabled={!currentPrompt.trim()}
|
||||
className="w-full h-14 text-base font-semibold rounded-xl shadow-md hover:shadow-lg transition-all card"
|
||||
>
|
||||
<Wand2 className="h-5 w-5 mr-2" />
|
||||
{selectedTool === 'generate' ? '生成图像' : '应用编辑'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 高级控制 */}
|
||||
<div className="pt-3 border-t border-gray-100 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center text-sm text-gray-500 hover:text-gray-700 transition-colors duration-200"
|
||||
>
|
||||
{showAdvanced ? <ChevronDown className="h-4 w-4 mr-1.5" /> : <ChevronRight className="h-4 w-4 mr-1.5" />}
|
||||
高级选项
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="mt-4 space-y-4 animate-in slide-down duration-300">
|
||||
{/* 创造力 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-500 block flex justify-between">
|
||||
<span className="font-medium">创造力</span>
|
||||
<span className="font-mono bg-gray-100 px-2 py-0.5 rounded">{temperature}</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-gray-200 rounded-full appearance-none cursor-pointer slider"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 种子 */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-gray-500 block font-medium">
|
||||
种子 (可选)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={seed || ''}
|
||||
onChange={(e) => setSeed(e.target.value ? parseInt(e.target.value) : null)}
|
||||
placeholder="随机"
|
||||
className="w-full h-10 px-3 bg-gray-50 border border-gray-200 rounded-lg text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-yellow-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowClearConfirm(!showClearConfirm)}
|
||||
className="flex items-center text-sm text-gray-500 hover:text-red-500 transition-colors duration-200 mt-4"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
清除会话
|
||||
</button>
|
||||
|
||||
{showClearConfirm && (
|
||||
<div className="mt-3 p-4 bg-red-50 rounded-xl border border-red-100 animate-in slide-down duration-300">
|
||||
<p className="text-sm text-red-700 mb-4">
|
||||
确定要清除此会话吗?这将删除所有内容。
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleClearSession}
|
||||
className="flex-1 h-10 text-sm font-semibold card text-gray-700"
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowClearConfirm(false)}
|
||||
className="flex-1 h-10 text-sm font-semibold border-gray-300 text-gray-700 hover:bg-gray-100 card"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* 提示提示模态框 */}
|
||||
<PromptHints open={showHintsModal} onOpenChange={setShowHintsModal} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PromptComposer;
|
||||
@@ -1,87 +0,0 @@
|
||||
import React from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X } from 'lucide-react';
|
||||
import { PromptHint } from '../types';
|
||||
import { Button } from './ui/Button';
|
||||
|
||||
const promptHints: PromptHint[] = [
|
||||
{
|
||||
category: 'subject',
|
||||
text: 'Be specific about the main subject',
|
||||
example: '"A vintage red bicycle" vs "bicycle"'
|
||||
},
|
||||
{
|
||||
category: 'scene',
|
||||
text: 'Describe the environment and setting',
|
||||
example: '"in a cobblestone alley during golden hour"'
|
||||
},
|
||||
{
|
||||
category: 'action',
|
||||
text: 'Include movement or activity',
|
||||
example: '"cyclist pedaling through puddles"'
|
||||
},
|
||||
{
|
||||
category: 'style',
|
||||
text: 'Specify artistic style or mood',
|
||||
example: '"cinematic photography, moody lighting"'
|
||||
},
|
||||
{
|
||||
category: 'camera',
|
||||
text: 'Add camera perspective details',
|
||||
example: '"shot with 85mm lens, shallow depth of field"'
|
||||
}
|
||||
];
|
||||
|
||||
const categoryColors = {
|
||||
subject: 'bg-blue-500/10 border-blue-500/30 text-blue-400',
|
||||
scene: 'bg-green-500/10 border-green-500/30 text-green-400',
|
||||
action: 'bg-purple-500/10 border-purple-500/30 text-purple-400',
|
||||
style: 'bg-orange-500/10 border-orange-500/30 text-orange-400',
|
||||
camera: 'bg-pink-500/10 border-pink-500/30 text-pink-400',
|
||||
};
|
||||
|
||||
interface PromptHintsProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const PromptHints: React.FC<PromptHintsProps> = ({ open, onOpenChange }) => {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/30 z-50 animate-in fade-in duration-200" />
|
||||
<Dialog.Content className="fixed inset-0 m-auto w-full max-w-md h-fit max-h-[80vh] overflow-y-auto bg-white border border-gray-200 rounded-lg p-6 z-50 animate-in scale-in duration-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Dialog.Title className="text-lg font-semibold text-gray-900">
|
||||
提示质量技巧
|
||||
</Dialog.Title>
|
||||
<Dialog.Close asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{promptHints.map((hint, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className={`inline-block px-2 py-1 rounded text-xs border ${categoryColors[hint.category]}`}>
|
||||
{hint.category}
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{hint.text}</p>
|
||||
<p className="text-sm text-gray-500 italic">{hint.example}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-4 bg-gray-100 rounded-lg border border-gray-200 mt-6">
|
||||
<p className="text-sm text-gray-700">
|
||||
<strong className="text-yellow-600">最佳实践:</strong> 写完整的句子来描述整个场景,
|
||||
而不仅仅是关键词。想象"用文字为我画一幅画"。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '../utils/cn';
|
||||
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<ToastProps> = ({ id, message, type, details, onClose, onHoverChange }) => {
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isExiting, setIsExiting] = useState(false);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsExiting(true);
|
||||
setTimeout(() => {
|
||||
onClose(id);
|
||||
}, 300); // Match the animation duration
|
||||
};
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col rounded-lg shadow-lg min-w-[300px] max-w-md transition-all duration-300 transform',
|
||||
getTypeStyles(),
|
||||
isExiting ? 'animate-out slide-out-to-right duration-300' : 'animate-in slide-in-from-right duration-300'
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="flex items-start justify-between p-4">
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium">{message}</span>
|
||||
{details && (
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
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={handleClose}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import React, { createContext, useContext, useReducer, useState, useEffect, useRef } from 'react';
|
||||
import { Toast } from './Toast';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info';
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
duration?: number;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export interface ToastContextType {
|
||||
addToast: (message: string, type: ToastType, duration?: number, details?: string) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
type ToastAction =
|
||||
| { type: 'ADD_TOAST'; payload: ToastMessage }
|
||||
| { 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, 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;
|
||||
}
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toasts, dispatch] = useReducer(toastReducer, []);
|
||||
const hoverTimeouts = useRef<Record<string, NodeJS.Timeout>>({});
|
||||
|
||||
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, details } });
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
dispatch({ type: 'REMOVE_TOAST', payload: id });
|
||||
};
|
||||
|
||||
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 => {
|
||||
// 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.timeout);
|
||||
});
|
||||
};
|
||||
}, [toasts]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ addToast, removeToast }}>
|
||||
{children}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className="animate-in slide-in-from-right duration-300"
|
||||
>
|
||||
<Toast
|
||||
id={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
details={toast.details}
|
||||
onClose={removeToast}
|
||||
onHoverChange={(hovered) => setToastHovered(toast.id, hovered)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-lg text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-yellow-400 text-gray-900 hover:bg-yellow-300 focus-visible:ring-yellow-400',
|
||||
secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus-visible:ring-gray-300',
|
||||
outline: 'border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-100 hover:text-gray-900',
|
||||
ghost: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900',
|
||||
destructive: 'bg-red-500 text-white hover:bg-red-600 focus-visible:ring-red-400',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
lg: 'h-12 px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
const [isClicking, setIsClicking] = React.useState(false);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setIsClicking(true);
|
||||
// Reset the clicking state after the animation completes
|
||||
setTimeout(() => setIsClicking(false), 200);
|
||||
|
||||
// Call the original onClick handler if it exists
|
||||
if (props.onClick) {
|
||||
props.onClick(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }), isClicking && 'animate-pulse-click')}
|
||||
ref={ref}
|
||||
{...props}
|
||||
onClick={handleClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = 'Button';
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
@@ -1,21 +0,0 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../../utils/cn';
|
||||
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 ring-offset-white placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-yellow-400 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 resize-none',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
@@ -1,557 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { geminiService, GenerationRequest, EditRequest } from '../services/geminiService'
|
||||
import { useAppStore } from '../store/useAppStore'
|
||||
import { generateId } from '../utils/imageUtils'
|
||||
import { Generation, Edit, Asset } from '../types'
|
||||
import { useToast } from '../components/ToastContext'
|
||||
import { uploadImages } from '../services/uploadService'
|
||||
import { blobToBase64 } from '../utils/imageUtils'
|
||||
|
||||
export const useImageGeneration = () => {
|
||||
const { addGeneration, setIsGenerating, setCanvasImage } = useAppStore()
|
||||
const { addToast } = useToast()
|
||||
|
||||
// 创建中断标志引用
|
||||
const isCancelledRef = React.useRef(false)
|
||||
|
||||
const generateMutation = useMutation({
|
||||
mutationFn: async (request: GenerationRequest) => {
|
||||
// 重置中断标志
|
||||
isCancelledRef.current = false
|
||||
|
||||
// 将参考图像从base64转换为Blob(如果需要)
|
||||
let blobReferenceImages: Blob[] | undefined;
|
||||
if (request.referenceImages) {
|
||||
blobReferenceImages = [];
|
||||
for (const img of request.referenceImages) {
|
||||
if (typeof img === 'string') {
|
||||
// 如果是base64字符串,转换为Blob
|
||||
const byteString = atob(img);
|
||||
const mimeString = 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
blobReferenceImages.push(new Blob([ab], { type: mimeString }));
|
||||
} else {
|
||||
// 如果已经是Blob,直接使用
|
||||
blobReferenceImages.push(img);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blobRequest: GenerationRequest = {
|
||||
...request,
|
||||
referenceImages: blobReferenceImages
|
||||
};
|
||||
|
||||
const result = await geminiService.generateImage(blobRequest)
|
||||
|
||||
// 检查是否已中断
|
||||
if (isCancelledRef.current) {
|
||||
throw new Error('生成已中断')
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsGenerating(true)
|
||||
},
|
||||
onSuccess: async (result, request) => {
|
||||
const { images, usageMetadata } = result;
|
||||
if (images.length > 0) {
|
||||
// 直接使用Blob并创建URL,避免存储base64数据
|
||||
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
|
||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
// 生成校验和(使用Blob的一部分数据)
|
||||
const checksum = await new Promise<string>(async (resolve) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let checksum = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'output',
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: 'image/png',
|
||||
width: 1024, // 默认Gemini输出尺寸
|
||||
height: 1024,
|
||||
checksum // 使用生成的校验和
|
||||
};
|
||||
}));
|
||||
|
||||
// 获取accessToken
|
||||
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
|
||||
let uploadResults: any[] | undefined;
|
||||
|
||||
// 上传生成的图像和参考图像
|
||||
if (accessToken) {
|
||||
try {
|
||||
// 上传生成的图像(跳过缓存,因为这些是新生成的图像)
|
||||
const imageUrls = outputAssets.map(asset => asset.url);
|
||||
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
|
||||
|
||||
// 上传参考图像(如果存在,使用缓存机制)
|
||||
let referenceUploadResults: any[] = [];
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
// 将参考图像也转换为Blob URL
|
||||
const referenceUrls = await Promise.all(request.referenceImages.map(async (blob) => {
|
||||
return useAppStore.getState().addBlob(blob);
|
||||
}));
|
||||
referenceUploadResults = await uploadImages(referenceUrls, accessToken, false);
|
||||
}
|
||||
|
||||
// 合并上传结果
|
||||
uploadResults = [...outputUploadResults, ...referenceUploadResults];
|
||||
|
||||
// 检查上传结果
|
||||
const failedUploads = uploadResults.filter(r => !r.success);
|
||||
if (failedUploads.length > 0) {
|
||||
console.warn(`${failedUploads.length}张图像上传失败`);
|
||||
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000);
|
||||
} else {
|
||||
console.log(`${uploadResults.length}张图像全部上传成功`);
|
||||
addToast('图像已成功上传', 'success', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像时出错:', error);
|
||||
addToast('图像上传失败', 'error', 5000);
|
||||
uploadResults = undefined;
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到accessToken,跳过上传');
|
||||
}
|
||||
|
||||
// 显示Token消耗信息(如果可用)
|
||||
if (usageMetadata?.totalTokenCount) {
|
||||
addToast(`本次生成消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
|
||||
}
|
||||
|
||||
const generation: Generation = {
|
||||
id: generateId(),
|
||||
prompt: request.prompt,
|
||||
parameters: {
|
||||
aspectRatio: '1:1',
|
||||
seed: request.seed,
|
||||
temperature: request.temperature
|
||||
},
|
||||
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob, index) => {
|
||||
// 将参考图像转换为Blob URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
// 生成校验和(使用Blob的一部分数据)
|
||||
const checksum = await new Promise<string>(async (resolve) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let checksum = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'original' as const,
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: blob.type || 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum
|
||||
};
|
||||
})) : [],
|
||||
outputAssets,
|
||||
modelVersion: 'gemini-2.5-flash-image-preview',
|
||||
timestamp: Date.now(),
|
||||
uploadResults: uploadResults,
|
||||
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
|
||||
};
|
||||
|
||||
addGeneration(generation);
|
||||
setCanvasImage(outputAssets[0].url);
|
||||
}
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: error => {
|
||||
console.error('生成失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
const errorDetails = error instanceof Error ? error.stack : undefined
|
||||
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
||||
setIsGenerating(false)
|
||||
},
|
||||
})
|
||||
|
||||
const cancelGeneration = () => {
|
||||
isCancelledRef.current = true
|
||||
setIsGenerating(false)
|
||||
addToast('生成已中断', 'info', 3000)
|
||||
}
|
||||
|
||||
return {
|
||||
generate: generateMutation.mutate,
|
||||
isGenerating: generateMutation.isPending,
|
||||
error: generateMutation.error,
|
||||
cancelGeneration,
|
||||
}
|
||||
}
|
||||
|
||||
export const useImageEditing = () => {
|
||||
const { addEdit, setIsGenerating, setCanvasImage, canvasImage, editReferenceImages, brushStrokes, selectedGenerationId, seed, temperature, uploadedImages } = useAppStore()
|
||||
|
||||
const { addToast } = useToast()
|
||||
|
||||
// 创建中断标志引用
|
||||
const isCancelledRef = React.useRef(false)
|
||||
|
||||
const editMutation = useMutation({
|
||||
mutationFn: async (instruction: string) => {
|
||||
// 重置中断标志
|
||||
isCancelledRef.current = false
|
||||
|
||||
// 如果可用,始终使用画布图像作为主要目标,否则使用第一张上传的图像
|
||||
const sourceImage = canvasImage || uploadedImages[0]
|
||||
if (!sourceImage) throw new Error('没有要编辑的图像')
|
||||
|
||||
// 将画布图像转换为Blob
|
||||
let originalImageBlob: Blob;
|
||||
if (sourceImage.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob数据
|
||||
const blob = useAppStore.getState().getBlob(sourceImage);
|
||||
if (!blob) throw new Error('无法从Blob URL获取图像数据');
|
||||
originalImageBlob = blob;
|
||||
} else if (sourceImage.includes('base64,')) {
|
||||
// 从base64数据创建Blob
|
||||
const base64 = sourceImage.split('base64,')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
originalImageBlob = new Blob([ab], { type: mimeString });
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
const response = await fetch(sourceImage);
|
||||
originalImageBlob = await response.blob();
|
||||
}
|
||||
|
||||
// 获取用于样式指导的参考图像
|
||||
let referenceImageBlobs: Blob[] = [];
|
||||
for (const img of editReferenceImages) {
|
||||
if (img.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob数据
|
||||
const blob = useAppStore.getState().getBlob(img);
|
||||
if (blob) {
|
||||
referenceImageBlobs.push(blob);
|
||||
}
|
||||
} else if (img.includes('base64,')) {
|
||||
// 从base64数据创建Blob
|
||||
const base64 = img.split('base64,')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
try {
|
||||
const response = await fetch(img);
|
||||
const blob = await response.blob();
|
||||
referenceImageBlobs.push(blob);
|
||||
} catch (error) {
|
||||
console.warn('无法获取参考图像:', img, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let maskImageBlob: Blob | undefined;
|
||||
let maskedReferenceImage: string | undefined;
|
||||
|
||||
// 如果存在画笔描边,则从描边创建遮罩
|
||||
if (brushStrokes.length > 0) {
|
||||
// 创建临时图像以获取实际尺寸
|
||||
const tempImg = new Image()
|
||||
tempImg.src = sourceImage
|
||||
await new Promise<void>(resolve => {
|
||||
tempImg.onload = () => resolve()
|
||||
})
|
||||
|
||||
// 创建具有确切图像尺寸的遮罩画布
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')!
|
||||
canvas.width = tempImg.width
|
||||
canvas.height = tempImg.height
|
||||
|
||||
// 用黑色填充(未遮罩区域)
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// 绘制白色描边(遮罩区域)
|
||||
ctx.strokeStyle = 'white'
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
brushStrokes.forEach(stroke => {
|
||||
if (stroke.points.length >= 4) {
|
||||
ctx.lineWidth = stroke.brushSize
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(stroke.points[0], stroke.points[1])
|
||||
|
||||
for (let i = 2; i < stroke.points.length; i += 2) {
|
||||
ctx.lineTo(stroke.points[i], stroke.points[i + 1])
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
})
|
||||
|
||||
// 将遮罩转换为Blob
|
||||
maskImageBlob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('无法创建遮罩图像Blob'));
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
|
||||
// 创建遮罩参考图像(带遮罩叠加的原始图像)
|
||||
const maskedCanvas = document.createElement('canvas')
|
||||
const maskedCtx = maskedCanvas.getContext('2d')!
|
||||
maskedCanvas.width = tempImg.width
|
||||
maskedCanvas.height = tempImg.height
|
||||
|
||||
// 绘制原始图像
|
||||
maskedCtx.drawImage(tempImg, 0, 0)
|
||||
|
||||
// 绘制带透明度的遮罩叠加
|
||||
maskedCtx.globalCompositeOperation = 'source-over'
|
||||
maskedCtx.globalAlpha = 0.4
|
||||
maskedCtx.fillStyle = '#A855F7'
|
||||
|
||||
brushStrokes.forEach(stroke => {
|
||||
if (stroke.points.length >= 4) {
|
||||
maskedCtx.lineWidth = stroke.brushSize
|
||||
maskedCtx.strokeStyle = '#A855F7'
|
||||
maskedCtx.lineCap = 'round'
|
||||
maskedCtx.lineJoin = 'round'
|
||||
maskedCtx.beginPath()
|
||||
maskedCtx.moveTo(stroke.points[0], stroke.points[1])
|
||||
|
||||
for (let i = 2; i < stroke.points.length; i += 2) {
|
||||
maskedCtx.lineTo(stroke.points[i], stroke.points[i + 1])
|
||||
}
|
||||
maskedCtx.stroke()
|
||||
}
|
||||
})
|
||||
|
||||
maskedCtx.globalAlpha = 1
|
||||
maskedCtx.globalCompositeOperation = 'source-over'
|
||||
|
||||
// 将遮罩参考图像转换为base64(用于后续处理)
|
||||
const maskedDataUrl = maskedCanvas.toDataURL('image/png')
|
||||
maskedReferenceImage = maskedDataUrl.split('base64,')[1]
|
||||
|
||||
// 将遮罩图像作为参考添加到模型中
|
||||
referenceImageBlobs = [maskImageBlob, ...referenceImageBlobs];
|
||||
}
|
||||
|
||||
const request: EditRequest = {
|
||||
instruction,
|
||||
originalImage: originalImageBlob,
|
||||
referenceImages: referenceImageBlobs.length > 0 ? referenceImageBlobs : undefined,
|
||||
maskImage: maskImageBlob,
|
||||
temperature,
|
||||
seed,
|
||||
}
|
||||
|
||||
const result = await geminiService.editImage(request)
|
||||
|
||||
// 检查是否已中断
|
||||
if (isCancelledRef.current) {
|
||||
throw new Error('编辑已中断')
|
||||
}
|
||||
|
||||
return { result, maskedReferenceImage }
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsGenerating(true)
|
||||
},
|
||||
onSuccess: async ({ result, maskedReferenceImage }, instruction) => {
|
||||
const { images, usageMetadata } = result;
|
||||
if (images.length > 0) {
|
||||
// 直接使用Blob并创建URL,避免存储base64数据
|
||||
const outputAssets: Asset[] = await Promise.all(images.map(async (blob, index) => {
|
||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
// 生成校验和(使用Blob的一部分数据)
|
||||
const checksum = await new Promise<string>(async (resolve) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let checksum = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'output',
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum
|
||||
};
|
||||
}));
|
||||
|
||||
// 如果有遮罩参考图像则创建遮罩参考资产
|
||||
const maskReferenceAsset: Asset | undefined = maskedReferenceImage ? await (async () => {
|
||||
// 将base64转换为Blob
|
||||
const byteString = atob(maskedReferenceImage);
|
||||
const mimeString = 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
|
||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||
|
||||
// 生成校验和(使用Blob的一部分数据)
|
||||
const checksum = await new Promise<string>(async (resolve) => {
|
||||
try {
|
||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
let checksum = '';
|
||||
for (let i = 0; i < uint8Array.length; i++) {
|
||||
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
resolve(checksum || generateId().slice(0, 32));
|
||||
} catch (error) {
|
||||
resolve(generateId().slice(0, 32));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
type: 'mask',
|
||||
url: blobUrl, // 存储Blob URL而不是base64
|
||||
mime: 'image/png',
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
checksum
|
||||
};
|
||||
})() : undefined;
|
||||
|
||||
// 获取accessToken
|
||||
const accessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
|
||||
let uploadResults: any[] | undefined;
|
||||
|
||||
// 上传编辑后的图像
|
||||
if (accessToken) {
|
||||
try {
|
||||
const imageUrls = outputAssets.map(asset => asset.url);
|
||||
// 上传编辑后的图像(跳过缓存,因为这些是新生成的图像)
|
||||
uploadResults = await uploadImages(imageUrls, accessToken, true);
|
||||
|
||||
// 检查上传结果
|
||||
const failedUploads = uploadResults.filter(r => !r.success);
|
||||
if (failedUploads.length > 0) {
|
||||
console.warn(`${failedUploads.length}张编辑后的图像上传失败`);
|
||||
addToast(`${failedUploads.length}张编辑后的图像上传失败`, 'warning', 5000);
|
||||
} else {
|
||||
console.log(`${uploadResults.length}张编辑后的图像全部上传成功`);
|
||||
addToast('编辑后的图像已成功上传', 'success', 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传编辑后的图像时出错:', error);
|
||||
addToast('编辑后的图像上传失败', 'error', 5000);
|
||||
uploadResults = undefined;
|
||||
}
|
||||
} else {
|
||||
console.warn('未找到accessToken,跳过上传');
|
||||
}
|
||||
|
||||
// 显示Token消耗信息(如果可用)
|
||||
if (usageMetadata?.totalTokenCount) {
|
||||
addToast(`本次编辑消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
|
||||
}
|
||||
|
||||
const edit: Edit = {
|
||||
id: generateId(),
|
||||
parentGenerationId: selectedGenerationId || '',
|
||||
maskAssetId: brushStrokes.length > 0 ? generateId() : undefined,
|
||||
maskReferenceAsset,
|
||||
instruction,
|
||||
outputAssets,
|
||||
timestamp: Date.now(),
|
||||
uploadResults: uploadResults,
|
||||
parameters: {
|
||||
seed: seed || undefined,
|
||||
temperature: temperature
|
||||
},
|
||||
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
|
||||
};
|
||||
|
||||
addEdit(edit);
|
||||
|
||||
// 自动在画布中加载编辑后的图像
|
||||
const { selectEdit, selectGeneration } = useAppStore.getState();
|
||||
setCanvasImage(outputAssets[0].url);
|
||||
selectEdit(edit.id);
|
||||
selectGeneration(null);
|
||||
}
|
||||
setIsGenerating(false);
|
||||
},
|
||||
onError: error => {
|
||||
console.error('编辑失败:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||
const errorDetails = error instanceof Error ? error.stack : undefined
|
||||
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
||||
setIsGenerating(false)
|
||||
},
|
||||
})
|
||||
|
||||
const cancelEdit = () => {
|
||||
isCancelledRef.current = true
|
||||
setIsGenerating(false)
|
||||
addToast('编辑已中断', 'info', 3000)
|
||||
}
|
||||
|
||||
return {
|
||||
edit: editMutation.mutate,
|
||||
isEditing: editMutation.isPending,
|
||||
error: editMutation.error,
|
||||
cancelEdit,
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import * as indexedDBService from '../services/indexedDBService';
|
||||
|
||||
export const useIndexedDBListener = () => {
|
||||
const [generations, setGenerations] = useState<any[]>([]);
|
||||
const [edits, setEdits] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const loadRecords = async () => {
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const allGenerations = await indexedDBService.getAllGenerations();
|
||||
const allEdits = await indexedDBService.getAllEdits();
|
||||
if (isMountedRef.current) {
|
||||
setGenerations(allGenerations);
|
||||
setEdits(allEdits);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('从IndexedDB加载记录失败:', err);
|
||||
if (isMountedRef.current) {
|
||||
setError('加载历史记录失败');
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 标记组件已挂载
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 初始化数据库并加载记录
|
||||
const initAndLoad = async () => {
|
||||
try {
|
||||
await indexedDBService.initDB();
|
||||
if (isMountedRef.current) {
|
||||
await loadRecords();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('初始化IndexedDB失败:', err);
|
||||
if (isMountedRef.current) {
|
||||
setError('初始化数据库失败');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initAndLoad();
|
||||
|
||||
// 设置定时器定期检查新记录
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (isMountedRef.current) {
|
||||
loadRecords();
|
||||
}
|
||||
}, 3000); // 每3秒检查一次
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
// 标记组件已卸载
|
||||
isMountedRef.current = false;
|
||||
|
||||
// 清除定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const refresh = () => {
|
||||
loadRecords();
|
||||
};
|
||||
|
||||
return { generations, edits, loading, error, refresh };
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useImageGeneration, useImageEditing } from '../hooks/useImageGeneration';
|
||||
|
||||
export const useKeyboardShortcuts = () => {
|
||||
const {
|
||||
setSelectedTool,
|
||||
setShowHistory,
|
||||
showHistory,
|
||||
setShowPromptPanel,
|
||||
showPromptPanel,
|
||||
currentPrompt,
|
||||
isGenerating,
|
||||
selectedTool,
|
||||
editReferenceImages,
|
||||
canvasImage,
|
||||
setCanvasImage,
|
||||
temperature,
|
||||
seed,
|
||||
uploadedImages: generateUploadedImages
|
||||
} = useAppStore();
|
||||
|
||||
const { generate } = useImageGeneration();
|
||||
const { edit } = useImageEditing();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Ignore if user is typing in an input
|
||||
if (event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement) {
|
||||
// Only handle Cmd/Ctrl + Enter for generation
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (!isGenerating && currentPrompt.trim()) {
|
||||
// 触发生成操作
|
||||
if (selectedTool === 'generate') {
|
||||
const referenceImages = generateUploadedImages
|
||||
.filter(img => img.includes('base64,'))
|
||||
.map(img => img.split('base64,')[1]);
|
||||
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
|
||||
edit(currentPrompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key.toLowerCase()) {
|
||||
case 'e':
|
||||
event.preventDefault();
|
||||
setSelectedTool('edit');
|
||||
break;
|
||||
case 'g':
|
||||
event.preventDefault();
|
||||
setSelectedTool('generate');
|
||||
break;
|
||||
case 'm':
|
||||
event.preventDefault();
|
||||
setSelectedTool('mask');
|
||||
break;
|
||||
case 'h':
|
||||
event.preventDefault();
|
||||
setShowHistory(!showHistory);
|
||||
break;
|
||||
case 'p':
|
||||
event.preventDefault();
|
||||
setShowPromptPanel(!showPromptPanel);
|
||||
break;
|
||||
case 'r':
|
||||
if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
console.log('Re-roll variants');
|
||||
}
|
||||
break;
|
||||
case 'enter':
|
||||
// 如果按Enter键且有提示词,则触发生成
|
||||
if (currentPrompt.trim() && !isGenerating) {
|
||||
event.preventDefault();
|
||||
if (selectedTool === 'generate') {
|
||||
const referenceImages = generateUploadedImages
|
||||
.filter(img => img.includes('base64,'))
|
||||
.map(img => img.split('base64,')[1]);
|
||||
|
||||
generate({
|
||||
prompt: currentPrompt,
|
||||
referenceImages: referenceImages.length > 0 ? referenceImages : undefined,
|
||||
temperature,
|
||||
seed: seed !== null ? seed : undefined
|
||||
});
|
||||
} else if (selectedTool === 'edit' || selectedTool === 'mask') {
|
||||
edit(currentPrompt);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [
|
||||
setSelectedTool,
|
||||
setShowHistory,
|
||||
showHistory,
|
||||
setShowPromptPanel,
|
||||
showPromptPanel,
|
||||
currentPrompt,
|
||||
isGenerating,
|
||||
selectedTool,
|
||||
generateUploadedImages,
|
||||
editReferenceImages,
|
||||
canvasImage,
|
||||
setCanvasImage,
|
||||
temperature,
|
||||
seed,
|
||||
generate,
|
||||
edit
|
||||
]);
|
||||
};
|
||||
235
v1/src/index.css
235
v1/src/index.css
@@ -1,235 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Toast animations */
|
||||
@keyframes slide-in-from-top-full {
|
||||
0% {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-right {
|
||||
0% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-out-to-right {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-left {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-out {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-down {
|
||||
0% {
|
||||
transform: translateY(-10px);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-click {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation-duration: 300ms;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.slide-in-from-top-full {
|
||||
animation-name: slide-in-from-top-full;
|
||||
}
|
||||
|
||||
.slide-in-from-right {
|
||||
animation-name: slide-in-from-right;
|
||||
}
|
||||
|
||||
.slide-out-to-right {
|
||||
animation-name: slide-out-to-right;
|
||||
}
|
||||
|
||||
.slide-in-from-left {
|
||||
animation-name: slide-in-from-left;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation-name: fade-in;
|
||||
}
|
||||
|
||||
.scale-in {
|
||||
animation-name: scale-in;
|
||||
}
|
||||
|
||||
.scale-out {
|
||||
animation-name: scale-out;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
overflow: hidden;
|
||||
background-color: #FFFFFF;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Custom scrollbar for light theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #F8F9FA;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #E9ECEF;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #DEE2E6;
|
||||
}
|
||||
|
||||
/* Custom range slider styling */
|
||||
.slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: #FDE047;
|
||||
cursor: pointer;
|
||||
border: 2px solid #FFFFFF;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
border-radius: 50%;
|
||||
background: #FDE047;
|
||||
cursor: pointer;
|
||||
border: 2px solid #FFFFFF;
|
||||
}
|
||||
|
||||
/* Marching ants animation */
|
||||
@keyframes marching-ants {
|
||||
0% { stroke-dashoffset: 0; }
|
||||
100% { stroke-dashoffset: 10; }
|
||||
}
|
||||
|
||||
.marching-ants {
|
||||
stroke-dasharray: 5 5;
|
||||
animation: marching-ants 0.5s linear infinite;
|
||||
}
|
||||
|
||||
/* Focus styles for accessibility */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #FDE047;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
* {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.slide-down {
|
||||
animation-name: slide-down;
|
||||
}
|
||||
|
||||
.pulse-click {
|
||||
animation-name: pulse-click;
|
||||
}
|
||||
|
||||
/* Card styles */
|
||||
.card {
|
||||
@apply bg-white rounded-xl shadow-card border border-gray-100 overflow-hidden;
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply shadow-card-hover;
|
||||
}
|
||||
|
||||
.card-lg {
|
||||
@apply shadow-card-lg;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -1,119 +0,0 @@
|
||||
import { get, set, del, keys } from 'idb-keyval';
|
||||
import { Project, Generation, Asset } from '../types';
|
||||
|
||||
const CACHE_PREFIX = 'nano-banana';
|
||||
const CACHE_VERSION = '1.0';
|
||||
// 限制缓存项目数量
|
||||
const MAX_CACHED_ITEMS = 50;
|
||||
// 限制缓存最大年龄 (3天)
|
||||
const MAX_CACHE_AGE = 3 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export class CacheService {
|
||||
private static getKey(type: string, id: string): string {
|
||||
return `${CACHE_PREFIX}-${CACHE_VERSION}-${type}-${id}`;
|
||||
}
|
||||
|
||||
// Project caching
|
||||
static async saveProject(project: Project): Promise<void> {
|
||||
// 在保存新项目之前,清理旧缓存
|
||||
await this.clearOldCache();
|
||||
await set(this.getKey('project', project.id), project);
|
||||
}
|
||||
|
||||
static async getProject(id: string): Promise<Project | null> {
|
||||
return (await get(this.getKey('project', id))) || null;
|
||||
}
|
||||
|
||||
static async getAllProjects(): Promise<Project[]> {
|
||||
const allKeys = await keys();
|
||||
const projectKeys = allKeys.filter(key =>
|
||||
typeof key === 'string' && key.includes(`${CACHE_PREFIX}-${CACHE_VERSION}-project-`)
|
||||
);
|
||||
|
||||
const projects = await Promise.all(
|
||||
projectKeys.map(key => get(key as string))
|
||||
);
|
||||
|
||||
return projects.filter(Boolean) as Project[];
|
||||
}
|
||||
|
||||
// Asset caching (for offline access)
|
||||
static async cacheAsset(asset: Asset, data: Blob): Promise<void> {
|
||||
// 在保存新资产之前,清理旧缓存
|
||||
await this.clearOldCache();
|
||||
await set(this.getKey('asset', asset.id), {
|
||||
asset,
|
||||
data,
|
||||
cachedAt: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
static async getCachedAsset(assetId: string): Promise<{ asset: Asset; data: Blob } | null> {
|
||||
const cached = await get(this.getKey('asset', assetId));
|
||||
return cached || null;
|
||||
}
|
||||
|
||||
// Generation metadata caching
|
||||
static async cacheGeneration(generation: Generation): Promise<void> {
|
||||
// 在保存新生成记录之前,清理旧缓存
|
||||
await this.clearOldCache();
|
||||
await set(this.getKey('generation', generation.id), generation);
|
||||
}
|
||||
|
||||
static async getGeneration(id: string): Promise<Generation | null> {
|
||||
return (await get(this.getKey('generation', id))) || null;
|
||||
}
|
||||
|
||||
// Clear old cache entries
|
||||
static async clearOldCache(maxAge: number = MAX_CACHE_AGE): Promise<void> {
|
||||
const allKeys = await keys();
|
||||
const now = Date.now();
|
||||
|
||||
// 收集需要删除的键
|
||||
const keysToDelete: string[] = [];
|
||||
const validCachedItems: Array<{key: string, cachedAt: number}> = [];
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (typeof key === 'string' && key.startsWith(CACHE_PREFIX)) {
|
||||
const cached = await get(key);
|
||||
if (cached?.cachedAt) {
|
||||
// 检查是否过期
|
||||
if ((now - cached.cachedAt) > maxAge) {
|
||||
keysToDelete.push(key);
|
||||
} else {
|
||||
validCachedItems.push({key, cachedAt: cached.cachedAt});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有效项目数量超过限制,删除最旧的项目
|
||||
if (validCachedItems.length > MAX_CACHED_ITEMS) {
|
||||
// 按时间排序,最旧的在前面
|
||||
validCachedItems.sort((a, b) => a.cachedAt - b.cachedAt);
|
||||
// 计算需要删除的数量
|
||||
const excessCount = validCachedItems.length - MAX_CACHED_ITEMS;
|
||||
// 添加最旧的项目到删除列表
|
||||
for (let i = 0; i < excessCount; i++) {
|
||||
keysToDelete.push(validCachedItems[i].key);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
for (const key of keysToDelete) {
|
||||
await del(key);
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有缓存
|
||||
static async clearAllCache(): Promise<void> {
|
||||
const allKeys = await keys();
|
||||
const cacheKeys = allKeys.filter(key =>
|
||||
typeof key === 'string' && key.startsWith(CACHE_PREFIX)
|
||||
);
|
||||
|
||||
for (const key of cacheKeys) {
|
||||
await del(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,327 +0,0 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
|
||||
// 注意:在生产环境中,这应该通过后端代理处理
|
||||
const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key'
|
||||
const genAI = new GoogleGenAI({ apiKey: API_KEY })
|
||||
|
||||
export interface GenerationRequest {
|
||||
prompt: string
|
||||
referenceImages?: Blob[] // Blob数组
|
||||
temperature?: number
|
||||
seed?: number
|
||||
}
|
||||
|
||||
export interface EditRequest {
|
||||
instruction: string
|
||||
originalImage: Blob // Blob
|
||||
referenceImages?: Blob[] // Blob数组
|
||||
maskImage?: Blob // Blob
|
||||
temperature?: number
|
||||
seed?: number
|
||||
}
|
||||
|
||||
export interface UsageMetadata {
|
||||
totalTokenCount?: number
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
}
|
||||
|
||||
export interface SegmentationRequest {
|
||||
image: Blob // Blob
|
||||
query: string // "像素(x,y)处的对象" 或 "红色汽车"
|
||||
}
|
||||
|
||||
export class GeminiService {
|
||||
// 将Blob转换为base64的辅助函数
|
||||
private async blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1]; // Remove data:image/png;base64, prefix
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
|
||||
try {
|
||||
const contents: any[] = [{ text: request.prompt }]
|
||||
|
||||
// 如果提供了参考图像则添加
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const base64Images = await Promise.all(
|
||||
request.referenceImages.map(blob => this.blobToBase64(blob))
|
||||
);
|
||||
|
||||
base64Images.forEach(image => {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: image,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
model: 'gemini-2.5-flash-image-preview',
|
||||
contents,
|
||||
})
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0]
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||||
}
|
||||
if (candidate.finishReason === 'IMAGE_SAFETY') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||||
}
|
||||
// 检查finishReason为STOP但没有inlineData的情况
|
||||
if (candidate.finishReason === 'STOP') {
|
||||
// 检查是否有inlineData
|
||||
let hasInlineData = false;
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.inlineData) {
|
||||
hasInlineData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有inlineData,则抛出错误
|
||||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||||
throw new Error(candidate.content.parts[0].text || '生成失败:未返回图像数据');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const images: Blob[] = []
|
||||
|
||||
// 检查响应是否存在以及是否有内容
|
||||
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) {
|
||||
// 将返回的base64数据转换为Blob
|
||||
const byteString = atob(part.inlineData.data);
|
||||
const mimeString = part.inlineData.mimeType || 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
images.push(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取usageMetadata(如果存在)
|
||||
const usageMetadata = response.usageMetadata
|
||||
|
||||
return { images, usageMetadata }
|
||||
} catch (error) {
|
||||
console.error('生成图像时出错:', error)
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error
|
||||
}
|
||||
throw new Error(`生成图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: any }> {
|
||||
try {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const originalImageBase64 = await this.blobToBase64(request.originalImage);
|
||||
|
||||
const contents = [
|
||||
{ text: this.buildEditPrompt(request) },
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: originalImageBase64,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// 如果提供了参考图像则添加
|
||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const base64ReferenceImages = await Promise.all(
|
||||
request.referenceImages.map(blob => this.blobToBase64(blob))
|
||||
);
|
||||
|
||||
base64ReferenceImages.forEach(image => {
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: image,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (request.maskImage) {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const maskImageBase64 = await this.blobToBase64(request.maskImage);
|
||||
contents.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: maskImageBase64,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
model: 'gemini-2.5-flash-image-preview',
|
||||
contents,
|
||||
})
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0]
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||||
}
|
||||
// 检查finishReason为STOP但没有inlineData的情况
|
||||
if (candidate.finishReason === 'STOP') {
|
||||
// 检查是否有inlineData
|
||||
let hasInlineData = false;
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.inlineData) {
|
||||
hasInlineData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有inlineData,则抛出错误
|
||||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||||
throw new Error(candidate.content.parts[0].text || '编辑失败:未返回图像数据');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const images: Blob[] = []
|
||||
|
||||
// 检查响应是否存在以及是否有内容
|
||||
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) {
|
||||
// 将返回的base64数据转换为Blob
|
||||
const byteString = atob(part.inlineData.data);
|
||||
const mimeString = part.inlineData.mimeType || 'image/png';
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
images.push(blob);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取usageMetadata(如果存在)
|
||||
const usageMetadata = response.usageMetadata
|
||||
|
||||
return { images, usageMetadata }
|
||||
} catch (error) {
|
||||
console.error('编辑图像时出错:', error)
|
||||
if (error instanceof Error && error.message) {
|
||||
throw error
|
||||
}
|
||||
throw new Error(`编辑图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
async segmentImage(request: SegmentationRequest): Promise<any> {
|
||||
try {
|
||||
// 将Blob转换为base64以发送到API
|
||||
const imageBase64 = await this.blobToBase64(request.image);
|
||||
|
||||
const prompt = [
|
||||
{
|
||||
text: `分析此图像并为以下对象创建分割遮罩: ${request.query}
|
||||
|
||||
返回具有此确切结构的JSON对象:
|
||||
{
|
||||
"masks": [
|
||||
{
|
||||
"label": "分割对象的描述",
|
||||
"box_2d": [x, y, width, height],
|
||||
"mask": "base64编码的二进制遮罩图像"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
仅分割请求的特定对象或区域。遮罩应该是二进制PNG,其中白色像素(255)表示选定区域,黑色像素(0)表示背景。`,
|
||||
},
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: imageBase64,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const response = await genAI.models.generateContent({
|
||||
model: 'gemini-2.5-flash-image-preview',
|
||||
contents: prompt,
|
||||
})
|
||||
|
||||
// 检查是否有被禁止的内容
|
||||
if (response.candidates && response.candidates.length > 0) {
|
||||
const candidate = response.candidates[0]
|
||||
if (candidate.finishReason === 'PROHIBITED_CONTENT') {
|
||||
throw new Error('内容被禁止:您的请求包含不允许的内容。请尝试其他提示。')
|
||||
}
|
||||
// 检查finishReason为STOP但没有inlineData的情况
|
||||
if (candidate.finishReason === 'STOP') {
|
||||
// 检查是否有inlineData
|
||||
let hasInlineData = false;
|
||||
if (candidate.content && candidate.content.parts) {
|
||||
for (const part of candidate.content.parts) {
|
||||
if (part.inlineData) {
|
||||
hasInlineData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有inlineData,则抛出错误
|
||||
if (!hasInlineData && candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
|
||||
throw new Error(candidate.content.parts[0].text || '分割失败:未返回结果数据');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(`分割图像失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}
|
||||
|
||||
private buildEditPrompt(request: EditRequest): string {
|
||||
const maskInstruction = request.maskImage ? '\n\n重要: 仅在遮罩图像显示白色像素(值255)的地方应用更改。完全不更改所有其他区域。精确遵守遮罩边界并在边缘保持无缝混合。' : ''
|
||||
|
||||
return `根据以下指令编辑此图像: ${request.instruction}
|
||||
|
||||
保持原始图像的光照、透视和整体构图。使更改看起来自然且无缝集成。${maskInstruction}
|
||||
|
||||
保持图像质量并确保编辑看起来专业且逼真。`
|
||||
}
|
||||
}
|
||||
|
||||
export const geminiService = new GeminiService()
|
||||
@@ -1,447 +0,0 @@
|
||||
import { Generation, Edit } from '../types';
|
||||
|
||||
// 数据库配置
|
||||
const DB_NAME = 'NanoBananaDB';
|
||||
const DB_VERSION = 1;
|
||||
const GENERATIONS_STORE = 'generations';
|
||||
const EDITS_STORE = 'edits';
|
||||
|
||||
// 重试配置
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY = 1000;
|
||||
|
||||
// IndexedDB实例
|
||||
let db: IDBDatabase | null = null;
|
||||
|
||||
/**
|
||||
* 初始化数据库
|
||||
*/
|
||||
export const initDB = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('数据库打开失败:', request.error);
|
||||
reject(request.error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
db = request.result;
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// 创建生成记录存储
|
||||
if (!db.objectStoreNames.contains(GENERATIONS_STORE)) {
|
||||
const genStore = db.createObjectStore(GENERATIONS_STORE, { keyPath: 'id' });
|
||||
genStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
|
||||
// 创建编辑记录存储
|
||||
if (!db.objectStoreNames.contains(EDITS_STORE)) {
|
||||
const editStore = db.createObjectStore(EDITS_STORE, { keyPath: 'id' });
|
||||
editStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
editStore.createIndex('parentGenerationId', 'parentGenerationId', { unique: false });
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取数据库实例
|
||||
*/
|
||||
const getDB = (): IDBDatabase => {
|
||||
if (!db) {
|
||||
throw new Error('数据库未初始化');
|
||||
}
|
||||
return db;
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加生成记录
|
||||
*/
|
||||
export const addGeneration = async (generation: Generation): Promise<void> => {
|
||||
// 创建轻量级生成记录,只存储必要的信息和上传后的URL
|
||||
const lightweightGeneration = {
|
||||
id: generation.id,
|
||||
prompt: generation.prompt,
|
||||
parameters: generation.parameters,
|
||||
modelVersion: generation.modelVersion,
|
||||
timestamp: generation.timestamp,
|
||||
uploadResults: generation.uploadResults,
|
||||
usageMetadata: generation.usageMetadata,
|
||||
// 只存储上传后的URL,不存储base64数据
|
||||
sourceAssets: generation.sourceAssets.map(asset => {
|
||||
const uploadedUrl = getUploadedAssetUrl(generation, asset.id, 'source');
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
// 如果没有上传后的URL,则不存储URL以避免base64数据
|
||||
url: uploadedUrl || '',
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum
|
||||
};
|
||||
}),
|
||||
outputAssets: generation.outputAssets.map(asset => {
|
||||
const uploadedUrl = getUploadedAssetUrl(generation, asset.id, 'output');
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
// 如果没有上传后的URL,则不存储URL以避免base64数据
|
||||
url: uploadedUrl || '',
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(GENERATIONS_STORE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.add(lightweightGeneration);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 添加编辑记录
|
||||
*/
|
||||
export const addEdit = async (edit: Edit): Promise<void> => {
|
||||
// 创建轻量级编辑记录,只存储必要的信息和上传后的URL
|
||||
const lightweightEdit = {
|
||||
id: edit.id,
|
||||
parentGenerationId: edit.parentGenerationId,
|
||||
maskAssetId: edit.maskAssetId,
|
||||
instruction: edit.instruction,
|
||||
timestamp: edit.timestamp,
|
||||
uploadResults: edit.uploadResults,
|
||||
parameters: edit.parameters,
|
||||
usageMetadata: edit.usageMetadata,
|
||||
// 只存储上传后的URL,不存储base64数据
|
||||
maskReferenceAsset: edit.maskReferenceAsset ? (() => {
|
||||
const uploadedUrl = getUploadedAssetUrl(edit, edit.maskReferenceAsset.id, 'mask');
|
||||
return {
|
||||
id: edit.maskReferenceAsset.id,
|
||||
type: edit.maskReferenceAsset.type,
|
||||
// 如果没有上传后的URL,则不存储URL以避免base64数据
|
||||
url: uploadedUrl || '',
|
||||
mime: edit.maskReferenceAsset.mime,
|
||||
width: edit.maskReferenceAsset.width,
|
||||
height: edit.maskReferenceAsset.height,
|
||||
checksum: edit.maskReferenceAsset.checksum
|
||||
};
|
||||
})() : undefined,
|
||||
outputAssets: edit.outputAssets.map(asset => {
|
||||
const uploadedUrl = getUploadedAssetUrl(edit, asset.id, 'output');
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
// 如果没有上传后的URL,则不存储URL以避免base64数据
|
||||
url: uploadedUrl || '',
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([EDITS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(EDITS_STORE);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = store.add(lightweightEdit);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 从uploadResults中获取资产的上传后URL
|
||||
* 注意:这个函数需要根据资产在数组中的位置来匹配上传结果
|
||||
* - 输出资产的索引与uploadResults中的索引相对应
|
||||
* - 源资产(参考图像)的索引从outputAssets.length开始
|
||||
*/
|
||||
const getUploadedAssetUrl = (record: Generation | Edit, assetId: string, assetType: 'output' | 'source' | 'mask'): string | null => {
|
||||
if (!record.uploadResults || record.uploadResults.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let assetIndex = -1;
|
||||
|
||||
// 根据资产类型确定在uploadResults中的索引
|
||||
if (assetType === 'output') {
|
||||
// 输出资产的索引与在outputAssets数组中的索引相同
|
||||
assetIndex = record.outputAssets.findIndex(a => a.id === assetId);
|
||||
} else if (assetType === 'source') {
|
||||
// 源资产(参考图像)的索引从outputAssets.length开始
|
||||
assetIndex = record.sourceAssets?.findIndex(a => a.id === assetId);
|
||||
if (assetIndex >= 0) {
|
||||
assetIndex += record.outputAssets.length;
|
||||
}
|
||||
} else if (assetType === 'mask') {
|
||||
// 遮罩参考资产通常是第一个输出资产之后的第一个源资产
|
||||
assetIndex = record.outputAssets.length;
|
||||
}
|
||||
|
||||
// 检查索引是否有效并且对应的上传结果是否存在且成功
|
||||
if (assetIndex >= 0 && assetIndex < record.uploadResults.length) {
|
||||
const uploadResult = record.uploadResults[assetIndex];
|
||||
if (uploadResult.success && uploadResult.url) {
|
||||
return uploadResult.url;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有生成记录(按时间倒序)
|
||||
*/
|
||||
export const getAllGenerations = async (): Promise<Generation[]> => {
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([GENERATIONS_STORE], 'readonly');
|
||||
const store = transaction.objectStore(GENERATIONS_STORE);
|
||||
const index = store.index('timestamp');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = index.getAll();
|
||||
request.onsuccess = () => {
|
||||
// 按时间倒序排列
|
||||
const generations = request.result.sort((a, b) => b.timestamp - a.timestamp);
|
||||
resolve(generations);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有编辑记录(按时间倒序)
|
||||
*/
|
||||
export const getAllEdits = async (): Promise<Edit[]> => {
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([EDITS_STORE], 'readonly');
|
||||
const store = transaction.objectStore(EDITS_STORE);
|
||||
const index = store.index('timestamp');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = index.getAll();
|
||||
request.onsuccess = () => {
|
||||
// 按时间倒序排列
|
||||
const edits = request.result.sort((a, b) => b.timestamp - a.timestamp);
|
||||
resolve(edits);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据父生成ID获取编辑记录
|
||||
*/
|
||||
export const getEditsByParentGenerationId = async (parentGenerationId: string): Promise<Edit[]> => {
|
||||
const db = getDB();
|
||||
const transaction = db.transaction([EDITS_STORE], 'readonly');
|
||||
const store = transaction.objectStore(EDITS_STORE);
|
||||
const index = store.index('parentGenerationId');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = index.getAll(IDBKeyRange.only(parentGenerationId));
|
||||
request.onsuccess = () => {
|
||||
// 按时间倒序排列
|
||||
const edits = request.result.sort((a, b) => b.timestamp - a.timestamp);
|
||||
resolve(edits);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除最旧的记录以保持限制
|
||||
*/
|
||||
export const cleanupOldRecords = async (limit: number = 100): Promise<void> => {
|
||||
const db = getDB();
|
||||
|
||||
// 清理生成记录
|
||||
const genTransaction = db.transaction([GENERATIONS_STORE], 'readwrite');
|
||||
const genStore = genTransaction.objectStore(GENERATIONS_STORE);
|
||||
|
||||
// 清理编辑记录
|
||||
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
|
||||
const editStore = editTransaction.objectStore(EDITS_STORE);
|
||||
|
||||
// 获取所有记录并按时间排序
|
||||
const allGenerations = await getAllGenerations();
|
||||
const allEdits = await getAllEdits();
|
||||
|
||||
// 计算需要删除的记录数量
|
||||
if (allGenerations.length > limit) {
|
||||
const toDelete = allGenerations.slice(limit);
|
||||
for (const gen of toDelete) {
|
||||
genStore.delete(gen.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (allEdits.length > limit) {
|
||||
const toDelete = allEdits.slice(limit);
|
||||
for (const edit of toDelete) {
|
||||
editStore.delete(edit.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理记录中的base64数据
|
||||
*/
|
||||
export const cleanupBase64Data = async (): Promise<void> => {
|
||||
try {
|
||||
// 获取所有生成记录
|
||||
const generations = await getAllGenerations();
|
||||
|
||||
// 获取所有编辑记录
|
||||
const edits = await getAllEdits();
|
||||
|
||||
const db = getDB();
|
||||
|
||||
// 更新生成记录
|
||||
for (const generation of generations) {
|
||||
// 检查是否有base64数据需要清理
|
||||
let needsUpdate = false;
|
||||
|
||||
// 清理源资产中的base64数据
|
||||
const cleanedSourceAssets = generation.sourceAssets.map((asset: any) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
...asset,
|
||||
url: '' // 移除base64数据
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
// 清理输出资产中的base64数据
|
||||
const cleanedOutputAssets = generation.outputAssets.map((asset: any) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
...asset,
|
||||
url: '' // 移除base64数据
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
// 如果需要更新,则保存清理后的记录
|
||||
if (needsUpdate) {
|
||||
const cleanedGeneration = {
|
||||
...generation,
|
||||
sourceAssets: cleanedSourceAssets,
|
||||
outputAssets: cleanedOutputAssets
|
||||
};
|
||||
|
||||
const transaction = db.transaction([GENERATIONS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(GENERATIONS_STORE);
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = store.put(cleanedGeneration);
|
||||
request.onsuccess = () => resolve(undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 更新编辑记录
|
||||
for (const edit of edits) {
|
||||
// 检查是否有base64数据需要清理
|
||||
let needsUpdate = false;
|
||||
|
||||
// 清理遮罩参考资产中的base64数据
|
||||
let cleanedMaskReferenceAsset = edit.maskReferenceAsset;
|
||||
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url && edit.maskReferenceAsset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
cleanedMaskReferenceAsset = {
|
||||
...edit.maskReferenceAsset,
|
||||
url: '' // 移除base64数据
|
||||
};
|
||||
}
|
||||
|
||||
// 清理输出资产中的base64数据
|
||||
const cleanedOutputAssets = edit.outputAssets.map((asset: any) => {
|
||||
if (asset.url && asset.url.startsWith('data:')) {
|
||||
needsUpdate = true;
|
||||
return {
|
||||
...asset,
|
||||
url: '' // 移除base64数据
|
||||
};
|
||||
}
|
||||
return asset;
|
||||
});
|
||||
|
||||
// 如果需要更新,则保存清理后的记录
|
||||
if (needsUpdate) {
|
||||
const cleanedEdit = {
|
||||
...edit,
|
||||
maskReferenceAsset: cleanedMaskReferenceAsset,
|
||||
outputAssets: cleanedOutputAssets
|
||||
};
|
||||
|
||||
const transaction = db.transaction([EDITS_STORE], 'readwrite');
|
||||
const store = transaction.objectStore(EDITS_STORE);
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = store.put(cleanedEdit);
|
||||
request.onsuccess = () => resolve(undefined);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('IndexedDB中的base64数据清理完成');
|
||||
} catch (error) {
|
||||
console.error('清理IndexedDB中的base64数据时出错:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 清空所有记录
|
||||
*/
|
||||
export const clearAllRecords = async (): Promise<void> => {
|
||||
const db = getDB();
|
||||
|
||||
const genTransaction = db.transaction([GENERATIONS_STORE], 'readwrite');
|
||||
const genStore = genTransaction.objectStore(GENERATIONS_STORE);
|
||||
|
||||
const editTransaction = db.transaction([EDITS_STORE], 'readwrite');
|
||||
const editStore = editTransaction.objectStore(EDITS_STORE);
|
||||
|
||||
return Promise.all([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = genStore.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
}),
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = editStore.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
})
|
||||
]).then(() => undefined);
|
||||
};
|
||||
|
||||
/**
|
||||
* 关闭数据库连接
|
||||
*/
|
||||
export const closeDB = (): void => {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
};
|
||||
@@ -1,290 +0,0 @@
|
||||
// src/services/uploadService.ts
|
||||
import { UploadResult } from '../types'
|
||||
|
||||
// 上传接口URL
|
||||
const UPLOAD_URL = import.meta.env.VITE_UPLOAD_API
|
||||
|
||||
// 创建一个Map来缓存已上传的图像
|
||||
const uploadCache = new Map<string, UploadResult>()
|
||||
|
||||
// 缓存配置
|
||||
const MAX_CACHE_SIZE = 20 // 减少最大缓存条目数
|
||||
const CACHE_EXPIRY_TIME = 15 * 60 * 1000 // 缓存过期时间15分钟
|
||||
|
||||
/**
|
||||
* 清理过期的缓存条目
|
||||
*/
|
||||
function cleanupExpiredCache(): void {
|
||||
const now = Date.now()
|
||||
let deletedCount = 0
|
||||
|
||||
uploadCache.forEach((value, key) => {
|
||||
if (now - value.timestamp > CACHE_EXPIRY_TIME) {
|
||||
uploadCache.delete(key)
|
||||
deletedCount++
|
||||
}
|
||||
})
|
||||
|
||||
if (deletedCount > 0) {
|
||||
console.log(`清除了 ${deletedCount} 个过期的缓存条目`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并维护缓存大小
|
||||
*/
|
||||
function maintainCacheSize(): void {
|
||||
// 如果缓存大小超过限制,删除最旧的条目
|
||||
if (uploadCache.size >= MAX_CACHE_SIZE) {
|
||||
// 获取所有条目并按时间排序
|
||||
const entries = Array.from(uploadCache.entries())
|
||||
entries.sort((a, b) => a[1].timestamp - b[1].timestamp)
|
||||
|
||||
// 删除最旧的条目,直到缓存大小在限制内
|
||||
const deleteCount = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.2)) // 删除20%的条目
|
||||
for (let i = 0; i < deleteCount && uploadCache.size >= MAX_CACHE_SIZE; i++) {
|
||||
uploadCache.delete(entries[i][0])
|
||||
}
|
||||
|
||||
console.log(`缓存已满,删除了 ${deleteCount} 个最旧的条目`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成图像的唯一标识符
|
||||
* @param imageData - 图像数据(可以是base64或Blob URL)
|
||||
* @returns 图像的唯一标识符
|
||||
*/
|
||||
function getImageHash(imageData: string): string {
|
||||
// 对于Blob URL,我们需要获取实际的数据来生成哈希
|
||||
if (imageData.startsWith('blob:')) {
|
||||
// 对于Blob URL,我们使用URL本身作为标识符的一部分
|
||||
// 这不是完美的解决方案,但对于大多数情况足够了
|
||||
try {
|
||||
return btoa(imageData).slice(0, 32)
|
||||
} catch (e) {
|
||||
// 如果btoa失败(例如包含非Latin1字符),使用encodeURIComponent
|
||||
return btoa(encodeURIComponent(imageData)).slice(0, 32)
|
||||
}
|
||||
}
|
||||
|
||||
// 对于base64数据,使用简单的哈希函数生成图像标识符
|
||||
let hash = 0
|
||||
for (let i = 0; i < imageData.length; i++) {
|
||||
const char = imageData.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // 转换为32位整数
|
||||
}
|
||||
return hash.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 从Blob URL获取Blob数据
|
||||
* @param blobUrl - Blob URL
|
||||
* @returns Blob对象
|
||||
*/
|
||||
async function getBlobFromUrl(blobUrl: string): Promise<Blob> {
|
||||
try {
|
||||
// 从AppStore获取Blob
|
||||
const { useAppStore } = await import('../store/useAppStore')
|
||||
const blob = useAppStore.getState().getBlob(blobUrl)
|
||||
|
||||
if (!blob) {
|
||||
throw new Error('无法从AppStore获取Blob,Blob可能已被清理');
|
||||
}
|
||||
|
||||
return blob;
|
||||
} catch (error) {
|
||||
console.error('从AppStore获取Blob时出错:', error);
|
||||
throw new Error('无法从Blob URL获取图像数据');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图像数据上传到指定接口
|
||||
* @param imageData - 图像数据(可以是base64、Blob URL或Blob对象)
|
||||
* @param accessToken - 访问令牌
|
||||
* @param skipCache - 是否跳过缓存检查
|
||||
* @returns 上传结果
|
||||
*/
|
||||
export const uploadImage = async (imageData: string | Blob, accessToken: string, skipCache: boolean = false): Promise<{ success: boolean; url?: string; error?: string }> => {
|
||||
// 检查缓存中是否已有该图像的上传结果
|
||||
const imageHash = typeof imageData === 'string' ? getImageHash(imageData) : 'blob-' + Date.now()
|
||||
|
||||
if (!skipCache && typeof imageData === 'string' && uploadCache.has(imageHash)) {
|
||||
const cachedResult = uploadCache.get(imageHash)!
|
||||
// 检查缓存是否过期
|
||||
if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) {
|
||||
console.log('从缓存中获取上传结果')
|
||||
// 确保返回的数据结构与新上传的结果一致
|
||||
return {
|
||||
success: cachedResult.success,
|
||||
url: cachedResult.url,
|
||||
error: cachedResult.error
|
||||
}
|
||||
} else {
|
||||
// 缓存过期,删除它
|
||||
uploadCache.delete(imageHash)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let blob: Blob
|
||||
|
||||
if (typeof imageData === 'string') {
|
||||
if (imageData.startsWith('blob:')) {
|
||||
// 从Blob URL获取Blob数据
|
||||
blob = await getBlobFromUrl(imageData)
|
||||
} else if (imageData.includes('base64,')) {
|
||||
// 从base64数据创建Blob
|
||||
const base64Data = imageData.split('base64,')[1]
|
||||
const byteString = atob(base64Data)
|
||||
// 从base64数据中提取MIME类型
|
||||
const mimeMatch = imageData.match(/^data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/)
|
||||
const mimeString = mimeMatch ? mimeMatch[1] : 'image/png' // 默认MIME类型
|
||||
const ab = new ArrayBuffer(byteString.length)
|
||||
const ia = new Uint8Array(ab)
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i)
|
||||
}
|
||||
blob = new Blob([ab], { type: mimeString })
|
||||
} else {
|
||||
// 从URL获取Blob
|
||||
const response = await fetch(imageData)
|
||||
blob = await response.blob()
|
||||
}
|
||||
} else {
|
||||
// 如果已经是Blob对象,直接使用
|
||||
blob = imageData
|
||||
}
|
||||
|
||||
// 创建FormData对象
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, 'generated-image.png')
|
||||
|
||||
// 发送POST请求
|
||||
const response = await fetch(UPLOAD_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accessToken: accessToken,
|
||||
// 添加其他可能需要的头部
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
// 记录响应状态以帮助调试
|
||||
console.log('上传响应状态:', response.status, response.statusText)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('上传失败响应内容:', errorText)
|
||||
throw new Error(`上传失败: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
console.log('上传响应结果:', result)
|
||||
|
||||
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
|
||||
if (result.code === 200) {
|
||||
// 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀
|
||||
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''
|
||||
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data
|
||||
|
||||
// 清理过期缓存
|
||||
cleanupExpiredCache()
|
||||
|
||||
// 维护缓存大小
|
||||
maintainCacheSize()
|
||||
|
||||
// 将上传结果存储到缓存中
|
||||
const uploadResult = { success: true, url: fullUrl, error: undefined }
|
||||
if (typeof imageData === 'string') {
|
||||
uploadCache.set(imageHash, {
|
||||
...uploadResult,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
return { success: true, url: fullUrl, error: undefined }
|
||||
} else {
|
||||
throw new Error(`上传失败: ${result.msg}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图像时出错:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const errorResult = { success: false, error: errorMessage }
|
||||
|
||||
// 清理过期缓存
|
||||
cleanupExpiredCache()
|
||||
|
||||
// 维护缓存大小(即使是失败的结果也缓存,但时间较短)
|
||||
maintainCacheSize()
|
||||
|
||||
// 将失败的上传结果也存储到缓存中(可选)
|
||||
if (typeof imageData === 'string') {
|
||||
uploadCache.set(imageHash, {
|
||||
...errorResult,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传多个图像
|
||||
* @param imageDatas - 图像数据数组(可以是base64、Blob URL或Blob对象)
|
||||
* @param accessToken - 访问令牌
|
||||
* @param skipCache - 是否跳过缓存检查
|
||||
* @returns 上传结果数组
|
||||
*/
|
||||
export const uploadImages = async (imageDatas: (string | Blob)[], accessToken: string, skipCache: boolean = false): Promise<UploadResult[]> => {
|
||||
try {
|
||||
const results: UploadResult[] = []
|
||||
|
||||
for (let i = 0; i < imageDatas.length; i++) {
|
||||
const imageData = imageDatas[i]
|
||||
try {
|
||||
const uploadResult = await uploadImage(imageData, accessToken, skipCache)
|
||||
const result: UploadResult = {
|
||||
success: uploadResult.success,
|
||||
url: uploadResult.url,
|
||||
error: uploadResult.error,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
results.push(result)
|
||||
console.log(`第${i + 1}张图像上传${uploadResult.success ? '成功' : '失败'}:`, uploadResult)
|
||||
} catch (error) {
|
||||
const result: UploadResult = {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
results.push(result)
|
||||
console.error(`第${i + 1}张图像上传失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有任何上传失败
|
||||
const failedUploads = results.filter(r => !r.success)
|
||||
if (failedUploads.length > 0) {
|
||||
console.warn(`${failedUploads.length}张图像上传失败`)
|
||||
} else {
|
||||
console.log(`所有${results.length}张图像上传成功`)
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
console.error('批量上传图像时出错:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除上传缓存
|
||||
*/
|
||||
export const clearUploadCache = (): void => {
|
||||
uploadCache.clear()
|
||||
console.log('上传缓存已清除')
|
||||
}
|
||||
@@ -1,762 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { Project, Generation, Edit, SegmentationMask, BrushStroke, UploadResult } from '../types';
|
||||
import { generateId } from '../utils/imageUtils';
|
||||
import * as indexedDBService from '../services/indexedDBService';
|
||||
|
||||
// 定义不包含图像数据的轻量级项目结构
|
||||
interface LightweightProject {
|
||||
id: string;
|
||||
title: string;
|
||||
generations: Array<{
|
||||
id: string;
|
||||
prompt: string;
|
||||
parameters: Generation['parameters'];
|
||||
sourceAssets: Array<{
|
||||
id: string;
|
||||
type: 'original';
|
||||
mime: string;
|
||||
width: number;
|
||||
height: number;
|
||||
checksum: string;
|
||||
// 存储Blob URL而不是base64数据
|
||||
blobUrl: string;
|
||||
}>;
|
||||
// 存储输出资产的Blob URL
|
||||
outputAssetsBlobUrls: string[];
|
||||
modelVersion: string;
|
||||
timestamp: number;
|
||||
uploadResults?: UploadResult[];
|
||||
usageMetadata?: Generation['usageMetadata'];
|
||||
}>;
|
||||
edits: Array<{
|
||||
id: string;
|
||||
parentGenerationId: string;
|
||||
maskAssetId?: string;
|
||||
// 存储遮罩参考资产的Blob URL
|
||||
maskReferenceAssetBlobUrl?: string;
|
||||
instruction: string;
|
||||
// 存储输出资产的Blob URL
|
||||
outputAssetsBlobUrls: string[];
|
||||
timestamp: number;
|
||||
uploadResults?: UploadResult[];
|
||||
parameters?: Edit['parameters'];
|
||||
usageMetadata?: Edit['usageMetadata'];
|
||||
}>;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// 当前项目(轻量级版本,不包含实际图像数据)
|
||||
currentProject: LightweightProject | null;
|
||||
|
||||
// 画布状态
|
||||
canvasImage: string | null;
|
||||
canvasZoom: number;
|
||||
canvasPan: { x: number; y: number };
|
||||
|
||||
// 上传状态
|
||||
uploadedImages: string[];
|
||||
editReferenceImages: string[];
|
||||
|
||||
// 用于绘制遮罩的画笔描边
|
||||
brushStrokes: BrushStroke[];
|
||||
brushSize: number;
|
||||
showMasks: boolean;
|
||||
|
||||
// 生成状态
|
||||
isGenerating: boolean;
|
||||
currentPrompt: string;
|
||||
temperature: number;
|
||||
seed: number | null;
|
||||
|
||||
// 历史记录和变体
|
||||
selectedGenerationId: string | null;
|
||||
selectedEditId: string | null;
|
||||
showHistory: boolean;
|
||||
|
||||
// 面板可见性
|
||||
showPromptPanel: boolean;
|
||||
|
||||
// UI状态
|
||||
selectedTool: 'generate' | 'edit' | 'mask';
|
||||
|
||||
// 存储Blob对象的Map
|
||||
blobStore: Map<string, Blob>;
|
||||
|
||||
// 操作
|
||||
setCurrentProject: (project: LightweightProject | null) => void;
|
||||
setCanvasImage: (url: string | null) => void;
|
||||
setCanvasZoom: (zoom: number) => void;
|
||||
setCanvasPan: (pan: { x: number; y: number }) => void;
|
||||
|
||||
addUploadedImage: (url: string) => void;
|
||||
removeUploadedImage: (index: number) => void;
|
||||
clearUploadedImages: () => void;
|
||||
|
||||
addEditReferenceImage: (url: string) => void;
|
||||
removeEditReferenceImage: (index: number) => void;
|
||||
clearEditReferenceImages: () => void;
|
||||
|
||||
addBrushStroke: (stroke: BrushStroke) => void;
|
||||
clearBrushStrokes: () => void;
|
||||
setBrushSize: (size: number) => void;
|
||||
setShowMasks: (show: boolean) => void;
|
||||
|
||||
setIsGenerating: (generating: boolean) => void;
|
||||
setCurrentPrompt: (prompt: string) => void;
|
||||
setTemperature: (temp: number) => void;
|
||||
setSeed: (seed: number | null) => void;
|
||||
|
||||
addGeneration: (generation: Generation) => void;
|
||||
addEdit: (edit: Edit) => void;
|
||||
removeGeneration: (id: string) => void;
|
||||
removeEdit: (id: string) => void;
|
||||
selectGeneration: (id: string | null) => void;
|
||||
selectEdit: (id: string | null) => void;
|
||||
setShowHistory: (show: boolean) => void;
|
||||
|
||||
setShowPromptPanel: (show: boolean) => void;
|
||||
|
||||
setSelectedTool: (tool: 'generate' | 'edit' | 'mask') => void;
|
||||
|
||||
// Blob存储操作
|
||||
addBlob: (blob: Blob) => string;
|
||||
getBlob: (url: string) => Blob | undefined;
|
||||
cleanupOldHistory: () => void;
|
||||
|
||||
// Blob URL清理操作
|
||||
revokeBlobUrls: (urls: string[]) => void;
|
||||
cleanupAllBlobUrls: () => void;
|
||||
|
||||
// 定期清理Blob URL
|
||||
scheduleBlobCleanup: () => void;
|
||||
}
|
||||
|
||||
// 限制历史记录数量
|
||||
const MAX_HISTORY_ITEMS = 50;
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
// 初始状态
|
||||
currentProject: null,
|
||||
canvasImage: null,
|
||||
canvasZoom: 1,
|
||||
canvasPan: { x: 0, y: 0 },
|
||||
|
||||
uploadedImages: [],
|
||||
editReferenceImages: [],
|
||||
|
||||
brushStrokes: [],
|
||||
brushSize: 20,
|
||||
showMasks: true,
|
||||
|
||||
isGenerating: false,
|
||||
currentPrompt: '',
|
||||
temperature: 1,
|
||||
seed: null,
|
||||
|
||||
selectedGenerationId: null,
|
||||
selectedEditId: null,
|
||||
showHistory: true,
|
||||
|
||||
showPromptPanel: true,
|
||||
|
||||
selectedTool: 'generate',
|
||||
|
||||
// Blob存储(不在持久化中保存)
|
||||
blobStore: new Map(),
|
||||
|
||||
// 操作
|
||||
setCurrentProject: (project) => set({ currentProject: project }),
|
||||
setCanvasImage: (url) => set({ canvasImage: url }),
|
||||
setCanvasZoom: (zoom) => set({ canvasZoom: zoom }),
|
||||
setCanvasPan: (pan) => set({ canvasPan: pan }),
|
||||
|
||||
addUploadedImage: (url) => set((state) => ({
|
||||
uploadedImages: [...state.uploadedImages, url]
|
||||
})),
|
||||
removeUploadedImage: (index) => set((state) => ({
|
||||
uploadedImages: state.uploadedImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearUploadedImages: () => set({ uploadedImages: [] }),
|
||||
|
||||
addEditReferenceImage: (url) => set((state) => ({
|
||||
editReferenceImages: [...state.editReferenceImages, url]
|
||||
})),
|
||||
removeEditReferenceImage: (index) => set((state) => ({
|
||||
editReferenceImages: state.editReferenceImages.filter((_, i) => i !== index)
|
||||
})),
|
||||
clearEditReferenceImages: () => set({ editReferenceImages: [] }),
|
||||
|
||||
addBrushStroke: (stroke) => set((state) => ({
|
||||
brushStrokes: [...state.brushStrokes, stroke]
|
||||
})),
|
||||
clearBrushStrokes: () => set({ brushStrokes: [] }),
|
||||
setBrushSize: (size) => set({ brushSize: size }),
|
||||
setShowMasks: (show) => set({ showMasks: show }),
|
||||
|
||||
setIsGenerating: (generating) => set({ isGenerating: generating }),
|
||||
setCurrentPrompt: (prompt) => set({ currentPrompt: prompt }),
|
||||
setTemperature: (temp) => set({ temperature: temp }),
|
||||
setSeed: (seed) => set({ seed: seed }),
|
||||
|
||||
// 添加Blob到存储并返回URL
|
||||
addBlob: (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
set((state) => {
|
||||
const newBlobStore = new Map(state.blobStore);
|
||||
newBlobStore.set(url, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
return url;
|
||||
},
|
||||
|
||||
// 从存储中获取Blob
|
||||
getBlob: (url: string) => {
|
||||
const state = get();
|
||||
return state.blobStore.get(url);
|
||||
},
|
||||
|
||||
addGeneration: (generation) => {
|
||||
// 保存到IndexedDB
|
||||
indexedDBService.addGeneration(generation).catch(err => {
|
||||
console.error('保存生成记录到IndexedDB失败:', err);
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
// 将base64图像数据转换为Blob并存储
|
||||
const sourceAssets = generation.sourceAssets.map(asset => {
|
||||
if (asset.url.startsWith('data:')) {
|
||||
// 从base64创建Blob
|
||||
const base64 = asset.url.split(',')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 存储Blob对象
|
||||
set((innerState) => {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(blobUrl, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum,
|
||||
blobUrl
|
||||
};
|
||||
} else if (asset.url.startsWith('blob:')) {
|
||||
// 如果已经是Blob URL,直接使用
|
||||
// 同时确保存储在blobStore中
|
||||
set((innerState) => {
|
||||
const blob = innerState.blobStore.get(asset.url);
|
||||
if (blob) {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(asset.url, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
}
|
||||
return innerState;
|
||||
});
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum,
|
||||
blobUrl: asset.url
|
||||
};
|
||||
}
|
||||
// 对于其他URL类型,直接使用URL
|
||||
return {
|
||||
id: asset.id,
|
||||
type: asset.type,
|
||||
mime: asset.mime,
|
||||
width: asset.width,
|
||||
height: asset.height,
|
||||
checksum: asset.checksum,
|
||||
blobUrl: asset.url
|
||||
};
|
||||
});
|
||||
|
||||
// 将输出资产转换为Blob URL
|
||||
const outputAssetsBlobUrls = generation.outputAssets.map(asset => {
|
||||
if (asset.url.startsWith('data:')) {
|
||||
// 从base64创建Blob
|
||||
const base64 = asset.url.split(',')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 存储Blob对象
|
||||
set((innerState) => {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(blobUrl, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
|
||||
return blobUrl;
|
||||
} else if (asset.url.startsWith('blob:')) {
|
||||
// 如果已经是Blob URL,直接使用
|
||||
return asset.url;
|
||||
}
|
||||
// 对于其他URL类型,直接使用
|
||||
return asset.url;
|
||||
});
|
||||
|
||||
// 创建轻量级生成记录
|
||||
const lightweightGeneration = {
|
||||
id: generation.id,
|
||||
prompt: generation.prompt,
|
||||
parameters: generation.parameters,
|
||||
sourceAssets,
|
||||
outputAssetsBlobUrls,
|
||||
modelVersion: generation.modelVersion,
|
||||
timestamp: generation.timestamp,
|
||||
uploadResults: generation.uploadResults,
|
||||
usageMetadata: generation.usageMetadata
|
||||
};
|
||||
|
||||
const updatedProject = state.currentProject ? {
|
||||
...state.currentProject,
|
||||
generations: [...state.currentProject.generations, lightweightGeneration],
|
||||
updatedAt: Date.now()
|
||||
} : {
|
||||
// 如果没有项目,创建一个新项目包含此生成记录
|
||||
id: generateId(),
|
||||
title: '未命名项目',
|
||||
generations: [lightweightGeneration],
|
||||
edits: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
// 清理旧记录以保持在限制内
|
||||
if (updatedProject.generations.length > MAX_HISTORY_ITEMS) {
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const generationsToRemove = updatedProject.generations.slice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
|
||||
generationsToRemove.forEach(gen => {
|
||||
gen.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
gen.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 清理数组
|
||||
updatedProject.generations.splice(0, updatedProject.generations.length - MAX_HISTORY_ITEMS);
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addEdit: (edit) => {
|
||||
// 保存到IndexedDB
|
||||
indexedDBService.addEdit(edit).catch(err => {
|
||||
console.error('保存编辑记录到IndexedDB失败:', err);
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
// 将遮罩参考资产转换为Blob URL(如果存在)
|
||||
let maskReferenceAssetBlobUrl: string | undefined;
|
||||
if (edit.maskReferenceAsset && edit.maskReferenceAsset.url.startsWith('data:')) {
|
||||
const base64 = edit.maskReferenceAsset.url.split(',')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = edit.maskReferenceAsset.url.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
maskReferenceAssetBlobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 存储Blob对象
|
||||
set((innerState) => {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(maskReferenceAssetBlobUrl!, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
} else if (edit.maskReferenceAsset) {
|
||||
maskReferenceAssetBlobUrl = edit.maskReferenceAsset.url;
|
||||
}
|
||||
|
||||
// 将输出资产转换为Blob URL
|
||||
const outputAssetsBlobUrls = edit.outputAssets.map(asset => {
|
||||
if (asset.url.startsWith('data:')) {
|
||||
// 从base64创建Blob
|
||||
const base64 = asset.url.split(',')[1];
|
||||
const byteString = atob(base64);
|
||||
const mimeString = asset.url.split(',')[0].split(':')[1].split(';')[0];
|
||||
const ab = new ArrayBuffer(byteString.length);
|
||||
const ia = new Uint8Array(ab);
|
||||
for (let i = 0; i < byteString.length; i++) {
|
||||
ia[i] = byteString.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([ab], { type: mimeString });
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 存储Blob对象
|
||||
set((innerState) => {
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.set(blobUrl, blob);
|
||||
return { blobStore: newBlobStore };
|
||||
});
|
||||
|
||||
return blobUrl;
|
||||
} else if (asset.url.startsWith('blob:')) {
|
||||
// 如果已经是Blob URL,直接使用
|
||||
return asset.url;
|
||||
}
|
||||
// 对于其他URL类型,直接使用
|
||||
return asset.url;
|
||||
});
|
||||
|
||||
// 创建轻量级编辑记录
|
||||
const lightweightEdit = {
|
||||
id: edit.id,
|
||||
parentGenerationId: edit.parentGenerationId,
|
||||
maskAssetId: edit.maskAssetId,
|
||||
maskReferenceAssetBlobUrl,
|
||||
instruction: edit.instruction,
|
||||
outputAssetsBlobUrls,
|
||||
timestamp: edit.timestamp,
|
||||
uploadResults: edit.uploadResults,
|
||||
parameters: edit.parameters,
|
||||
usageMetadata: edit.usageMetadata
|
||||
};
|
||||
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
const updatedProject = {
|
||||
...state.currentProject,
|
||||
edits: [...state.currentProject.edits, lightweightEdit],
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
// 清理旧记录以保持在限制内
|
||||
if (updatedProject.edits.length > MAX_HISTORY_ITEMS) {
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const editsToRemove = updatedProject.edits.slice(0, updatedProject.edits.length - MAX_HISTORY_ITEMS);
|
||||
editsToRemove.forEach(edit => {
|
||||
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
|
||||
}
|
||||
edit.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 清理数组
|
||||
updatedProject.edits.splice(0, updatedProject.edits.length - MAX_HISTORY_ITEMS);
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
selectGeneration: (id) => set({ selectedGenerationId: id }),
|
||||
selectEdit: (id) => set({ selectedEditId: id }),
|
||||
setShowHistory: (show) => set({ showHistory: show }),
|
||||
|
||||
setShowPromptPanel: (show) => set({ showPromptPanel: show }),
|
||||
|
||||
setSelectedTool: (tool) => set({ selectedTool: tool }),
|
||||
|
||||
// 删除生成记录
|
||||
removeGeneration: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const generationToRemove = state.currentProject.generations.find(gen => gen.id === id);
|
||||
|
||||
if (generationToRemove) {
|
||||
// 收集要删除的生成记录中的Blob URLs
|
||||
generationToRemove.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
generationToRemove.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 从项目中移除生成记录
|
||||
const updatedProject = {
|
||||
...state.currentProject,
|
||||
generations: state.currentProject.generations.filter(gen => gen.id !== id),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
}),
|
||||
|
||||
// 删除编辑记录
|
||||
removeEdit: (id) => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
const editToRemove = state.currentProject.edits.find(edit => edit.id === id);
|
||||
|
||||
if (editToRemove) {
|
||||
// 收集要删除的编辑记录中的Blob URLs
|
||||
if (editToRemove.maskReferenceAssetBlobUrl && editToRemove.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(editToRemove.maskReferenceAssetBlobUrl);
|
||||
}
|
||||
editToRemove.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 从项目中移除编辑记录
|
||||
const updatedProject = {
|
||||
...state.currentProject,
|
||||
edits: state.currentProject.edits.filter(edit => edit.id !== id),
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
return {
|
||||
currentProject: updatedProject
|
||||
};
|
||||
}),
|
||||
|
||||
// 清理旧的历史记录
|
||||
cleanupOldHistory: () => set((state) => {
|
||||
if (!state.currentProject) return {};
|
||||
|
||||
const generations = [...state.currentProject.generations];
|
||||
const edits = [...state.currentProject.edits];
|
||||
|
||||
// 收集需要释放的Blob URLs
|
||||
const urlsToRevoke: string[] = [];
|
||||
|
||||
// 如果生成记录超过限制,只保留最新的记录
|
||||
if (generations.length > MAX_HISTORY_ITEMS) {
|
||||
const generationsToRemove = generations.slice(0, generations.length - MAX_HISTORY_ITEMS);
|
||||
generationsToRemove.forEach(gen => {
|
||||
gen.sourceAssets.forEach(asset => {
|
||||
if (asset.blobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(asset.blobUrl);
|
||||
}
|
||||
});
|
||||
gen.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
generations.splice(0, generations.length - MAX_HISTORY_ITEMS);
|
||||
}
|
||||
|
||||
// 如果编辑记录超过限制,只保留最新的记录
|
||||
if (edits.length > MAX_HISTORY_ITEMS) {
|
||||
const editsToRemove = edits.slice(0, edits.length - MAX_HISTORY_ITEMS);
|
||||
editsToRemove.forEach(edit => {
|
||||
if (edit.maskReferenceAssetBlobUrl && edit.maskReferenceAssetBlobUrl.startsWith('blob:')) {
|
||||
urlsToRevoke.push(edit.maskReferenceAssetBlobUrl);
|
||||
}
|
||||
edit.outputAssetsBlobUrls.forEach(url => {
|
||||
if (url.startsWith('blob:')) {
|
||||
urlsToRevoke.push(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
edits.splice(0, edits.length - MAX_HISTORY_ITEMS);
|
||||
}
|
||||
|
||||
// 释放Blob URLs
|
||||
if (urlsToRevoke.length > 0) {
|
||||
set((innerState) => {
|
||||
urlsToRevoke.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(innerState.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
innerState = { ...innerState, blobStore: newBlobStore };
|
||||
});
|
||||
return innerState;
|
||||
});
|
||||
}
|
||||
|
||||
// 同时清理IndexedDB中的旧记录
|
||||
indexedDBService.cleanupOldRecords(MAX_HISTORY_ITEMS).catch(err => {
|
||||
console.error('清理IndexedDB旧记录失败:', err);
|
||||
});
|
||||
|
||||
return {
|
||||
currentProject: {
|
||||
...state.currentProject,
|
||||
generations,
|
||||
edits,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
};
|
||||
}),
|
||||
|
||||
// 释放指定的Blob URLs
|
||||
revokeBlobUrls: (urls: string[]) => set((state) => {
|
||||
urls.forEach(url => {
|
||||
if (state.blobStore.has(url)) {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(state.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
state = { ...state, blobStore: newBlobStore };
|
||||
}
|
||||
});
|
||||
return state;
|
||||
}),
|
||||
|
||||
// 释放所有Blob URLs
|
||||
cleanupAllBlobUrls: () => set((state) => {
|
||||
state.blobStore.forEach((_, url) => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
return { ...state, blobStore: new Map() };
|
||||
}),
|
||||
|
||||
// 定期清理Blob URL
|
||||
scheduleBlobCleanup: () => {
|
||||
// 清理超过10分钟未使用的Blob
|
||||
const state = get();
|
||||
const now = Date.now();
|
||||
|
||||
state.blobStore.forEach((blob, url) => {
|
||||
// 检查URL是否仍在使用中
|
||||
const isUsedInProject = state.currentProject && (
|
||||
state.currentProject.generations.some(gen =>
|
||||
gen.sourceAssets.some(asset => asset.blobUrl === url) ||
|
||||
gen.outputAssetsBlobUrls.some(outputUrl => outputUrl === url)
|
||||
) ||
|
||||
state.currentProject.edits.some(edit =>
|
||||
(edit.maskReferenceAssetBlobUrl === url) ||
|
||||
edit.outputAssetsBlobUrls.some(outputUrl => outputUrl === url)
|
||||
)
|
||||
);
|
||||
|
||||
const isUsedInCanvas = state.canvasImage === url;
|
||||
const isUsedInUploads = state.uploadedImages.includes(url);
|
||||
const isUsedInEdits = state.editReferenceImages.includes(url);
|
||||
|
||||
// 如果Blob没有被使用,则清理它
|
||||
if (!isUsedInProject && !isUsedInCanvas && !isUsedInUploads && !isUsedInEdits) {
|
||||
URL.revokeObjectURL(url);
|
||||
const newBlobStore = new Map(state.blobStore);
|
||||
newBlobStore.delete(url);
|
||||
set({ blobStore: newBlobStore });
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
{
|
||||
name: 'nano-banana-store',
|
||||
partialize: (state) => ({
|
||||
currentProject: state.currentProject,
|
||||
// 我们只持久化轻量级项目数据,不包含Blob对象
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -1,91 +0,0 @@
|
||||
export interface Asset {
|
||||
id: string;
|
||||
type: 'original' | 'mask' | 'output';
|
||||
url: string;
|
||||
mime: string;
|
||||
width: number;
|
||||
height: number;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface Generation {
|
||||
id: string;
|
||||
prompt: string;
|
||||
parameters: {
|
||||
seed?: number;
|
||||
temperature?: number;
|
||||
aspectRatio?: string;
|
||||
};
|
||||
sourceAssets: Asset[];
|
||||
outputAssets: Asset[];
|
||||
modelVersion: string;
|
||||
timestamp: number;
|
||||
costEstimate?: number;
|
||||
uploadResults?: UploadResult[];
|
||||
usageMetadata?: {
|
||||
totalTokenCount?: number;
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Edit {
|
||||
id: string;
|
||||
parentGenerationId: string;
|
||||
maskAssetId?: string;
|
||||
maskReferenceAsset?: Asset;
|
||||
instruction: string;
|
||||
outputAssets: Asset[];
|
||||
timestamp: number;
|
||||
uploadResults?: UploadResult[];
|
||||
parameters?: {
|
||||
seed?: number;
|
||||
temperature?: number;
|
||||
};
|
||||
usageMetadata?: {
|
||||
totalTokenCount?: number;
|
||||
promptTokenCount?: number;
|
||||
candidatesTokenCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
generations: Generation[];
|
||||
edits: Edit[];
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface SegmentationMask {
|
||||
id: string;
|
||||
imageData: ImageData;
|
||||
bounds: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
feather: number;
|
||||
}
|
||||
|
||||
export interface BrushStroke {
|
||||
id: string;
|
||||
points: number[];
|
||||
brushSize: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface PromptHint {
|
||||
category: 'subject' | 'scene' | 'action' | 'style' | 'camera';
|
||||
text: string;
|
||||
example: string;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
export function base64ToBlob(base64: string, mimeType: string = 'image/png'): Blob {
|
||||
const byteCharacters = atob(base64);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
return new Blob([byteArray], { type: mimeType });
|
||||
}
|
||||
|
||||
export function blobToBase64(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1]; // Remove data:image/png;base64, prefix
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// 将URL转换为Blob
|
||||
export async function urlToBlob(url: string): Promise<Blob> {
|
||||
const response = await fetch(url);
|
||||
return await response.blob();
|
||||
}
|
||||
|
||||
export function createImageFromBase64(base64: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = reject;
|
||||
img.src = `data:image/png;base64,${base64}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function resizeImageToFit(
|
||||
image: HTMLImageElement,
|
||||
maxWidth: number,
|
||||
maxHeight: number
|
||||
): { width: number; height: number } {
|
||||
const ratio = Math.min(maxWidth / image.width, maxHeight / image.height);
|
||||
return {
|
||||
width: image.width * ratio,
|
||||
height: image.height * ratio
|
||||
};
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
export function downloadImage(imageData: string, filename: string): void {
|
||||
if (imageData.startsWith('blob:')) {
|
||||
// 对于Blob URL,我们需要获取实际的Blob数据
|
||||
fetch(imageData)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
} else if (imageData.startsWith('data:')) {
|
||||
// 对于数据URL,直接下载
|
||||
const a = document.createElement('a');
|
||||
a.href = imageData;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
// 对于其他URL,获取并转换为blob
|
||||
fetch(imageData)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 优化的图像压缩函数
|
||||
export async function compressImage(blob: Blob, quality: number = 0.8): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (!ctx) {
|
||||
reject(new Error('无法获取canvas上下文'));
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// 设置canvas尺寸
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// 绘制图像
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// 转换为Blob
|
||||
canvas.toBlob(
|
||||
(compressedBlob) => {
|
||||
if (compressedBlob) {
|
||||
resolve(compressedBlob);
|
||||
} else {
|
||||
reject(new Error('图像压缩失败'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
quality
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = reject;
|
||||
|
||||
// 将Blob转换为URL以便加载到图像中
|
||||
const url = URL.createObjectURL(blob);
|
||||
img.src = url;
|
||||
|
||||
// 清理URL
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
// 调用原始的onload处理程序
|
||||
if (img.onload) {
|
||||
(img.onload as any).call(img);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
1
v1/src/vite-env.d.ts
vendored
1
v1/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user