From c21319fe3c406610ea0bb13a134ecc608af1da3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A2=81=E6=B6=9B?= Date: Mon, 6 Oct 2025 00:02:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=AE=BE=E7=BD=AE=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IFLOW.md | 90 ++++++++++++++-- electron/main.js | 5 - electron/main.ts | 18 ++-- package.json | 23 +--- src/App.tsx | 24 +++++ src/components/CustomTitleBar.tsx | 14 ++- src/components/Header.tsx | 13 ++- src/components/ImageCanvas.tsx | 123 +++------------------- src/components/SettingsModal.tsx | 167 ++++++++++++++++++++++++++++++ src/services/geminiService.ts | 3 +- src/services/uploadService.ts | 7 +- src/utils/imageUtils.ts | 36 ++++++- vite.config.ts | 4 - 13 files changed, 356 insertions(+), 171 deletions(-) create mode 100644 src/components/SettingsModal.tsx diff --git a/IFLOW.md b/IFLOW.md index 02d4580..45236bf 100644 --- a/IFLOW.md +++ b/IFLOW.md @@ -12,8 +12,9 @@ - **AI 集成**: Google Generative AI SDK (Gemini) - **数据存储**: IndexedDB (通过 idb-keyval) - **构建工具**: Vite +- **桌面应用**: Electron -项目结构遵循标准的 React 应用组织方式,主要源代码位于 `src/` 目录下。 +项目结构遵循标准的 React 应用组织方式,主要源代码位于 `src/` 目录下。该项目同时支持Web和桌面应用(Electron)。 ## 构建和运行 @@ -31,15 +32,23 @@ 3. **启动开发服务器**: ```bash + # 启动Web开发服务器 npm run dev + + # 启动Electron开发环境 + npm run electron:dev ``` - 访问 `http://localhost:5173` 查看应用。 + 访问 `http://localhost:5173` 查看Web应用。 ### 构建和部署 - **构建生产版本**: ```bash + # 构建Web版本 npm run build + + # 构建Electron桌面应用 + npm run electron:build ``` - **预览生产构建**: @@ -82,26 +91,39 @@ src/ ├── components/ # React 组件 │ ├── ui/ # 可重用的 UI 组件 +│ │ ├── Button.tsx +│ │ ├── Input.tsx +│ │ └── Textarea.tsx +│ ├── CustomTitleBar.tsx # 自定义标题栏(用于Electron应用) +│ ├── Header.tsx # 应用头部和导航 │ ├── PromptComposer.tsx # 提示输入和工具选择 │ ├── ImageCanvas.tsx # 使用 Konva 的交互式画布 │ ├── HistoryPanel.tsx # 生成历史和变体 -│ ├── Header.tsx # 应用头部和导航 -│ └── InfoModal.tsx # 关于模态框和链接 +│ ├── InfoModal.tsx # 关于模态框和链接 +│ ├── Toast.tsx # 消息提示组件 +│ └── ToastContext.tsx # 消息提示上下文 ├── services/ # 外部服务集成 │ ├── geminiService.ts # Gemini API 客户端 -│ ├── uploadService.ts # 图像上传服务 -│ ├── cacheService.ts # IndexedDB 缓存层 -│ └── referenceImageService.ts # 参考图像处理 +│ ├── indexedDBService.ts # IndexedDB 数据库服务 +│ ├── cacheService.ts # IndexedDB 缓存层(未使用) +│ ├── referenceImageService.ts # 参考图像处理(未使用) +│ └── uploadService.ts # 图像上传服务(未使用) ├── store/ # Zustand 状态管理 │ └── useAppStore.ts # 全局应用状态 ├── hooks/ # 自定义 React 钩子 │ ├── useImageGeneration.ts # 生成和编辑逻辑 -│ └── useKeyboardShortcuts.ts # 键盘导航 +│ ├── useKeyboardShortcuts.ts # 键盘导航 +│ └── useIndexedDBListener.ts # IndexedDB监听器(未使用) ├── utils/ # 工具函数 │ ├── cn.ts # 类名工具 │ └── imageUtils.ts # 图像处理助手 -└── types/ # TypeScript 类型定义 - └── index.ts # 核心类型定义 +├── types/ # TypeScript 类型定义 +│ └── index.ts # 核心类型定义 +└── __tests__/ # 测试文件 + ├── ImageCanvas.test.tsx + ├── PromptComposer.test.tsx + ├── useAppStore.test.ts + └── useImageGeneration.test.ts ``` ### 组件开发 @@ -130,4 +152,50 @@ src/ 3. 维护类型安全,严格使用 TypeScript 和正确定义 4. 彻底测试,确保键盘导航和可访问性 5. 记录更改,更新 README 并添加内联注释 -6. 遵守 AGPL-3.0 许可证 \ No newline at end of file +6. 遵守 AGPL-3.0 许可证 + +## 项目特性 + +### AI 图像生成与编辑 +- 文本到图像生成:使用 Google Gemini 2.5 Flash Image 模型从描述性提示创建图像 +- 对话式编辑:使用自然语言指令修改现有图像 +- 区域感知选择:通过绘制遮罩来针对特定区域进行编辑 + +### 用户界面 +- 交互式画布:支持平滑缩放、平移和导航大图像 +- 画笔工具:可变画笔尺寸以实现精确的遮罩绘制 +- 响应式设计:在所有设备上都能完美运行 +- 键盘快捷键:使用热键提高工作效率 + +### 数据管理 +- 生成历史:跟踪所有创作和编辑记录 +- 变体比较:生成并并排比较多个版本 +- 离线缓存:使用 IndexedDB 存储以实现离线资产访问 +- 项目管理:有序存储所有生成的内容 + +### 部署选项 +- Web 应用:标准的 Web 应用程序,可在浏览器中运行 +- 桌面应用:使用 Electron 构建的桌面应用程序,支持 Windows、macOS 和 Linux + +## 依赖包更新 + +项目已清理未使用的依赖包,当前依赖包括: + +### 运行时依赖 +- `@google/genai`: Google Generative AI SDK +- `@radix-ui/react-dialog`: React 对话框组件 +- `@tanstack/react-query`: 服务端状态管理 +- `class-variance-authority`, `clsx`, `tailwind-merge`: 类名工具 +- `idb-keyval`: IndexedDB 封装库 +- `konva`, `react-konva`: Canvas 图形库 +- `lucide-react`: 图标库 +- `react`, `react-dom`: React 核心库 +- `react-day-picker`: 日期选择器组件 +- `zustand`: 客户端状态管理 + +### 开发依赖 +- `@eslint/js`, `eslint`, `typescript-eslint`: 代码质量工具 +- `@testing-library/*`: 测试工具 +- `electron-builder`: Electron 应用构建工具 +- `typescript`: TypeScript 编译器 +- `vite`: 构建工具 \ No newline at end of file diff --git a/electron/main.js b/electron/main.js index f552b32..eb7f071 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,11 +1,6 @@ import { app, BrowserWindow } from 'electron'; import * as path from 'path'; -// Handle creating/removing shortcuts on Windows when installing/uninstalling. -if (require('electron-squirrel-startup')) { - app.quit(); -} - const createWindow = () => { // Create the browser window. const mainWindow = new BrowserWindow({ diff --git a/electron/main.ts b/electron/main.ts index fbf3cbf..fed45dc 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,18 +1,13 @@ import { app, BrowserWindow, ipcMain } from 'electron'; import * as path from 'path'; -// Handle creating/removing shortcuts on Windows when installing/uninstalling. -if (require('electron-squirrel-startup')) { - app.quit(); -} - let mainWindow: BrowserWindow | null = null; const createWindow = () => { // Create the browser window. mainWindow = new BrowserWindow({ - width: 1200, - height: 800, + width: 1600, + height: 900, frame: false, // 隐藏默认的窗口框架 icon: path.join(__dirname, '../../build/icon.ico'), // 设置应用图标 webPreferences: { @@ -25,16 +20,15 @@ const createWindow = () => { // and load the index.html of the app. if (process.env.VITE_DEV_SERVER_URL) { mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL); + // 仅在开发环境中打开开发者工具 + mainWindow.webContents.openDevTools(); } else { // 使用绝对路径从项目根目录的 dist 文件夹加载 const indexPath = path.join(__dirname, '../../dist/index.html'); console.log('Loading index.html from:', indexPath); mainWindow.loadFile(indexPath); - } - - // Open the DevTools. - if (process.env.VITE_DEV_SERVER_URL) { - mainWindow.webContents.openDevTools(); + // 在生产环境中确保关闭开发者工具 + mainWindow.webContents.closeDevTools(); } }; diff --git a/package.json b/package.json index 43d790d..346f849 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,8 @@ "url": "https://git.pandorastudio.cn/yuantao/Nano-Banana-AI-Image-Editor.git" }, "author": { - "name": "Mark Fulton", - "email": "markfulton@example.com", - "url": "https://markfulton.com" + "name": "潘哆呐科技", + "email": "work@pandorastudio.cn" }, "main": "electron/index.js", "scripts": { @@ -30,15 +29,9 @@ "dependencies": { "@google/genai": "^1.16.0", "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-switch": "^1.2.6", "@tanstack/react-query": "^5.85.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "electron-squirrel-startup": "^1.0.1", - "fabric": "^6.7.1", "idb-keyval": "^6.2.2", "konva": "^9.3.22", "lucide-react": "^0.344.0", @@ -53,25 +46,13 @@ "@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", - "autoprefixer": "^10.4.18", - "electron": "^38.2.1", "electron-builder": "^26.0.12", "eslint": "^9.9.1", "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", - "pump": "^3.0.3", - "tailwindcss": "^3.4.1", - "tar": "^7.4.3", - "ts-jest": "^29.4.3", "typescript": "^5.5.3", "typescript-eslint": "^8.3.0", "vite": "^5.4.2" diff --git a/src/App.tsx b/src/App.tsx index be2fdf1..5648ae2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,30 @@ function AppContent() { const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null); const [isPreviewVisible, setIsPreviewVisible] = useState(false); + // 在挂载时检查localStorage中的设置 + useEffect(() => { + // 检查是否有存储在localStorage中的API密钥 + const savedGeminiApiKey = localStorage.getItem('VITE_GEMINI_API_KEY'); + if (savedGeminiApiKey) { + // 这里可以添加任何需要在应用启动时执行的逻辑 + console.log('使用localStorage中的Gemini API密钥'); + } + + // 检查是否有存储在localStorage中的访问令牌 + const savedAccessToken = localStorage.getItem('VITE_ACCESS_TOKEN'); + if (savedAccessToken) { + // 这里可以添加任何需要在应用启动时执行的逻辑 + console.log('使用localStorage中的访问令牌'); + } + + // 检查是否有存储在localStorage中的上传URL + const savedUploadAssetUrl = localStorage.getItem('VITE_UPLOAD_ASSET_URL'); + if (savedUploadAssetUrl) { + // 这里可以添加任何需要在应用启动时执行的逻辑 + console.log('使用localStorage中的上传资源URL'); + } + }, []); + // 在挂载时初始化IndexedDB并清理base64数据 useEffect(() => { const init = async () => { diff --git a/src/components/CustomTitleBar.tsx b/src/components/CustomTitleBar.tsx index 412cebd..6d32cb4 100644 --- a/src/components/CustomTitleBar.tsx +++ b/src/components/CustomTitleBar.tsx @@ -1,10 +1,12 @@ import React, { useState, useEffect } from 'react'; import { Button } from './ui/Button'; -import { HelpCircle, Minus, Square, X } from 'lucide-react'; +import { HelpCircle, Minus, Square, X, Settings } from 'lucide-react'; import { InfoModal } from './InfoModal'; +import { SettingsModal } from './SettingsModal'; export const CustomTitleBar: React.FC = () => { const [showInfoModal, setShowInfoModal] = useState(false); + const [showSettingsModal, setShowSettingsModal] = useState(false); const [isMaximized, setIsMaximized] = useState(false); // 检查窗口是否最大化 @@ -58,6 +60,15 @@ export const CustomTitleBar: React.FC = () => {
+ +
+ + +
+ +
+
+
+ + setAccessToken(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent" + placeholder="输入访问令牌" + /> +

+ 用于图像上传功能的访问令牌 +

+
+ +
+ + setGeminiApiKey(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent" + placeholder="输入Gemini API密钥" + /> +

+ 用于AI图像生成和编辑的Google Gemini API密钥 +

+
+ +
+ + setUploadAssetUrl(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-transparent" + placeholder="输入上传资源URL前缀" + /> +

+ 图像上传后返回的URL前缀 +

+
+
+ +
+ +
+ + + + +
+
+
+ + + + ); +}; \ No newline at end of file diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index d37ef21..6c960c5 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -1,7 +1,8 @@ import { GoogleGenAI } from '@google/genai' // 注意:在生产环境中,这应该通过后端代理处理 -const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key' +// 优先使用localStorage中的API密钥,如果没有则使用环境变量中的,最后使用默认值 +const API_KEY = localStorage.getItem('VITE_GEMINI_API_KEY') || import.meta.env.VITE_GEMINI_API_KEY || 'demo-key' const genAI = new GoogleGenAI({ apiKey: API_KEY }) export interface GenerationRequest { diff --git a/src/services/uploadService.ts b/src/services/uploadService.ts index 451e596..5bf467e 100644 --- a/src/services/uploadService.ts +++ b/src/services/uploadService.ts @@ -2,7 +2,8 @@ import { UploadResult } from '../types' // 上传接口URL -const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload' +// 优先使用localStorage中的URL,如果没有则使用环境变量中的 +const UPLOAD_URL = localStorage.getItem('VITE_UPLOAD_ASSET_URL') || import.meta.env.VITE_UPLOAD_ASSET_URL || 'https://api.pandorastudio.cn/auth/OSSupload' // 创建一个Map来缓存已上传的图像 const uploadCache = new Map() @@ -198,8 +199,8 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string, // 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"} if (result.code === 200) { - // 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀 - const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || ''; + // 使用localStorage中的VITE_UPLOAD_ASSET_URL作为前缀,如果没有则使用环境变量中的 + const uploadAssetUrl = localStorage.getItem('VITE_UPLOAD_ASSET_URL') || import.meta.env.VITE_UPLOAD_ASSET_URL || ''; const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data; // 清理过期缓存 diff --git a/src/utils/imageUtils.ts b/src/utils/imageUtils.ts index ce15c29..a227928 100644 --- a/src/utils/imageUtils.ts +++ b/src/utils/imageUtils.ts @@ -54,7 +54,41 @@ export function generateId(): string { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } -export function downloadImage(imageData: string, filename: string): void { +export async function downloadImage(imageData: string, filename: string): Promise { + try { + // 使用fetch获取图像数据并创建Blob URL以确保正确下载 + // 添加更多缓存控制头以绕过CDN缓存 + const response = await fetch(imageData, { + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + }); + + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error('下载图像失败:', error); + // 如果fetch失败,回退到直接使用a标签 + const link = document.createElement('a'); + link.href = imageData; + link.download = filename; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } +} + +export function downloadImageSimple(imageData: string, filename: string): void { if (imageData.startsWith('blob:')) { // 对于Blob URL,我们需要获取实际的Blob数据 fetch(imageData) diff --git a/vite.config.ts b/vite.config.ts index 9d3d69f..ca34b2d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,10 +2,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import * as path from 'path'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import * as path from 'path'; - // https://vitejs.dev/config/ export default defineConfig({ base: './', // 添加这行以确保资源路径正确