From 9a5e4d8041e899a13ebbbaf0fa389d82082f9e82 Mon Sep 17 00:00:00 2001 From: yuantao Date: Fri, 19 Sep 2025 16:31:09 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E4=BA=86IFLOW=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E6=96=87=E4=BB=B6=EF=BC=9B=20=E4=BC=98=E5=8C=96=20?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BA=86=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=82=AC=E6=B5=AE=E7=AA=97=E7=9A=84=E6=98=BE=E7=A4=BA=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IFLOW.md | 142 +++++++++++++++++ src/App.tsx | 36 ++++- src/components/HistoryPanel.tsx | 274 +------------------------------- src/services/uploadService.ts | 232 +++++++++++++-------------- 4 files changed, 297 insertions(+), 387 deletions(-) create mode 100644 IFLOW.md diff --git a/IFLOW.md b/IFLOW.md new file mode 100644 index 0000000..2b459ea --- /dev/null +++ b/IFLOW.md @@ -0,0 +1,142 @@ +# Nano Banana AI Image Editor - iFlow 文档 + +## 项目概述 + +Nano Banana AI Image Editor 是一个基于 React 的 AI 图像编辑工具,用户可以通过直观的界面与 Google 的 Gemini AI 模型进行交互,实现图像生成和编辑功能。 + +## 技术栈 + +- **核心框架**: React 18.x (TypeScript) +- **构建工具**: Vite 5.x +- **语言**: TypeScript (ES2020) +- **状态管理**: Zustand +- **数据获取**: TanStack Query (React Query) +- **UI 框架**: + - Tailwind CSS 3.x (样式框架) + - Radix UI (无样式的可访问 UI 组件) + - Lucide React (图标库) +- **图像处理**: + - Konva (2D 画布库) + - react-konva (Konva 的 React 封装) +- **本地存储**: IndexedDB (通过 idb-keyval 库操作) +- **AI 集成**: Google Generative AI SDK (@google/genai) +- **工具库**: + - class-variance-authority (组件变体管理) + - clsx + tailwind-merge (CSS 类名合并) + - fabric.js (备用画布库) + +## 代码风格和命名规范 + +### 代码风格 +- 使用 TypeScript 严格模式 (strict: true) +- 函数式组件为主,使用 React Hooks +- 组件文件使用 .tsx 扩展名 +- 工具函数文件使用 .ts 扩展名 +- 使用 ESLint 进行代码检查 +- 启用严格的 TypeScript 编译选项 + +### 命名规范 +- 组件文件和组件名使用 PascalCase (如 `Header.tsx`, `ImageCanvas`) +- 工具函数和普通文件使用 camelCase (如 `imageUtils.ts`, `useAppStore.ts`) +- 常量使用 UPPER_SNAKE_CASE +- 变量和函数使用 camelCase +- 组件 Props 接口命名为组件名 + Props (如 `ButtonProps`) +- Hook 函数以 use 开头 (如 `useAppStore`, `useImageGeneration`) + +## 样式和 UI 框架 + +### Tailwind CSS +- 使用自定义颜色方案,以香蕉黄 (banana) 为主题色 +- 定义了 light 主题颜色 (背景、面板、边框、文字等) +- 使用自定义间距、阴影和动画 +- 使用 tailwind-merge 和 clsx 进行类名合并 + +### 组件设计 +- 使用 Radix UI 构建无样式的可访问组件 +- 自定义 UI 组件位于 `src/components/ui` 目录 +- 组件变体使用 class-variance-authority 管理 +- 图标使用 Lucide React + +## 项目结构 + +``` +Nano-Banana-AI-Image-Editor/ +├── src/ +│ ├── components/ # React 组件 +│ │ ├── ui/ # 基础 UI 组件 +│ │ ├── Header.tsx # 应用头部 +│ │ ├── ImageCanvas.tsx # 图像画布 +│ │ ├── PromptComposer.tsx # 提示词编辑器 +│ │ ├── HistoryPanel.tsx # 历史记录面板 +│ │ └── ... # 其他组件 +│ ├── hooks/ # 自定义 React Hooks +│ │ ├── useImageGeneration.ts # 图像生成 Hook +│ │ ├── useIndexedDBListener.ts # IndexedDB 监听 Hook +│ │ └── useKeyboardShortcuts.ts # 键盘快捷键 Hook +│ ├── services/ # 业务逻辑服务 +│ │ ├── geminiService.ts # Gemini AI 服务 +│ │ ├── indexedDBService.ts # IndexedDB 操作服务 +│ │ ├── uploadService.ts # 文件上传服务 +│ │ └── cacheService.ts # 缓存服务 +│ ├── store/ # 状态管理 +│ │ └── useAppStore.ts # 全局状态管理 (Zustand) +│ ├── utils/ # 工具函数 +│ │ ├── cn.ts # 类名合并工具 +│ │ └── imageUtils.ts # 图像处理工具 +│ ├── types/ # TypeScript 类型定义 +│ ├── App.tsx # 根组件 +│ ├── main.tsx # 应用入口 +│ └── vite-env.d.ts # Vite 类型声明 +├── public/ # 静态资源 +├── node_modules/ # 依赖包 +├── index.html # HTML 模板 +├── package.json # 项目配置 +├── tsconfig.json # TypeScript 配置 +├── vite.config.ts # Vite 配置 +├── tailwind.config.js # Tailwind 配置 +├── postcss.config.js # PostCSS 配置 +├── eslint.config.js # ESLint 配置 +└── README.md # 项目说明 +``` + +## 核心功能模块 + +### 1. 图像画布 (ImageCanvas) +- 使用 Konva 和 react-konva 实现图像显示和编辑 +- 支持图像缩放、平移 +- 实现画笔工具进行遮罩绘制 +- 支持图像下载功能 + +### 2. 提示词编辑 (PromptComposer) +- 用户输入提示词生成图像 +- 提供提示词建议功能 +- 集成 AI 模型参数调整 (如风格、质量等) + +### 3. 历史记录 (HistoryPanel) +- 显示生成的图像历史 +- 支持历史图像的查看和重新编辑 +- 使用 IndexedDB 存储历史数据 + +### 4. 状态管理 (useAppStore) +- 使用 Zustand 管理全局状态 +- 存储画布状态、用户设置、历史记录等 +- 提供状态操作方法 + +### 5. AI 服务 (geminiService) +- 集成 Google Gemini AI 模型 +- 实现图像生成和编辑功能 +- 处理与 AI 模型的交互 + +## 开发环境配置 + +1. 安装依赖: `npm install` +2. 启动开发服务器: `npm run dev` +3. 构建生产版本: `npm run build` +4. 代码检查: `npm run lint` + +## 注意事项 + +- 项目使用 IndexedDB 存储图像数据,需要注意存储空间管理 +- AI 功能需要配置 Google API 密钥 +- 图像处理功能依赖浏览器 Canvas API +- 移动端适配需要特别关注界面布局和交互 \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e494eb8..143c1ab 100644 --- a/src/App.tsx +++ b/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'; @@ -23,6 +23,7 @@ function AppContent() { useKeyboardShortcuts(); const { showPromptPanel, setShowPromptPanel, setShowHistory } = useAppStore(); + const [hoveredImage, setHoveredImage] = useState<{url: string, title: string, width?: number, height?: number} | null>(null); // 在挂载时初始化IndexedDB并清理base64数据 useEffect(() => { @@ -78,7 +79,7 @@ function AppContent() {
-
+
@@ -91,10 +92,39 @@ function AppContent() {
- +
+ + {/* 悬浮预览 */} + {hoveredImage && ( +
+
+
+ {hoveredImage.title} +
+
+ 预览 +
+ {/* 图像信息 */} +
+ {hoveredImage.width && hoveredImage.height && ( +
+ 尺寸: + {hoveredImage.width} × {hoveredImage.height} +
+ )} +
+
+
+ )}
); } diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index 37ef7bd..8e460a4 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -9,7 +9,7 @@ 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 }> = ({ setHoveredImage }) => { const { currentProject, canvasImage, @@ -63,9 +63,7 @@ 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 generations = currentProject?.generations || []; const edits = currentProject?.edits || []; @@ -536,49 +534,6 @@ 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; - } - - // 确保预览窗口不会超出下边界 - 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); @@ -589,89 +544,12 @@ 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; - } - - // 确保预览窗口不会超出下边界 - 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={() => { setHoveredImage(null); @@ -753,42 +631,6 @@ 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; - } - - // 确保预览窗口不会超出下边界 - 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); @@ -799,85 +641,12 @@ 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; - } - - // 确保预览窗口不会超出下边界 - 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={() => { setHoveredImage(null); @@ -1188,37 +957,6 @@ export const HistoryPanel: React.FC = () => { title={previewModal.title} description={previewModal.description} /> - - {/* 悬浮预览 */} - {hoveredImage && ( -
-
- {hoveredImage.title} -
- 预览 - {/* 图像信息 */} -
- {hoveredImage.width && hoveredImage.height && ( -
- 尺寸: - {hoveredImage.width} × {hoveredImage.height} -
- )} -
-
- )}
); }; \ No newline at end of file diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index abe1f8c..3e973cf 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -2,31 +2,31 @@ import { UploadResult } from '../types' // 上传接口URL -const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload' +const UPLOAD_URL = import.meta.env.VITE_UPLOAD_API // 创建一个Map来缓存已上传的图像 const uploadCache = new Map() // 缓存配置 -const MAX_CACHE_SIZE = 50; // 减少最大缓存条目数 -const CACHE_EXPIRY_TIME = 15 * 60 * 1000; // 缓存过期时间15分钟 +const MAX_CACHE_SIZE = 50 // 减少最大缓存条目数 +const CACHE_EXPIRY_TIME = 15 * 60 * 1000 // 缓存过期时间15分钟 /** * 清理过期的缓存条目 */ function cleanupExpiredCache(): void { - const now = Date.now(); - let deletedCount = 0; - + const now = Date.now() + let deletedCount = 0 + uploadCache.forEach((value, key) => { if (now - value.timestamp > CACHE_EXPIRY_TIME) { - uploadCache.delete(key); - deletedCount++; + uploadCache.delete(key) + deletedCount++ } - }); - + }) + if (deletedCount > 0) { - console.log(`清除了 ${deletedCount} 个过期的缓存条目`); + console.log(`清除了 ${deletedCount} 个过期的缓存条目`) } } @@ -37,16 +37,16 @@ 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 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%的条目 + 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]); + uploadCache.delete(entries[i][0]) } - - console.log(`缓存已满,删除了 ${deleteCount} 个最旧的条目`); + + console.log(`缓存已满,删除了 ${deleteCount} 个最旧的条目`) } } @@ -60,17 +60,17 @@ function getImageHash(imageData: string): string { if (imageData.startsWith('blob:')) { // 对于Blob URL,我们使用URL本身作为标识符的一部分 // 这不是完美的解决方案,但对于大多数情况足够了 - return btoa(imageData).slice(0, 32); + return btoa(imageData).slice(0, 32) } - + // 对于base64数据,使用简单的哈希函数生成图像标识符 - let hash = 0; + 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位整数 + const char = imageData.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // 转换为32位整数 } - return hash.toString(); + return hash.toString() } /** @@ -81,37 +81,37 @@ function getImageHash(imageData: string): string { async function getBlobFromUrl(blobUrl: string): Promise { try { // 从AppStore获取Blob - const { useAppStore } = await import('../store/useAppStore'); - const blob = useAppStore.getState().getBlob(blobUrl); - + const { useAppStore } = await import('../store/useAppStore') + const blob = useAppStore.getState().getBlob(blobUrl) + if (!blob) { // 如果AppStore中没有找到Blob,尝试从URL获取 - console.warn('无法从AppStore获取Blob,尝试从URL获取:', blobUrl); + console.warn('无法从AppStore获取Blob,尝试从URL获取:', blobUrl) try { - const response = await fetch(blobUrl); + const response = await fetch(blobUrl) if (!response.ok) { - throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`); + throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`) } - return await response.blob(); + return await response.blob() } catch (error) { - console.error('从URL获取Blob失败:', error); - throw new Error('无法从Blob URL获取图像数据'); + console.error('从URL获取Blob失败:', error) + throw new Error('无法从Blob URL获取图像数据') } } - - return blob; + + return blob } catch (error) { - console.error('从AppStore获取Blob时出错:', error); + console.error('从AppStore获取Blob时出错:', error) // 如果导入AppStore失败,直接尝试从URL获取 try { - const response = await fetch(blobUrl); + const response = await fetch(blobUrl) if (!response.ok) { - throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`); + throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`) } - return await response.blob(); + return await response.blob() } catch (fetchError) { - console.error('从URL获取Blob失败:', fetchError); - throw new Error('无法从Blob URL获取图像数据'); + console.error('从URL获取Blob失败:', fetchError) + throw new Error('无法从Blob URL获取图像数据') } } } @@ -125,118 +125,118 @@ async function getBlobFromUrl(blobUrl: string): Promise { */ 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(); - + const imageHash = typeof imageData === 'string' ? getImageHash(imageData) : 'blob-' + Date.now() + if (!skipCache && typeof imageData === 'string' && uploadCache.has(imageHash)) { - const cachedResult = uploadCache.get(imageHash)!; + const cachedResult = uploadCache.get(imageHash)! // 检查缓存是否过期 if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) { - console.log('从缓存中获取上传结果'); - return cachedResult; + console.log('从缓存中获取上传结果') + return cachedResult } else { // 缓存过期,删除它 - uploadCache.delete(imageHash); + uploadCache.delete(imageHash) } } try { - let blob: Blob; - + let blob: Blob + if (typeof imageData === 'string') { if (imageData.startsWith('blob:')) { // 从Blob URL获取Blob数据 - blob = await getBlobFromUrl(imageData); + blob = await getBlobFromUrl(imageData) } else if (imageData.includes('base64,')) { // 从base64数据创建Blob - const base64Data = imageData.split('base64,')[1]; - const byteString = atob(base64Data); - const mimeString = 'image/png'; // 默认MIME类型 - const ab = new ArrayBuffer(byteString.length); - const ia = new Uint8Array(ab); + const base64Data = imageData.split('base64,')[1] + const byteString = atob(base64Data) + const mimeString = '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); + ia[i] = byteString.charCodeAt(i) } - blob = new Blob([ab], { type: mimeString }); + blob = new Blob([ab], { type: mimeString }) } else { // 从URL获取Blob - const response = await fetch(imageData); - blob = await response.blob(); + const response = await fetch(imageData) + blob = await response.blob() } } else { // 如果已经是Blob对象,直接使用 - blob = imageData; + blob = imageData } // 创建FormData对象 - const formData = new FormData(); - formData.append('file', blob, 'generated-image.png'); + const formData = new FormData() + formData.append('file', blob, 'generated-image.png') // 发送POST请求 const response = await fetch(UPLOAD_URL, { method: 'POST', - headers: { - 'accessToken': accessToken, + headers: { + accessToken: accessToken, // 添加其他可能需要的头部 }, body: formData, - }); + }) // 记录响应状态以帮助调试 - console.log('上传响应状态:', response.status, response.statusText); - + 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 errorText = await response.text() + console.error('上传失败响应内容:', errorText) + throw new Error(`上传失败: ${response.status} ${response.statusText} - ${errorText}`) } - const result = await response.json(); - console.log('上传响应结果:', result); - + 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; - + const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || '' + const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data + // 清理过期缓存 - cleanupExpiredCache(); - + cleanupExpiredCache() + // 维护缓存大小 - maintainCacheSize(); - + maintainCacheSize() + // 将上传结果存储到缓存中 - const uploadResult = { success: true, url: fullUrl, error: undefined }; + const uploadResult = { success: true, url: fullUrl, error: undefined } if (typeof imageData === 'string') { uploadCache.set(imageHash, { ...uploadResult, - timestamp: Date.now() - }); + timestamp: Date.now(), + }) } - - return uploadResult; + + return uploadResult } else { - throw new Error(`上传失败: ${result.msg}`); + throw new Error(`上传失败: ${result.msg}`) } } catch (error) { - console.error('上传图像时出错:', error); - const errorResult = { success: false, error: error instanceof Error ? error.message : String(error) }; - + console.error('上传图像时出错:', error) + const errorResult = { success: false, error: error instanceof Error ? error.message : String(error) } + // 清理过期缓存 - cleanupExpiredCache(); - + cleanupExpiredCache() + // 维护缓存大小(即使是失败的结果也缓存,但时间较短) - maintainCacheSize(); - + maintainCacheSize() + // 将失败的上传结果也存储到缓存中(可选) if (typeof imageData === 'string') { uploadCache.set(imageHash, { ...errorResult, - timestamp: Date.now() - }); + timestamp: Date.now(), + }) } - - return errorResult; + + return errorResult } } @@ -249,43 +249,43 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string, */ export const uploadImages = async (imageDatas: (string | Blob)[], accessToken: string, skipCache: boolean = false): Promise => { try { - const results: UploadResult[] = []; + const results: UploadResult[] = [] for (let i = 0; i < imageDatas.length; i++) { - const imageData = imageDatas[i]; + const imageData = imageDatas[i] try { - const uploadResult = await uploadImage(imageData, accessToken, skipCache); + 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); + } + 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); + } + results.push(result) + console.error(`第${i + 1}张图像上传失败:`, error) } } // 检查是否有任何上传失败 - const failedUploads = results.filter(r => !r.success); + const failedUploads = results.filter(r => !r.success) if (failedUploads.length > 0) { - console.warn(`${failedUploads.length}张图像上传失败`); + console.warn(`${failedUploads.length}张图像上传失败`) } else { - console.log(`所有${results.length}张图像上传成功`); + console.log(`所有${results.length}张图像上传成功`) } - return results; + return results } catch (error) { - console.error('批量上传图像时出错:', error); - throw error; + console.error('批量上传图像时出错:', error) + throw error } } @@ -293,6 +293,6 @@ export const uploadImages = async (imageDatas: (string | Blob)[], accessToken: s * 清除上传缓存 */ export const clearUploadCache = (): void => { - uploadCache.clear(); - console.log('上传缓存已清除'); -} \ No newline at end of file + uploadCache.clear() + console.log('上传缓存已清除') +} From 70684b2ddfbabf6f44fc99588a19d8d00a83117a Mon Sep 17 00:00:00 2001 From: yuantao Date: Fri, 19 Sep 2025 17:00:51 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BC=98=E5=8C=96=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E4=BA=86=E5=8E=86=E5=8F=B2=E8=AE=B0=E5=BD=95=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=AA=97=E5=8F=A3=E7=9A=84=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 26 ++++++++++++++++++++++++-- src/components/HistoryPanel.tsx | 27 ++++++++++++++++++++++++++- src/services/uploadService.ts | 2 +- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 143c1ab..f95a8a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,8 @@ function AppContent() { const { showPromptPanel, setShowPromptPanel, 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(() => { @@ -73,6 +75,19 @@ function AppContent() { return () => clearInterval(interval); }, []); + // 控制预览窗口的显示和隐藏动画 + useEffect(() => { + if (hoveredImage) { + // 延迟一小段时间后设置为可见,以触发动画 + const timer = setTimeout(() => { + setIsPreviewVisible(true); + }, 10); + return () => clearTimeout(timer); + } else { + setIsPreviewVisible(false); + } + }, [hoveredImage]); + return (
@@ -92,7 +107,7 @@ function AppContent() {
- +
@@ -102,7 +117,14 @@ function AppContent() {
-
+
{hoveredImage.title}
diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index 8e460a4..63c0caf 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -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<{ setHoveredImage: (image: {url: string, title: string, width?: number, height?: number} | null) => void }> = ({ setHoveredImage }) => { +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, @@ -534,6 +537,10 @@ export const HistoryPanel: React.FC<{ setHoveredImage: (image: {url: string, tit width: img.width, height: img.height }); + // 传递鼠标位置信息给App组件 + if (setPreviewPosition) { + setPreviewPosition({ x: e.clientX, y: e.clientY }); + } }; img.onerror = (error) => { console.error('图像加载失败:', error); @@ -544,6 +551,10 @@ export const HistoryPanel: React.FC<{ setHoveredImage: (image: {url: string, tit width: 0, height: 0 }); + // 传递鼠标位置信息给App组件 + if (setPreviewPosition) { + setPreviewPosition({ x: e.clientX, y: e.clientY }); + } }; img.src = imageUrl; } @@ -553,6 +564,9 @@ export const HistoryPanel: React.FC<{ setHoveredImage: (image: {url: string, tit }} onMouseLeave={() => { setHoveredImage(null); + if (setPreviewPosition) { + setPreviewPosition(null); + } }} > {(() => { @@ -631,6 +645,10 @@ export const HistoryPanel: React.FC<{ setHoveredImage: (image: {url: string, tit width: img.width, height: img.height }); + // 传递鼠标位置信息给App组件 + if (setPreviewPosition) { + setPreviewPosition({ x: e.clientX, y: e.clientY }); + } }; img.onerror = (error) => { console.error('图像加载失败:', error); @@ -641,6 +659,10 @@ export const HistoryPanel: React.FC<{ setHoveredImage: (image: {url: string, tit width: 0, height: 0 }); + // 传递鼠标位置信息给App组件 + if (setPreviewPosition) { + setPreviewPosition({ x: e.clientX, y: e.clientY }); + } }; img.src = imageUrl; } @@ -650,6 +672,9 @@ export const HistoryPanel: React.FC<{ setHoveredImage: (image: {url: string, tit }} onMouseLeave={() => { setHoveredImage(null); + if (setPreviewPosition) { + setPreviewPosition(null); + } }} > {(() => { diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index 3e973cf..8e155fe 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -8,7 +8,7 @@ const UPLOAD_URL = import.meta.env.VITE_UPLOAD_API const uploadCache = new Map() // 缓存配置 -const MAX_CACHE_SIZE = 50 // 减少最大缓存条目数 +const MAX_CACHE_SIZE = 20 // 减少最大缓存条目数 const CACHE_EXPIRY_TIME = 15 * 60 * 1000 // 缓存过期时间15分钟 /** From eae15ced5a1f7becba634193c291dfac0f2842cc Mon Sep 17 00:00:00 2001 From: yuantao Date: Fri, 19 Sep 2025 17:25:46 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=B8=B8=E7=94=A8?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PromptComposer.tsx | 9 ++ src/components/PromptSuggestions.tsx | 120 +++++++++++++++++++++++++++ src/services/uploadService.ts | 25 ++++-- 3 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 src/components/PromptSuggestions.tsx diff --git a/src/components/PromptComposer.tsx b/src/components/PromptComposer.tsx index 001236f..b1adbb5 100644 --- a/src/components/PromptComposer.tsx +++ b/src/components/PromptComposer.tsx @@ -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 = () => { @@ -341,6 +342,14 @@ export const PromptComposer: React.FC = () => { className="min-h-[120px] resize-none text-sm rounded-xl" /> + {/* 常用提示词 */} + { + setCurrentPrompt(currentPrompt ? `${currentPrompt}, ${word}` : word); + }} + minFrequency={3} + /> + {/* 提示质量指示器 */} + )} +
+ +
+ {displayWords.map(({ word, count }) => ( + + ))} +
+ + {frequentWords.length > 20 && ( +
+ 共 {frequentWords.length} 个常用提示词 +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index 8e155fe..b36bdd5 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -60,7 +60,12 @@ function getImageHash(imageData: string): string { if (imageData.startsWith('blob:')) { // 对于Blob URL,我们使用URL本身作为标识符的一部分 // 这不是完美的解决方案,但对于大多数情况足够了 - return btoa(imageData).slice(0, 32) + try { + return btoa(imageData).slice(0, 32) + } catch (e) { + // 如果btoa失败(例如包含非Latin1字符),使用encodeURIComponent + return btoa(encodeURIComponent(imageData)).slice(0, 32) + } } // 对于base64数据,使用简单的哈希函数生成图像标识符 @@ -132,7 +137,12 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string, // 检查缓存是否过期 if (Date.now() - cachedResult.timestamp < CACHE_EXPIRY_TIME) { console.log('从缓存中获取上传结果') - return cachedResult + // 确保返回的数据结构与新上传的结果一致 + return { + success: cachedResult.success, + url: cachedResult.url, + error: cachedResult.error + } } else { // 缓存过期,删除它 uploadCache.delete(imageHash) @@ -150,7 +160,9 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string, // 从base64数据创建Blob const base64Data = imageData.split('base64,')[1] const byteString = atob(base64Data) - const mimeString = 'image/png' // 默认MIME类型 + // 从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++) { @@ -214,13 +226,14 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string, }) } - return uploadResult + return { success: true, url: fullUrl, error: undefined } } else { throw new Error(`上传失败: ${result.msg}`) } } catch (error) { console.error('上传图像时出错:', error) - const errorResult = { success: false, error: error instanceof Error ? error.message : String(error) } + const errorMessage = error instanceof Error ? error.message : String(error) + const errorResult = { success: false, error: errorMessage } // 清理过期缓存 cleanupExpiredCache() @@ -236,7 +249,7 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string, }) } - return errorResult + return { success: false, error: errorMessage } } } From 4b5b1a5ebac1537dcf594750ce9be20f4d068933 Mon Sep 17 00:00:00 2001 From: yuantao Date: Fri, 19 Sep 2025 18:40:43 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20=E5=8E=86=E5=8F=B2?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 10 +- src/components/HistoryPanel.tsx | 148 ++++++++++++++++++++++++++++-- src/components/PromptComposer.tsx | 12 +-- src/hooks/useImageGeneration.ts | 2 +- src/services/uploadService.ts | 29 +----- src/store/useAppStore.ts | 107 ++++++++++++++++++++- 6 files changed, 261 insertions(+), 47 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index f95a8a9..eafda7d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,7 +22,7 @@ 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); @@ -95,8 +95,8 @@ function AppContent() {
-
-
+
+
@@ -105,8 +105,8 @@ function AppContent() {
-
-
+
+
diff --git a/src/components/HistoryPanel.tsx b/src/components/HistoryPanel.tsx index 63c0caf..c9ea7b3 100644 --- a/src/components/HistoryPanel.tsx +++ b/src/components/HistoryPanel.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useAppStore } from '../store/useAppStore'; import { Button } from './ui/Button'; -import { History, Download, Image as ImageIcon } from 'lucide-react'; +import { History, Download, Trash2, Image as ImageIcon } from 'lucide-react'; import { cn } from '../utils/cn'; import { ImagePreviewModal } from './ImagePreviewModal'; import * as indexedDBService from '../services/indexedDBService'; @@ -23,7 +23,9 @@ export const HistoryPanel: React.FC<{ showHistory, setShowHistory, setCanvasImage, - selectedTool + selectedTool, + removeGeneration, + removeEdit } = useAppStore(); const { getBlob } = useAppStore.getState(); @@ -46,6 +48,9 @@ export const HistoryPanel: React.FC<{ // 使用自定义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(() => { const today = new Date(); @@ -212,18 +217,47 @@ export const HistoryPanel: React.FC<{ decodeBlobImages(); }, [generations, edits, getBlob, decodedImages]); + // 监听鼠标离开窗口事件,确保悬浮预览正确关闭 + useEffect(() => { + const handleMouseLeave = (e: MouseEvent) => { + // 当鼠标离开浏览器窗口时,关闭悬浮预览 + if (e.relatedTarget === null) { + setHoveredImage(null); + if (setPreviewPosition) { + setPreviewPosition(null); + } + } + }; + + const handleBlur = () => { + // 当窗口失去焦点时,关闭悬浮预览 + setHoveredImage(null); + if (setPreviewPosition) { + setPreviewPosition(null); + } + }; + + window.addEventListener('mouseleave', handleMouseLeave); + window.addEventListener('blur', handleBlur); + + return () => { + window.removeEventListener('mouseleave', handleMouseLeave); + window.removeEventListener('blur', handleBlur); + }; + }, [setHoveredImage, setPreviewPosition]); + if (!showHistory) { return ( -
+
@@ -522,6 +556,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) { @@ -563,6 +600,9 @@ export const HistoryPanel: React.FC<{ // 不需要处理鼠标移动事件,因为预览现在在页面中心显示 }} onMouseLeave={() => { + // 清除当前悬停的记录 + setHoveredRecord(null); + setHoveredImage(null); if (setPreviewPosition) { setPreviewPosition(null); @@ -589,6 +629,47 @@ export const HistoryPanel: React.FC<{
G{globalIndex + 1}
+ + {/* 悬停时显示的按钮 */} + {hoveredRecord && hoveredRecord.type === 'generation' && hoveredRecord.id === generation.id && ( +
+ + +
+ )}
); }); @@ -629,6 +710,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) { @@ -671,6 +755,9 @@ export const HistoryPanel: React.FC<{ // 不需要处理鼠标移动事件,因为预览现在在页面中心显示 }} onMouseLeave={() => { + // 清除当前悬停的记录 + setHoveredRecord(null); + setHoveredImage(null); if (setPreviewPosition) { setPreviewPosition(null); @@ -697,6 +784,47 @@ export const HistoryPanel: React.FC<{
E{globalIndex + 1}
+ + {/* 悬停时显示的按钮 */} + {hoveredRecord && hoveredRecord.type === 'edit' && hoveredRecord.id === edit.id && ( +
+ + +
+ )}
); }); @@ -818,7 +946,8 @@ export const HistoryPanel: React.FC<{ const uploadedUrl = gen.uploadResults && gen.uploadResults[index + 1] && gen.uploadResults[index + 1].success ? `${gen.uploadResults[index + 1].url}?x-oss-process=image/quality,q_30` : null; - const displayUrl = uploadedUrl || asset.url; + // 对于Blob URL,我们需要从decodedImages中获取解码后的图像数据 + const displayUrl = uploadedUrl || decodedImages[asset.blobUrl] || asset.blobUrl || asset.url; return (
{ if (!showPromptPanel) { return ( -
+
@@ -338,8 +338,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" /> {/* 常用提示词 */} diff --git a/src/hooks/useImageGeneration.ts b/src/hooks/useImageGeneration.ts index 302f2c9..5de3f51 100644 --- a/src/hooks/useImageGeneration.ts +++ b/src/hooks/useImageGeneration.ts @@ -171,7 +171,7 @@ export const useImageGeneration = () => { id: generateId(), type: 'original' as const, url: blobUrl, // 存储Blob URL而不是base64 - mime: 'image/png', + mime: blob.type || 'image/png', width: 1024, height: 1024, checksum diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index b36bdd5..29ee22e 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -90,34 +90,13 @@ async function getBlobFromUrl(blobUrl: string): Promise { const blob = useAppStore.getState().getBlob(blobUrl) if (!blob) { - // 如果AppStore中没有找到Blob,尝试从URL获取 - console.warn('无法从AppStore获取Blob,尝试从URL获取:', blobUrl) - try { - const response = await fetch(blobUrl) - if (!response.ok) { - throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`) - } - return await response.blob() - } catch (error) { - console.error('从URL获取Blob失败:', error) - throw new Error('无法从Blob URL获取图像数据') - } + throw new Error('无法从AppStore获取Blob,Blob可能已被清理'); } - return blob + return blob; } catch (error) { - console.error('从AppStore获取Blob时出错:', error) - // 如果导入AppStore失败,直接尝试从URL获取 - try { - const response = await fetch(blobUrl) - if (!response.ok) { - throw new Error(`获取Blob失败: ${response.status} ${response.statusText}`) - } - return await response.blob() - } catch (fetchError) { - console.error('从URL获取Blob失败:', fetchError) - throw new Error('无法从Blob URL获取图像数据') - } + console.error('从AppStore获取Blob时出错:', error); + throw new Error('无法从Blob URL获取图像数据'); } } diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index b2ec279..7e4de2a 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -111,6 +111,8 @@ interface AppState { 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; @@ -259,6 +261,17 @@ export const useAppStore = create()( }; } 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, @@ -269,7 +282,7 @@ export const useAppStore = create()( blobUrl: asset.url }; } - // 对于其他URL类型,创建一个新的Blob URL + // 对于其他URL类型,直接使用URL return { id: asset.id, type: asset.type, @@ -519,6 +532,98 @@ export const useAppStore = create()( 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 {};