You've already forked Nano-Banana-AI-Image-Editor
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41c86e8166 |
@@ -4,8 +4,5 @@ VITE_ACCESS_TOKEN=your_access_token_here
|
|||||||
# Gemini API密钥
|
# Gemini API密钥
|
||||||
VITE_GEMINI_API_KEY=your_gemini_api_key_here
|
VITE_GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
|
||||||
# 上传接口
|
|
||||||
VITE_UPLOAD_API=
|
|
||||||
|
|
||||||
# 远程资源路径
|
# 远程资源路径
|
||||||
VITE_UPLOAD_ASSET_URL=''
|
VITE_UPLOAD_ASSET_URL=''
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -23,5 +23,3 @@ dist-ssr
|
|||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
.env
|
.env
|
||||||
|
|
||||||
release
|
|
||||||
2
.npmrc
2
.npmrc
@@ -1,2 +0,0 @@
|
|||||||
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
|
|
||||||
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/
|
|
||||||
88
IFLOW.md
88
IFLOW.md
@@ -12,9 +12,8 @@
|
|||||||
- **AI 集成**: Google Generative AI SDK (Gemini)
|
- **AI 集成**: Google Generative AI SDK (Gemini)
|
||||||
- **数据存储**: IndexedDB (通过 idb-keyval)
|
- **数据存储**: IndexedDB (通过 idb-keyval)
|
||||||
- **构建工具**: Vite
|
- **构建工具**: Vite
|
||||||
- **桌面应用**: Electron
|
|
||||||
|
|
||||||
项目结构遵循标准的 React 应用组织方式,主要源代码位于 `src/` 目录下。该项目同时支持Web和桌面应用(Electron)。
|
项目结构遵循标准的 React 应用组织方式,主要源代码位于 `src/` 目录下。
|
||||||
|
|
||||||
## 构建和运行
|
## 构建和运行
|
||||||
|
|
||||||
@@ -32,23 +31,15 @@
|
|||||||
|
|
||||||
3. **启动开发服务器**:
|
3. **启动开发服务器**:
|
||||||
```bash
|
```bash
|
||||||
# 启动Web开发服务器
|
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# 启动Electron开发环境
|
|
||||||
npm run electron:dev
|
|
||||||
```
|
```
|
||||||
访问 `http://localhost:5173` 查看Web应用。
|
访问 `http://localhost:5173` 查看应用。
|
||||||
|
|
||||||
### 构建和部署
|
### 构建和部署
|
||||||
|
|
||||||
- **构建生产版本**:
|
- **构建生产版本**:
|
||||||
```bash
|
```bash
|
||||||
# 构建Web版本
|
|
||||||
npm run build
|
npm run build
|
||||||
|
|
||||||
# 构建Electron桌面应用
|
|
||||||
npm run electron:build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **预览生产构建**:
|
- **预览生产构建**:
|
||||||
@@ -91,39 +82,26 @@
|
|||||||
src/
|
src/
|
||||||
├── components/ # React 组件
|
├── components/ # React 组件
|
||||||
│ ├── ui/ # 可重用的 UI 组件
|
│ ├── ui/ # 可重用的 UI 组件
|
||||||
│ │ ├── Button.tsx
|
|
||||||
│ │ ├── Input.tsx
|
|
||||||
│ │ └── Textarea.tsx
|
|
||||||
│ ├── CustomTitleBar.tsx # 自定义标题栏(用于Electron应用)
|
|
||||||
│ ├── Header.tsx # 应用头部和导航
|
|
||||||
│ ├── PromptComposer.tsx # 提示输入和工具选择
|
│ ├── PromptComposer.tsx # 提示输入和工具选择
|
||||||
│ ├── ImageCanvas.tsx # 使用 Konva 的交互式画布
|
│ ├── ImageCanvas.tsx # 使用 Konva 的交互式画布
|
||||||
│ ├── HistoryPanel.tsx # 生成历史和变体
|
│ ├── HistoryPanel.tsx # 生成历史和变体
|
||||||
│ ├── InfoModal.tsx # 关于模态框和链接
|
│ ├── Header.tsx # 应用头部和导航
|
||||||
│ ├── Toast.tsx # 消息提示组件
|
│ └── InfoModal.tsx # 关于模态框和链接
|
||||||
│ └── ToastContext.tsx # 消息提示上下文
|
|
||||||
├── services/ # 外部服务集成
|
├── services/ # 外部服务集成
|
||||||
│ ├── geminiService.ts # Gemini API 客户端
|
│ ├── geminiService.ts # Gemini API 客户端
|
||||||
│ ├── indexedDBService.ts # IndexedDB 数据库服务
|
│ ├── uploadService.ts # 图像上传服务
|
||||||
│ ├── cacheService.ts # IndexedDB 缓存层(未使用)
|
│ ├── cacheService.ts # IndexedDB 缓存层
|
||||||
│ ├── referenceImageService.ts # 参考图像处理(未使用)
|
│ └── referenceImageService.ts # 参考图像处理
|
||||||
│ └── uploadService.ts # 图像上传服务(未使用)
|
|
||||||
├── store/ # Zustand 状态管理
|
├── store/ # Zustand 状态管理
|
||||||
│ └── useAppStore.ts # 全局应用状态
|
│ └── useAppStore.ts # 全局应用状态
|
||||||
├── hooks/ # 自定义 React 钩子
|
├── hooks/ # 自定义 React 钩子
|
||||||
│ ├── useImageGeneration.ts # 生成和编辑逻辑
|
│ ├── useImageGeneration.ts # 生成和编辑逻辑
|
||||||
│ ├── useKeyboardShortcuts.ts # 键盘导航
|
│ └── useKeyboardShortcuts.ts # 键盘导航
|
||||||
│ └── useIndexedDBListener.ts # IndexedDB监听器(未使用)
|
|
||||||
├── utils/ # 工具函数
|
├── utils/ # 工具函数
|
||||||
│ ├── cn.ts # 类名工具
|
│ ├── cn.ts # 类名工具
|
||||||
│ └── imageUtils.ts # 图像处理助手
|
│ └── imageUtils.ts # 图像处理助手
|
||||||
├── types/ # TypeScript 类型定义
|
└── types/ # TypeScript 类型定义
|
||||||
│ └── index.ts # 核心类型定义
|
└── index.ts # 核心类型定义
|
||||||
└── __tests__/ # 测试文件
|
|
||||||
├── ImageCanvas.test.tsx
|
|
||||||
├── PromptComposer.test.tsx
|
|
||||||
├── useAppStore.test.ts
|
|
||||||
└── useImageGeneration.test.ts
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 组件开发
|
### 组件开发
|
||||||
@@ -153,49 +131,3 @@ src/
|
|||||||
4. 彻底测试,确保键盘导航和可访问性
|
4. 彻底测试,确保键盘导航和可访问性
|
||||||
5. 记录更改,更新 README 并添加内联注释
|
5. 记录更改,更新 README 并添加内联注释
|
||||||
6. 遵守 AGPL-3.0 许可证
|
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`: 构建工具
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# 应用图标
|
|
||||||
|
|
||||||
此目录包含用于打包桌面应用程序的图标文件。
|
|
||||||
|
|
||||||
## Windows 平台
|
|
||||||
|
|
||||||
Windows 平台需要 ICO 格式的图标文件,命名为 `icon.ico`。
|
|
||||||
|
|
||||||
### 如何创建 ICO 文件
|
|
||||||
|
|
||||||
有几种方法可以创建 ICO 文件:
|
|
||||||
|
|
||||||
#### 方法1:使用 Python 脚本(推荐)
|
|
||||||
|
|
||||||
项目根目录下提供了一个 Python 脚本 `convert_svg_to_ico.py`,可以直接将 SVG 转换为 ICO 格式:
|
|
||||||
|
|
||||||
1. 确保已安装 Python 3 和必要的依赖包:
|
|
||||||
```
|
|
||||||
pip install Pillow cairosvg
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 在 Windows 上,您可能还需要安装 GTK3 运行时环境:
|
|
||||||
- 下载并安装 GTK3: https://github.com/tschoonj/GTK-for-Windows-Runtime-Environment-Installer
|
|
||||||
|
|
||||||
3. 运行转换脚本:
|
|
||||||
```
|
|
||||||
npm run electron:icon
|
|
||||||
```
|
|
||||||
或直接运行:
|
|
||||||
```
|
|
||||||
python convert_svg_to_ico.py
|
|
||||||
```
|
|
||||||
|
|
||||||
脚本会自动将 `public/favicon.svg` 转换为 `build/icon.ico`,并包含多个尺寸。
|
|
||||||
|
|
||||||
#### 方法2:使用在线转换工具
|
|
||||||
|
|
||||||
- [ConvertICO](https://convertico.com/)
|
|
||||||
- [Online-Convert](https://image.online-convert.com/convert-to-ico)
|
|
||||||
- [CloudConvert](https://cloudconvert.com/png-to-ico)
|
|
||||||
|
|
||||||
#### 方法3:使用图像编辑软件
|
|
||||||
|
|
||||||
使用支持 ICO 格式的图像编辑软件(如 GIMP、Photoshop 插件等)手动创建。
|
|
||||||
|
|
||||||
### 图标尺寸要求
|
|
||||||
|
|
||||||
Windows 应用程序图标应包含多个尺寸:
|
|
||||||
- 16x16
|
|
||||||
- 32x32
|
|
||||||
- 48x48
|
|
||||||
- 256x256
|
|
||||||
|
|
||||||
确保在生成 ICO 文件时包含这些尺寸。
|
|
||||||
BIN
build/icon.ico
BIN
build/icon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB |
@@ -1,14 +0,0 @@
|
|||||||
import { copyFileSync, existsSync, mkdirSync } from 'fs';
|
|
||||||
import { resolve } from 'path';
|
|
||||||
|
|
||||||
const source = resolve('./public/favicon.svg');
|
|
||||||
const dest = resolve('./dist/favicon.svg');
|
|
||||||
|
|
||||||
// 确保 dist 目录存在
|
|
||||||
if (!existsSync('./dist')) {
|
|
||||||
mkdirSync('./dist');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 复制文件
|
|
||||||
copyFileSync(source, dest);
|
|
||||||
console.log('favicon.svg copied successfully');
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
const { spawn } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
// 先编译 TypeScript 文件
|
|
||||||
const tsc = spawn('npx', ['tsc', '-p', 'tsconfig.electron.json'], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
shell: true
|
|
||||||
});
|
|
||||||
|
|
||||||
tsc.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
// 编译成功后复制 preload.js 文件
|
|
||||||
const sourcePreload = path.join(__dirname, 'preload.js');
|
|
||||||
const destPreload = path.join(__dirname, 'dist', 'preload.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.copyFileSync(sourcePreload, destPreload);
|
|
||||||
console.log('preload.js copied successfully');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy preload.js:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编译成功后运行 Electron 应用
|
|
||||||
const electron = require('electron');
|
|
||||||
const appPath = path.join(__dirname, 'dist', 'main.js');
|
|
||||||
|
|
||||||
// 启动 Vite 开发服务器
|
|
||||||
const vite = spawn('npm', ['run', 'dev'], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
shell: true,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
VITE_DEV_SERVER_URL: 'http://localhost:5173',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 等待一段时间让 Vite 服务器启动,然后启动 Electron
|
|
||||||
setTimeout(() => {
|
|
||||||
const child = spawn(electron, [appPath], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
NODE_ENV: 'development',
|
|
||||||
VITE_DEV_SERVER_URL: 'http://localhost:5173',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
process.exit(code || 0);
|
|
||||||
});
|
|
||||||
}, 5000); // 等待 5 秒钟让 Vite 服务器启动
|
|
||||||
} else {
|
|
||||||
console.error('TypeScript compilation failed');
|
|
||||||
process.exit(code || 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
// Electron 入口文件
|
|
||||||
// 使用 require 而不是 import 来避免 ES 模块问题
|
|
||||||
require('./dist/main.js');
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
const createWindow = () => {
|
|
||||||
// Create the browser window.
|
|
||||||
const mainWindow = new BrowserWindow({
|
|
||||||
width: 1200,
|
|
||||||
height: 800,
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
nodeIntegration: false,
|
|
||||||
contextIsolation: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// and load the index.html of the app.
|
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
|
||||||
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL);
|
|
||||||
} else {
|
|
||||||
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the DevTools.
|
|
||||||
if (process.env.VITE_DEV_SERVER_URL) {
|
|
||||||
mainWindow.webContents.openDevTools();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// This method will be called when Electron has finished
|
|
||||||
// initialization and is ready to create browser windows.
|
|
||||||
// Some APIs can only be used after this event occurs.
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
createWindow();
|
|
||||||
|
|
||||||
// On OS X it's common to re-create a window in the app when the
|
|
||||||
// dock icon is clicked and there are no other windows open.
|
|
||||||
app.on('activate', () => {
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
|
||||||
createWindow();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Quit when all windows are closed, except on macOS. There, it's common
|
|
||||||
// for applications and their menu bar to stay active until the user quits
|
|
||||||
// explicitly with Cmd + Q.
|
|
||||||
app.on('window-all-closed', () => {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// In this file you can include the rest of your app's specific main process
|
|
||||||
// code. You can also put them in separate files and import them here.
|
|
||||||
113
electron/main.ts
113
electron/main.ts
@@ -1,113 +0,0 @@
|
|||||||
import { app, BrowserWindow, ipcMain, globalShortcut } from 'electron';
|
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
let mainWindow: BrowserWindow | null = null;
|
|
||||||
|
|
||||||
const createWindow = () => {
|
|
||||||
// Create the browser window.
|
|
||||||
mainWindow = new BrowserWindow({
|
|
||||||
width: 1600,
|
|
||||||
height: 900,
|
|
||||||
frame: false, // 隐藏默认的窗口框架
|
|
||||||
icon: path.join(__dirname, '../../build/icon.ico'), // 设置应用图标
|
|
||||||
webPreferences: {
|
|
||||||
preload: path.join(__dirname, 'preload.js'),
|
|
||||||
nodeIntegration: false,
|
|
||||||
contextIsolation: true,
|
|
||||||
devTools: process.env.NODE_ENV === 'development', // 仅在开发环境中启用开发者工具
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
// 在生产环境中确保关闭开发者工具
|
|
||||||
mainWindow.webContents.closeDevTools();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// IPC 处理程序
|
|
||||||
ipcMain.handle('window-minimize', () => {
|
|
||||||
if (mainWindow) {
|
|
||||||
mainWindow.minimize();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('window-maximize', () => {
|
|
||||||
if (mainWindow) {
|
|
||||||
if (mainWindow.isMaximized()) {
|
|
||||||
mainWindow.unmaximize();
|
|
||||||
} else {
|
|
||||||
mainWindow.maximize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('window-close', () => {
|
|
||||||
if (mainWindow) {
|
|
||||||
mainWindow.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.handle('window-is-maximized', () => {
|
|
||||||
return mainWindow ? mainWindow.isMaximized() : false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// This method will be called when Electron has finished
|
|
||||||
// initialization and is ready to create browser windows.
|
|
||||||
// Some APIs can only be used after this event occurs.
|
|
||||||
app.whenReady().then(() => {
|
|
||||||
createWindow();
|
|
||||||
|
|
||||||
// 在生产环境中禁用开发者工具快捷键
|
|
||||||
if (process.env.NODE_ENV !== 'development') {
|
|
||||||
// 注册全局快捷键拦截
|
|
||||||
globalShortcut.register('Control+Shift+I', () => {
|
|
||||||
// 拦截快捷键,不执行任何操作
|
|
||||||
console.log('Developer tools shortcut intercepted in production');
|
|
||||||
});
|
|
||||||
|
|
||||||
globalShortcut.register('Control+Shift+J', () => {
|
|
||||||
// 拦截快捷键,不执行任何操作
|
|
||||||
console.log('Developer tools console shortcut intercepted in production');
|
|
||||||
});
|
|
||||||
|
|
||||||
globalShortcut.register('F12', () => {
|
|
||||||
// 拦截快捷键,不执行任何操作
|
|
||||||
console.log('Developer tools F12 shortcut intercepted in production');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// On OS X it's common to re-create a window in the app when the
|
|
||||||
// dock icon is clicked and there are no other windows open.
|
|
||||||
app.on('activate', () => {
|
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
|
||||||
createWindow();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Quit when all windows are closed, except on macOS. There, it's common
|
|
||||||
// for applications and their menu bar to stay active until the user quits
|
|
||||||
// explicitly with Cmd + Q.
|
|
||||||
app.on('window-all-closed', () => {
|
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
app.quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 在应用退出时取消注册所有全局快捷键
|
|
||||||
app.on('will-quit', () => {
|
|
||||||
// 取消注册所有快捷键
|
|
||||||
globalShortcut.unregisterAll();
|
|
||||||
});
|
|
||||||
|
|
||||||
// In this file you can include the rest of your app's specific main process
|
|
||||||
// code. You can also put them in separate files and import them here.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "commonjs"
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
const { contextBridge, ipcRenderer, remote } = require('electron');
|
|
||||||
|
|
||||||
// As an example, we expose a function to the renderer process
|
|
||||||
// that shows a dialog
|
|
||||||
contextBridge.exposeInMainWorld('electronAPI', {
|
|
||||||
showDialog: () => ipcRenderer.invoke('show-dialog'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Custom APIs for renderer
|
|
||||||
const api = {};
|
|
||||||
|
|
||||||
// 窗口控制 API
|
|
||||||
contextBridge.exposeInMainWorld('electron', {
|
|
||||||
minimize: () => ipcRenderer.invoke('window-minimize'),
|
|
||||||
maximize: () => ipcRenderer.invoke('window-maximize'),
|
|
||||||
close: () => ipcRenderer.invoke('window-close'),
|
|
||||||
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = api;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
const { spawn } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
// 先编译 TypeScript 文件
|
|
||||||
const tsc = spawn('npx', ['tsc', '-p', 'tsconfig.electron.json'], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
shell: true
|
|
||||||
});
|
|
||||||
|
|
||||||
tsc.on('close', (code) => {
|
|
||||||
if (code === 0) {
|
|
||||||
// 编译成功后复制 preload.js 文件
|
|
||||||
const sourcePreload = path.join(__dirname, 'preload.js');
|
|
||||||
const destPreload = path.join(__dirname, 'dist', 'preload.js');
|
|
||||||
|
|
||||||
try {
|
|
||||||
fs.copyFileSync(sourcePreload, destPreload);
|
|
||||||
console.log('preload.js copied successfully');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to copy preload.js:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编译成功后运行 Electron 应用
|
|
||||||
const electron = require('electron');
|
|
||||||
const appPath = path.join(__dirname, 'dist', 'main.js');
|
|
||||||
|
|
||||||
// 先确保应用已构建
|
|
||||||
const build = spawn('npm', ['run', 'build'], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
shell: true
|
|
||||||
});
|
|
||||||
|
|
||||||
build.on('close', (buildCode) => {
|
|
||||||
if (buildCode === 0) {
|
|
||||||
const child = spawn(electron, [appPath], {
|
|
||||||
stdio: 'inherit',
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
NODE_ENV: 'production'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
process.exit(code || 0);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('Build failed');
|
|
||||||
process.exit(buildCode || 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.error('TypeScript compilation failed');
|
|
||||||
process.exit(code || 1);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
-->
|
-->
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍌</text></svg>" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Nano Banana AI 图像编辑器 - AI 图像生成器和编辑器</title>
|
<title>Nano Banana AI 图像编辑器 - AI 图像生成器和编辑器</title>
|
||||||
<meta name="description" content="由 Gemini 2.5 Flash Image 提供支持的专业 AI 图像生成和对话式编辑。使用自然语言提示创建、编辑和增强图像。" />
|
<meta name="description" content="由 Gemini 2.5 Flash Image 提供支持的专业 AI 图像生成和对话式编辑。使用自然语言提示创建、编辑和增强图像。" />
|
||||||
|
|||||||
9068
package-lock.json
generated
9068
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
62
package.json
62
package.json
@@ -8,30 +8,24 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.pandorastudio.cn/yuantao/Nano-Banana-AI-Image-Editor.git"
|
"url": "https://git.pandorastudio.cn/yuantao/Nano-Banana-AI-Image-Editor.git"
|
||||||
},
|
},
|
||||||
"author": {
|
|
||||||
"name": "潘哆呐科技",
|
|
||||||
"email": "work@pandorastudio.cn"
|
|
||||||
},
|
|
||||||
"main": "electron/index.js",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build && node copy-favicon.js",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch"
|
||||||
"electron:dev": "node electron/dev-runner.js",
|
|
||||||
"electron:build": "cross-env NODE_ENV=production vite build && electron-builder --win --x64",
|
|
||||||
"electron:preview": "node electron/prod-runner.js",
|
|
||||||
"electron:compile": "tsc -p tsconfig.electron.json",
|
|
||||||
"postinstall": "echo Skipping electron-builder install-app-deps"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.16.0",
|
"@google/genai": "^1.16.0",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@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",
|
"@tanstack/react-query": "^5.85.5",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"fabric": "^6.7.1",
|
||||||
"idb-keyval": "^6.2.2",
|
"idb-keyval": "^6.2.2",
|
||||||
"konva": "^9.3.22",
|
"konva": "^9.3.22",
|
||||||
"lucide-react": "^0.344.0",
|
"lucide-react": "^0.344.0",
|
||||||
@@ -46,51 +40,23 @@
|
|||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
"@testing-library/jest-dom": "^6.8.0",
|
"@testing-library/jest-dom": "^6.8.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.18",
|
||||||
"cross-env": "^7.0.3",
|
|
||||||
"electron": "^38.2.1",
|
|
||||||
"electron-builder": "^26.0.12",
|
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.9.1",
|
||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^30.2.0",
|
"jest-environment-jsdom": "^30.1.2",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"postcss": "^8.4.35",
|
||||||
"postcss": "^8.5.6",
|
"tailwindcss": "^3.4.1",
|
||||||
"tailwindcss": "^3.4.15",
|
"ts-jest": "^29.4.3",
|
||||||
"ts-jest": "^29.4.6",
|
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.3.0",
|
"typescript-eslint": "^8.3.0",
|
||||||
"vite": "^5.4.2"
|
"vite": "^5.4.2"
|
||||||
},
|
|
||||||
"build": {
|
|
||||||
"appId": "com.nanobanana.editor",
|
|
||||||
"productName": "Nano Banana AI Image Editor",
|
|
||||||
"npmRebuild": false,
|
|
||||||
"electronDownload": {
|
|
||||||
"mirror": "https://npmmirror.com/mirrors/electron/",
|
|
||||||
"customDir": "38.2.1",
|
|
||||||
"customFilename": "electron-v38.2.1-win32-x64.zip"
|
|
||||||
},
|
|
||||||
"directories": {
|
|
||||||
"output": "release",
|
|
||||||
"buildResources": "build"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist/**/*",
|
|
||||||
"electron/**/*",
|
|
||||||
"package.json"
|
|
||||||
],
|
|
||||||
"win": {
|
|
||||||
"target": [
|
|
||||||
"nsis",
|
|
||||||
"zip"
|
|
||||||
],
|
|
||||||
"icon": "build/icon.ico"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
|
||||||
<circle cx="50" cy="50" r="45" fill="#FDE047"/>
|
|
||||||
<text x="50" y="70" font-size="60" text-anchor="middle" fill="#000">🍌</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 201 B |
30
src/App.tsx
30
src/App.tsx
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { cn } from './utils/cn';
|
import { cn } from './utils/cn';
|
||||||
import { CustomTitleBar } from './components/CustomTitleBar';
|
import { Header } from './components/Header';
|
||||||
import { PromptComposer } from './components/PromptComposer';
|
import { PromptComposer } from './components/PromptComposer';
|
||||||
import { ImageCanvas } from './components/ImageCanvas';
|
import { ImageCanvas } from './components/ImageCanvas';
|
||||||
import { HistoryPanel } from './components/HistoryPanel';
|
import { HistoryPanel } from './components/HistoryPanel';
|
||||||
@@ -27,30 +27,6 @@ function AppContent() {
|
|||||||
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null);
|
const [previewPosition, setPreviewPosition] = useState<{x: number, y: number} | null>(null);
|
||||||
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
|
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数据
|
// 在挂载时初始化IndexedDB并清理base64数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -115,7 +91,9 @@ function AppContent() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
|
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
|
||||||
<CustomTitleBar />
|
<div className="card card-lg rounded-none">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden p-4 gap-4 relative">
|
<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("flex-shrink-0 transition-all duration-300 ease-in-out overflow-hidden", showPromptPanel ? "w-72" : "w-8")}>
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import React from 'react';
|
|
||||||
import { ImageCanvas } from '../components/ImageCanvas';
|
import { ImageCanvas } from '../components/ImageCanvas';
|
||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
|
|
||||||
// Mock Konva components
|
// Mock Konva components
|
||||||
jest.mock('react-konva', () => ({
|
jest.mock('react-konva', () => ({
|
||||||
Stage: React.forwardRef(({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }, ref: React.Ref<HTMLDivElement>) => (
|
Stage: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => (
|
||||||
<div data-testid="konva-stage" ref={ref} {...props}>
|
<div data-testid="konva-stage" {...props}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)),
|
),
|
||||||
Layer: ({ children }: { children: React.ReactNode }) => <div data-testid="konva-layer">{children}</div>,
|
Layer: ({ children }: { children: React.ReactNode }) => <div data-testid="konva-layer">{children}</div>,
|
||||||
Image: () => <div data-testid="konva-image" />,
|
Image: () => <div data-testid="konva-image" />,
|
||||||
Line: () => <div data-testid="konva-line" />
|
Line: () => <div data-testid="konva-line" />
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Button } from './ui/Button';
|
|
||||||
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);
|
|
||||||
|
|
||||||
// 检查窗口是否最大化
|
|
||||||
useEffect(() => {
|
|
||||||
const checkMaximized = () => {
|
|
||||||
if (window.electron && window.electron.isMaximized) {
|
|
||||||
window.electron.isMaximized().then(setIsMaximized);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkMaximized();
|
|
||||||
|
|
||||||
// 监听窗口状态变化
|
|
||||||
const handleResize = () => {
|
|
||||||
checkMaximized();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => window.removeEventListener('resize', handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const minimizeWindow = () => {
|
|
||||||
if (window.electron && window.electron.minimize) {
|
|
||||||
window.electron.minimize();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const maximizeWindow = () => {
|
|
||||||
if (window.electron && window.electron.maximize) {
|
|
||||||
window.electron.maximize();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeWindow = () => {
|
|
||||||
if (window.electron && window.electron.close) {
|
|
||||||
window.electron.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="h-12 bg-white flex items-center justify-between px-3 rounded-t-xl border-b border-gray-200 draggable">
|
|
||||||
<div className="flex items-center space-x-2 non-draggable">
|
|
||||||
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-yellow-50">
|
|
||||||
{/* 使用应用的主要图标 */}
|
|
||||||
<img src="./favicon.svg" alt="App Icon" className="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-base font-medium text-gray-800 hidden sm:block">
|
|
||||||
Nano Banana AI 图像编辑器
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-1 non-draggable">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setShowSettingsModal(true)}
|
|
||||||
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card non-draggable"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<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 non-draggable"
|
|
||||||
>
|
|
||||||
<HelpCircle className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 窗口控制按钮 - 仅在 Electron 环境中显示 */}
|
|
||||||
{window.electron && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={minimizeWindow}
|
|
||||||
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card non-draggable"
|
|
||||||
>
|
|
||||||
<Minus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={maximizeWindow}
|
|
||||||
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card non-draggable"
|
|
||||||
>
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={closeWindow}
|
|
||||||
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-red-100 rounded-full card non-draggable"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
|
|
||||||
<SettingsModal open={showSettingsModal} onOpenChange={setShowSettingsModal} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
import { HelpCircle, Settings } from 'lucide-react';
|
import { HelpCircle } from 'lucide-react';
|
||||||
import { InfoModal } from './InfoModal';
|
import { InfoModal } from './InfoModal';
|
||||||
import { SettingsModal } from './SettingsModal';
|
|
||||||
|
|
||||||
export const Header: React.FC = () => {
|
export const Header: React.FC = () => {
|
||||||
const [showInfoModal, setShowInfoModal] = useState(false);
|
const [showInfoModal, setShowInfoModal] = useState(false);
|
||||||
const [showSettingsModal, setShowSettingsModal] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -21,14 +19,6 @@ export const Header: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setShowSettingsModal(true)}
|
|
||||||
className="h-7 w-7 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full card"
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -41,7 +31,6 @@ export const Header: React.FC = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
|
<InfoModal open={showInfoModal} onOpenChange={setShowInfoModal} />
|
||||||
<SettingsModal open={showSettingsModal} onOpenChange={setShowSettingsModal} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -725,7 +725,9 @@ export const HistoryPanel: React.FC<{
|
|||||||
console.error('图像加载失败:', error);
|
console.error('图像加载失败:', error);
|
||||||
// 如果是Blob URL失效,尝试重新获取
|
// 如果是Blob URL失效,尝试重新获取
|
||||||
if (imageUrl.startsWith('blob:')) {
|
if (imageUrl.startsWith('blob:')) {
|
||||||
const blob = getBlob(imageUrl);
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
|
const blob = useAppStore.getState().getBlob(imageUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
console.log('从AppStore找到Blob,尝试重新创建URL...');
|
console.log('从AppStore找到Blob,尝试重新创建URL...');
|
||||||
// 重新创建Blob URL
|
// 重新创建Blob URL
|
||||||
@@ -765,6 +767,20 @@ export const HistoryPanel: React.FC<{
|
|||||||
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('导入AppStore时出错:', err);
|
||||||
|
// 即使图像加载失败,也显示预览
|
||||||
|
setHoveredImage({
|
||||||
|
url: imageUrl,
|
||||||
|
title: `生成记录 G${globalIndex + 1}`,
|
||||||
|
width: 0,
|
||||||
|
height: 0
|
||||||
|
});
|
||||||
|
// 传递鼠标位置信息给App组件
|
||||||
|
if (setPreviewPosition) {
|
||||||
|
setPreviewPosition({ x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// 即使图像加载失败,也显示预览
|
// 即使图像加载失败,也显示预览
|
||||||
setHoveredImage({
|
setHoveredImage({
|
||||||
@@ -1340,7 +1356,8 @@ export const HistoryPanel: React.FC<{
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
// Blob URL可能已失效,尝试重新创建
|
// Blob URL可能已失效,尝试重新创建
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
@@ -1350,6 +1367,7 @@ export const HistoryPanel: React.FC<{
|
|||||||
imgElement.src = newUrl;
|
imgElement.src = newUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
img.src = displayUrl;
|
img.src = displayUrl;
|
||||||
}
|
}
|
||||||
@@ -1376,12 +1394,14 @@ export const HistoryPanel: React.FC<{
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 如果图像加载失败,尝试重新创建Blob URL
|
// 如果图像加载失败,尝试重新创建Blob URL
|
||||||
if (displayUrl.startsWith('blob:')) {
|
if (displayUrl.startsWith('blob:')) {
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
(e.target as HTMLImageElement).src = newUrl;
|
(e.target as HTMLImageElement).src = newUrl;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1426,7 +1446,8 @@ export const HistoryPanel: React.FC<{
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
// Blob URL可能已失效,尝试重新创建
|
// Blob URL可能已失效,尝试重新创建
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
@@ -1436,6 +1457,7 @@ export const HistoryPanel: React.FC<{
|
|||||||
imgElement.src = newUrl;
|
imgElement.src = newUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
img.src = displayUrl;
|
img.src = displayUrl;
|
||||||
}
|
}
|
||||||
@@ -1462,12 +1484,14 @@ export const HistoryPanel: React.FC<{
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 如果图像加载失败,尝试重新创建Blob URL
|
// 如果图像加载失败,尝试重新创建Blob URL
|
||||||
if (displayUrl.startsWith('blob:')) {
|
if (displayUrl.startsWith('blob:')) {
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
(e.target as HTMLImageElement).src = newUrl;
|
(e.target as HTMLImageElement).src = newUrl;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1566,7 +1590,8 @@ export const HistoryPanel: React.FC<{
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
// Blob URL可能已失效,尝试重新创建
|
// Blob URL可能已失效,尝试重新创建
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
@@ -1576,6 +1601,7 @@ export const HistoryPanel: React.FC<{
|
|||||||
imgElement.src = newUrl;
|
imgElement.src = newUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
img.src = displayUrl;
|
img.src = displayUrl;
|
||||||
}
|
}
|
||||||
@@ -1602,12 +1628,14 @@ export const HistoryPanel: React.FC<{
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 如果图像加载失败,尝试重新创建Blob URL
|
// 如果图像加载失败,尝试重新创建Blob URL
|
||||||
if (displayUrl.startsWith('blob:')) {
|
if (displayUrl.startsWith('blob:')) {
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
(e.target as HTMLImageElement).src = newUrl;
|
(e.target as HTMLImageElement).src = newUrl;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1652,7 +1680,8 @@ export const HistoryPanel: React.FC<{
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
// Blob URL可能已失效,尝试重新创建
|
// Blob URL可能已失效,尝试重新创建
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
@@ -1662,6 +1691,7 @@ export const HistoryPanel: React.FC<{
|
|||||||
imgElement.src = newUrl;
|
imgElement.src = newUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
img.src = displayUrl;
|
img.src = displayUrl;
|
||||||
}
|
}
|
||||||
@@ -1688,12 +1718,14 @@ export const HistoryPanel: React.FC<{
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 如果图像加载失败,尝试重新创建Blob URL
|
// 如果图像加载失败,尝试重新创建Blob URL
|
||||||
if (displayUrl.startsWith('blob:')) {
|
if (displayUrl.startsWith('blob:')) {
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
(e.target as HTMLImageElement).src = newUrl;
|
(e.target as HTMLImageElement).src = newUrl;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -1744,7 +1776,8 @@ export const HistoryPanel: React.FC<{
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
// Blob URL可能已失效,尝试重新创建
|
// Blob URL可能已失效,尝试重新创建
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
@@ -1754,6 +1787,7 @@ export const HistoryPanel: React.FC<{
|
|||||||
imgElement.src = newUrl;
|
imgElement.src = newUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
img.src = displayUrl;
|
img.src = displayUrl;
|
||||||
}
|
}
|
||||||
@@ -1780,12 +1814,14 @@ export const HistoryPanel: React.FC<{
|
|||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
// 如果图像加载失败,尝试重新创建Blob URL
|
// 如果图像加载失败,尝试重新创建Blob URL
|
||||||
if (displayUrl.startsWith('blob:')) {
|
if (displayUrl.startsWith('blob:')) {
|
||||||
// 直接使用已导入的useAppStore
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(displayUrl);
|
const blob = useAppStore.getState().getBlob(displayUrl);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
(e.target as HTMLImageElement).src = newUrl;
|
(e.target as HTMLImageElement).src = newUrl;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import type { Stage as StageType } from 'konva/lib/Stage';
|
|||||||
import { useAppStore } from '../store/useAppStore';
|
import { useAppStore } from '../store/useAppStore';
|
||||||
import { Button } from './ui/Button';
|
import { Button } from './ui/Button';
|
||||||
import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react';
|
import { ZoomIn, ZoomOut, RotateCcw, Download } from 'lucide-react';
|
||||||
import { downloadImage } from '../utils/imageUtils';
|
|
||||||
|
|
||||||
export const ImageCanvas: React.FC = () => {
|
export const ImageCanvas: React.FC = () => {
|
||||||
const {
|
const {
|
||||||
@@ -147,10 +146,13 @@ export const ImageCanvas: React.FC = () => {
|
|||||||
const newUrl = URL.createObjectURL(blob);
|
const newUrl = URL.createObjectURL(blob);
|
||||||
console.log('从IndexedDB创建新的Blob URL:', newUrl);
|
console.log('从IndexedDB创建新的Blob URL:', newUrl);
|
||||||
// 更新canvasImage为新的URL
|
// 更新canvasImage为新的URL
|
||||||
|
import('../store/useAppStore').then((storeModule) => {
|
||||||
|
const useAppStore = storeModule.useAppStore;
|
||||||
// 检查是否已取消
|
// 检查是否已取消
|
||||||
if (!isCancelled) {
|
if (!isCancelled) {
|
||||||
useAppStore.getState().setCanvasImage(newUrl);
|
useAppStore.getState().setCanvasImage(newUrl);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error('IndexedDB中未找到图像');
|
console.error('IndexedDB中未找到图像');
|
||||||
}
|
}
|
||||||
@@ -177,6 +179,8 @@ export const ImageCanvas: React.FC = () => {
|
|||||||
console.log('正在检查Blob URL是否有效...');
|
console.log('正在检查Blob URL是否有效...');
|
||||||
|
|
||||||
// 尝试从AppStore重新获取Blob并创建新的URL
|
// 尝试从AppStore重新获取Blob并创建新的URL
|
||||||
|
import('../store/useAppStore').then((module) => {
|
||||||
|
const useAppStore = module.useAppStore;
|
||||||
const blob = useAppStore.getState().getBlob(canvasImage);
|
const blob = useAppStore.getState().getBlob(canvasImage);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
// 检查是否已取消
|
// 检查是否已取消
|
||||||
@@ -220,7 +224,14 @@ export const ImageCanvas: React.FC = () => {
|
|||||||
console.error('检查Blob URL时出错:', fetchErr);
|
console.error('检查Blob URL时出错:', fetchErr);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}).catch(err => {
|
||||||
|
// 检查是否已取消
|
||||||
|
if (isCancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('导入AppStore时出错:', err);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -381,7 +392,7 @@ export const ImageCanvas: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = () => {
|
||||||
// 首先尝试从当前选中的生成记录或编辑记录中获取上传后的URL
|
// 首先尝试从当前选中的生成记录或编辑记录中获取上传后的URL
|
||||||
const { selectedGenerationId, selectedEditId, currentProject } = useAppStore.getState();
|
const { selectedGenerationId, selectedEditId, currentProject } = useAppStore.getState();
|
||||||
|
|
||||||
@@ -398,11 +409,40 @@ export const ImageCanvas: React.FC = () => {
|
|||||||
// 下载第一个上传结果(通常是生成的图像)
|
// 下载第一个上传结果(通常是生成的图像)
|
||||||
const uploadResult = selectedRecord.uploadResults[0];
|
const uploadResult = selectedRecord.uploadResults[0];
|
||||||
if (uploadResult.success && uploadResult.url) {
|
if (uploadResult.success && uploadResult.url) {
|
||||||
try {
|
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||||||
await downloadImage(uploadResult.url, `nano-banana-${Date.now()}.png`);
|
// 添加更多缓存控制头以绕过CDN缓存
|
||||||
} catch (error) {
|
fetch(uploadResult.url, {
|
||||||
console.error('下载图像失败:', error);
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0'
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
// 如果fetch失败,回退到直接使用a标签
|
||||||
|
const link = document.createElement('a');
|
||||||
|
if (uploadResult.url) {
|
||||||
|
link.href = uploadResult.url;
|
||||||
|
link.download = `nano-banana-${Date.now()}.png`;
|
||||||
|
link.target = '_blank';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 立即返回
|
// 立即返回
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -429,15 +469,86 @@ export const ImageCanvas: React.FC = () => {
|
|||||||
|
|
||||||
// 如果Konva下载失败,回退到下载原始图像
|
// 如果Konva下载失败,回退到下载原始图像
|
||||||
if (canvasImage) {
|
if (canvasImage) {
|
||||||
try {
|
// 处理不同类型的URL
|
||||||
await downloadImage(canvasImage, `nano-banana-${Date.now()}.png`);
|
if (canvasImage.startsWith('data:')) {
|
||||||
} catch (error) {
|
// 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获取图像数据并创建Blob URL以确保正确下载
|
||||||
|
// 添加更多缓存控制头以绕过CDN缓存
|
||||||
|
fetch(canvasImage, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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);
|
console.error('下载图像失败:', error);
|
||||||
|
// 如果fetch失败,回退到直接使用a标签
|
||||||
|
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 {
|
||||||
|
// 普通URL格式
|
||||||
|
// 使用fetch获取图像数据并创建Blob URL以确保正确下载
|
||||||
|
// 添加更多缓存控制头以绕过CDN缓存
|
||||||
|
fetch(canvasImage, {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
// 如果fetch失败,回退到直接使用a标签
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
|||||||
@@ -334,12 +334,6 @@ export const PromptComposer: React.FC = () => {
|
|||||||
// 开始连续生成循环
|
// 开始连续生成循环
|
||||||
const generateWithRetry = async () => {
|
const generateWithRetry = async () => {
|
||||||
try {
|
try {
|
||||||
// 在开始前检查是否仍在连续生成模式
|
|
||||||
if (!useAppStore.getState().isContinuousGenerating) {
|
|
||||||
console.log('连续生成已取消');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 即使没有参考图像也继续生成,因为提示文本是必需的
|
// 即使没有参考图像也继续生成,因为提示文本是必需的
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
// 使用mutateAsync来等待结果
|
// 使用mutateAsync来等待结果
|
||||||
@@ -353,13 +347,6 @@ export const PromptComposer: React.FC = () => {
|
|||||||
setIsContinuousGenerating(false);
|
setIsContinuousGenerating(false);
|
||||||
resolve();
|
resolve();
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
// 检查是否是因为中断导致的错误
|
|
||||||
if (error.message === '生成已中断' || error.message === '生成已取消') {
|
|
||||||
console.log('生成被用户中断');
|
|
||||||
setIsContinuousGenerating(false);
|
|
||||||
resolve(); // 不再重试
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 生成失败,增加重试计数并继续
|
// 生成失败,增加重试计数并继续
|
||||||
const newCount = useAppStore.getState().retryCount + 1;
|
const newCount = useAppStore.getState().retryCount + 1;
|
||||||
setRetryCount(newCount);
|
setRetryCount(newCount);
|
||||||
@@ -372,12 +359,7 @@ export const PromptComposer: React.FC = () => {
|
|||||||
// 如果仍在连续生成模式下,继续重试
|
// 如果仍在连续生成模式下,继续重试
|
||||||
if (useAppStore.getState().isContinuousGenerating) {
|
if (useAppStore.getState().isContinuousGenerating) {
|
||||||
console.log('生成失败,正在重试...');
|
console.log('生成失败,正在重试...');
|
||||||
// 再次检查连续生成状态
|
setTimeout(generateWithRetry, 1000); // 1秒后重试
|
||||||
setTimeout(() => {
|
|
||||||
if (useAppStore.getState().isContinuousGenerating) {
|
|
||||||
generateWithRetry();
|
|
||||||
}
|
|
||||||
}, 1000); // 1秒后重试
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -389,7 +371,6 @@ export const PromptComposer: React.FC = () => {
|
|||||||
// 取消连续生成
|
// 取消连续生成
|
||||||
const cancelContinuousGeneration = () => {
|
const cancelContinuousGeneration = () => {
|
||||||
setIsContinuousGenerating(false);
|
setIsContinuousGenerating(false);
|
||||||
// 立即调用 cancelGeneration 来中断当前请求
|
|
||||||
cancelGeneration();
|
cancelGeneration();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import * as Dialog from '@radix-ui/react-dialog';
|
|
||||||
import { X, Settings } from 'lucide-react';
|
|
||||||
import { Button } from './ui/Button';
|
|
||||||
|
|
||||||
interface SettingsModalProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsModal: React.FC<SettingsModalProps> = ({ open, onOpenChange }) => {
|
|
||||||
const [accessToken, setAccessToken] = useState('');
|
|
||||||
const [geminiApiKey, setGeminiApiKey] = useState('');
|
|
||||||
const [uploadApiUrl, setUploadApiUrl] = useState('');
|
|
||||||
const [uploadAssetUrl, setUploadAssetUrl] = useState('');
|
|
||||||
const [modelName, setModelName] = useState('');
|
|
||||||
|
|
||||||
// 组件挂载时从localStorage加载设置
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
const savedAccessToken = localStorage.getItem('VITE_ACCESS_TOKEN') || '';
|
|
||||||
const savedGeminiApiKey = localStorage.getItem('VITE_GEMINI_API_KEY') || '';
|
|
||||||
const savedUploadApiUrl = localStorage.getItem('VITE_UPLOAD_API') || '';
|
|
||||||
const savedUploadAssetUrl = localStorage.getItem('VITE_UPLOAD_ASSET_URL') || '';
|
|
||||||
const savedModelName = localStorage.getItem('VITE_GEMINI_MODEL_NAME') || 'gemini-2.5-flash-image-preview';
|
|
||||||
|
|
||||||
setAccessToken(savedAccessToken);
|
|
||||||
setGeminiApiKey(savedGeminiApiKey);
|
|
||||||
setUploadApiUrl(savedUploadApiUrl);
|
|
||||||
setUploadAssetUrl(savedUploadAssetUrl);
|
|
||||||
setModelName(savedModelName);
|
|
||||||
}
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
// 保存到localStorage
|
|
||||||
if (accessToken) {
|
|
||||||
localStorage.setItem('VITE_ACCESS_TOKEN', accessToken);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('VITE_ACCESS_TOKEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geminiApiKey) {
|
|
||||||
localStorage.setItem('VITE_GEMINI_API_KEY', geminiApiKey);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('VITE_GEMINI_API_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadApiUrl) {
|
|
||||||
localStorage.setItem('VITE_UPLOAD_API', uploadApiUrl);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('VITE_UPLOAD_API');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadAssetUrl) {
|
|
||||||
localStorage.setItem('VITE_UPLOAD_ASSET_URL', uploadAssetUrl);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('VITE_UPLOAD_ASSET_URL');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (modelName) {
|
|
||||||
localStorage.setItem('VITE_GEMINI_MODEL_NAME', modelName);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('VITE_GEMINI_MODEL_NAME');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示保存成功的提示
|
|
||||||
alert('设置已保存!');
|
|
||||||
|
|
||||||
// 关闭模态框
|
|
||||||
onOpenChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
|
||||||
// 重置为默认值(环境变量中的值)
|
|
||||||
const defaultAccessToken = import.meta.env.VITE_ACCESS_TOKEN || '';
|
|
||||||
const defaultGeminiApiKey = import.meta.env.VITE_GEMINI_API_KEY || '';
|
|
||||||
const defaultUploadApiUrl = import.meta.env.VITE_UPLOAD_API || '';
|
|
||||||
const defaultUploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || '';
|
|
||||||
const defaultModelName = import.meta.env.VITE_GEMINI_MODEL_NAME || 'gemini-2.5-flash-image-preview';
|
|
||||||
|
|
||||||
setAccessToken(defaultAccessToken);
|
|
||||||
setGeminiApiKey(defaultGeminiApiKey);
|
|
||||||
setUploadApiUrl(defaultUploadApiUrl);
|
|
||||||
setUploadAssetUrl(defaultUploadAssetUrl);
|
|
||||||
setModelName(defaultModelName);
|
|
||||||
};
|
|
||||||
|
|
||||||
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-md 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">
|
|
||||||
设置
|
|
||||||
</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-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="accessToken" className="text-sm font-medium text-gray-700">
|
|
||||||
访问令牌
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="accessToken"
|
|
||||||
type="password"
|
|
||||||
value={accessToken}
|
|
||||||
onChange={(e) => 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="输入访问令牌"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
用于图像上传功能的访问令牌
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="geminiApiKey" className="text-sm font-medium text-gray-700">
|
|
||||||
Gemini API 密钥
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="geminiApiKey"
|
|
||||||
type="password"
|
|
||||||
value={geminiApiKey}
|
|
||||||
onChange={(e) => 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密钥"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
用于AI图像生成和编辑的Google Gemini API密钥
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="uploadApiUrl" className="text-sm font-medium text-gray-700">
|
|
||||||
上传接口URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="uploadApiUrl"
|
|
||||||
type="text"
|
|
||||||
value={uploadApiUrl}
|
|
||||||
onChange={(e) => setUploadApiUrl(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"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
图像上传的目标接口URL
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="uploadAssetUrl" className="text-sm font-medium text-gray-700">
|
|
||||||
上传资源URL
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="uploadAssetUrl"
|
|
||||||
type="text"
|
|
||||||
value={uploadAssetUrl}
|
|
||||||
onChange={(e) => 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前缀"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
图像上传后返回的URL前缀
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label htmlFor="modelName" className="text-sm font-medium text-gray-700">
|
|
||||||
模型名称
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="modelName"
|
|
||||||
type="text"
|
|
||||||
value={modelName}
|
|
||||||
onChange={(e) => setModelName(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="输入模型名称"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
用于AI图像生成和编辑的Google Gemini模型名称
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between pt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleReset}
|
|
||||||
className="px-4 py-2 text-sm"
|
|
||||||
>
|
|
||||||
重置为默认值
|
|
||||||
</Button>
|
|
||||||
<div className="space-x-2">
|
|
||||||
<Dialog.Close asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="px-4 py-2 text-sm"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
</Dialog.Close>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="px-4 py-2 text-sm bg-yellow-500 hover:bg-yellow-600 text-white"
|
|
||||||
>
|
|
||||||
保存设置
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Portal>
|
|
||||||
</Dialog.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -42,8 +42,10 @@ export const Toast: React.FC<ToastProps> = ({ id, message, type, details, onClos
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
const handleMouseLeave = () => {
|
||||||
// Immediately mark as not hovered
|
// Set a timeout to mark as not hovered after 1 second
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
onHoverChange?.(false);
|
onHoverChange?.(false);
|
||||||
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|||||||
@@ -50,11 +50,6 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeToast = (id: string) => {
|
const removeToast = (id: string) => {
|
||||||
// Clear any existing timeout for this toast
|
|
||||||
if (hoverTimeouts.current[id]) {
|
|
||||||
clearTimeout(hoverTimeouts.current[id]);
|
|
||||||
delete hoverTimeouts.current[id];
|
|
||||||
}
|
|
||||||
dispatch({ type: 'REMOVE_TOAST', payload: id });
|
dispatch({ type: 'REMOVE_TOAST', payload: id });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,24 +59,21 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
|
|
||||||
// Auto remove toasts after duration, but respect hover state
|
// Auto remove toasts after duration, but respect hover state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Create a copy of current timeouts to track which ones we need to clear
|
const timers = toasts.map(toast => {
|
||||||
const currentTimeouts = { ...hoverTimeouts.current };
|
|
||||||
|
|
||||||
toasts.forEach(toast => {
|
|
||||||
// Clear any existing timeout for this toast
|
// Clear any existing timeout for this toast
|
||||||
if (currentTimeouts[toast.id]) {
|
if (hoverTimeouts.current[toast.id]) {
|
||||||
clearTimeout(currentTimeouts[toast.id]);
|
clearTimeout(hoverTimeouts.current[toast.id]);
|
||||||
delete currentTimeouts[toast.id];
|
delete hoverTimeouts.current[toast.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
// If toast is hovered, don't set a timer
|
// If toast is hovered, don't set a timer
|
||||||
if (toast.hovered) {
|
if (toast.hovered) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If duration is 0, it's persistent
|
// If duration is 0, it's persistent
|
||||||
if (toast.duration === 0) {
|
if (toast.duration === 0) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set timeout to remove toast
|
// Set timeout to remove toast
|
||||||
@@ -89,23 +81,14 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
removeToast(toast.id);
|
removeToast(toast.id);
|
||||||
}, toast.duration);
|
}, toast.duration);
|
||||||
|
|
||||||
hoverTimeouts.current[toast.id] = timeout;
|
return { id: toast.id, timeout };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear any remaining timeouts for toasts that no longer exist
|
// Cleanup function
|
||||||
Object.entries(currentTimeouts).forEach(([id, timeout]) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
delete hoverTimeouts.current[id];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup function for when component unmounts or toasts change
|
|
||||||
return () => {
|
return () => {
|
||||||
// Clear all active timeouts
|
timers.forEach(timer => {
|
||||||
Object.values(hoverTimeouts.current).forEach(timeout => {
|
if (timer) clearTimeout(timer.timeout);
|
||||||
clearTimeout(timeout);
|
|
||||||
});
|
});
|
||||||
// Reset the timeouts object
|
|
||||||
hoverTimeouts.current = {};
|
|
||||||
};
|
};
|
||||||
}, [toasts]);
|
}, [toasts]);
|
||||||
|
|
||||||
|
|||||||
@@ -30,35 +30,35 @@ export const useImageGeneration = () => {
|
|||||||
abortControllerRef.current = new AbortController()
|
abortControllerRef.current = new AbortController()
|
||||||
|
|
||||||
// 将参考图像从base64转换为Blob(如果需要)
|
// 将参考图像从base64转换为Blob(如果需要)
|
||||||
let blobReferenceImages: Blob[] | undefined
|
let blobReferenceImages: Blob[] | undefined;
|
||||||
if (request.referenceImages) {
|
if (request.referenceImages) {
|
||||||
blobReferenceImages = []
|
blobReferenceImages = [];
|
||||||
for (const img of request.referenceImages) {
|
for (const img of request.referenceImages) {
|
||||||
if (typeof img === 'string') {
|
if (typeof img === 'string') {
|
||||||
// 如果是base64字符串,转换为Blob
|
// 如果是base64字符串,转换为Blob
|
||||||
const byteString = atob(img)
|
const byteString = atob(img);
|
||||||
const mimeString = 'image/png'
|
const mimeString = 'image/png';
|
||||||
const ab = new ArrayBuffer(byteString.length)
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
const ia = new Uint8Array(ab)
|
const ia = new Uint8Array(ab);
|
||||||
for (let i = 0; i < byteString.length; i++) {
|
for (let i = 0; i < byteString.length; i++) {
|
||||||
ia[i] = byteString.charCodeAt(i)
|
ia[i] = byteString.charCodeAt(i);
|
||||||
}
|
}
|
||||||
const blob = new Blob([ab], { type: mimeString })
|
const blob = new Blob([ab], { type: mimeString });
|
||||||
blobReferenceImages.push(blob)
|
blobReferenceImages.push(blob);
|
||||||
} else {
|
} else {
|
||||||
// 如果已经是Blob,直接使用
|
// 如果已经是Blob,直接使用
|
||||||
blobReferenceImages.push(img)
|
blobReferenceImages.push(img);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 保存参考图像Blob的引用
|
// 保存参考图像Blob的引用
|
||||||
referenceImageBlobsRef.current = blobReferenceImages
|
referenceImageBlobsRef.current = blobReferenceImages;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blobRequest: GenerationRequest = {
|
const blobRequest: GenerationRequest = {
|
||||||
...request,
|
...request,
|
||||||
referenceImages: blobReferenceImages,
|
referenceImages: blobReferenceImages,
|
||||||
abortSignal: abortControllerRef.current.signal,
|
abortSignal: abortControllerRef.current.signal
|
||||||
}
|
};
|
||||||
|
|
||||||
const result = await geminiService.generateImage(blobRequest)
|
const result = await geminiService.generateImage(blobRequest)
|
||||||
|
|
||||||
@@ -73,28 +73,27 @@ export const useImageGeneration = () => {
|
|||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
},
|
},
|
||||||
onSuccess: async (result, request) => {
|
onSuccess: async (result, request) => {
|
||||||
const { images, usageMetadata } = result
|
const { images, usageMetadata } = result;
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
// 直接使用Blob并创建URL,避免存储base64数据
|
// 直接使用Blob并创建URL,避免存储base64数据
|
||||||
const outputAssets: Asset[] = await Promise.all(
|
const outputAssets: Asset[] = await Promise.all(images.map(async (blob) => {
|
||||||
images.map(async blob => {
|
|
||||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||||
const blobUrl = useAppStore.getState().addBlob(blob)
|
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||||
|
|
||||||
// 生成校验和(使用Blob的一部分数据)
|
// 生成校验和(使用Blob的一部分数据)
|
||||||
const checksum = await (async () => {
|
const checksum = await (async () => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer()
|
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||||
const uint8Array = new Uint8Array(arrayBuffer)
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
let checksum = ''
|
let checksum = '';
|
||||||
for (let i = 0; i < uint8Array.length; i++) {
|
for (let i = 0; i < uint8Array.length; i++) {
|
||||||
checksum += uint8Array[i].toString(16).padStart(2, '0')
|
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||||
}
|
}
|
||||||
return checksum || generateId().slice(0, 32)
|
return checksum || generateId().slice(0, 32);
|
||||||
} catch {
|
} catch {
|
||||||
return generateId().slice(0, 32)
|
return generateId().slice(0, 32);
|
||||||
}
|
}
|
||||||
})()
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -103,68 +102,65 @@ export const useImageGeneration = () => {
|
|||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
width: 1024, // 默认Gemini输出尺寸
|
width: 1024, // 默认Gemini输出尺寸
|
||||||
height: 1024,
|
height: 1024,
|
||||||
checksum, // 使用生成的校验和
|
checksum // 使用生成的校验和
|
||||||
}
|
};
|
||||||
})
|
}));
|
||||||
)
|
|
||||||
|
|
||||||
// 获取accessToken
|
// 获取accessToken
|
||||||
const accessToken = localStorage.getItem('VITE_ACCESS_TOKEN') || ''
|
const accessToken = (import.meta as unknown as { env: { VITE_ACCESS_TOKEN?: string } }).env.VITE_ACCESS_TOKEN || '';
|
||||||
let uploadResults: Array<{ success: boolean; url?: string; error?: string; timestamp: number }> | undefined
|
let uploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> | undefined;
|
||||||
|
|
||||||
// 上传生成的图像和参考图像
|
// 上传生成的图像和参考图像
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
try {
|
try {
|
||||||
// 上传生成的图像(跳过缓存,因为这些是新生成的图像)
|
// 上传生成的图像(跳过缓存,因为这些是新生成的图像)
|
||||||
const imageUrls = outputAssets.map(asset => asset.url)
|
const imageUrls = outputAssets.map(asset => asset.url);
|
||||||
const outputUploadResults = await uploadImages(imageUrls, accessToken, true)
|
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
|
||||||
|
|
||||||
// 上传参考图像(如果存在,使用缓存机制)
|
// 上传参考图像(如果存在,使用缓存机制)
|
||||||
let referenceUploadResults: Array<{ success: boolean; url?: string; error?: string; timestamp: number }> = []
|
let referenceUploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> = [];
|
||||||
if (request.referenceImages && request.referenceImages.length > 0) {
|
if (request.referenceImages && request.referenceImages.length > 0) {
|
||||||
// 将参考图像转换为base64字符串格式上传(与老版本保持一致)
|
// 将参考图像转换为base64字符串格式上传(与老版本保持一致)
|
||||||
const referenceBase64s = await Promise.all(
|
const referenceBase64s = await Promise.all(request.referenceImages.map(async (blob) => {
|
||||||
request.referenceImages.map(async blob => {
|
|
||||||
if (typeof blob === 'string') {
|
if (typeof blob === 'string') {
|
||||||
// 如果已经是base64字符串,直接返回
|
// 如果已经是base64字符串,直接返回
|
||||||
return blob
|
return blob;
|
||||||
} else {
|
} else {
|
||||||
// 如果是Blob对象,转换为base64字符串
|
// 如果是Blob对象,转换为base64字符串
|
||||||
return new Promise<string>(resolve => {
|
return new Promise<string>((resolve) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader();
|
||||||
reader.onload = () => resolve(reader.result as string)
|
reader.onload = () => resolve(reader.result as string);
|
||||||
reader.readAsDataURL(blob)
|
reader.readAsDataURL(blob);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
}));
|
||||||
)
|
referenceUploadResults = await uploadImages(referenceBase64s, accessToken, false);
|
||||||
referenceUploadResults = await uploadImages(referenceBase64s, accessToken, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并上传结果
|
// 合并上传结果
|
||||||
uploadResults = [...outputUploadResults, ...referenceUploadResults]
|
uploadResults = [...outputUploadResults, ...referenceUploadResults];
|
||||||
|
|
||||||
// 检查上传结果
|
// 检查上传结果
|
||||||
const failedUploads = uploadResults.filter(r => !r.success)
|
const failedUploads = uploadResults.filter(r => !r.success);
|
||||||
if (failedUploads.length > 0) {
|
if (failedUploads.length > 0) {
|
||||||
console.warn(`${failedUploads.length}张图像上传失败`)
|
console.warn(`${failedUploads.length}张图像上传失败`);
|
||||||
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000)
|
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000);
|
||||||
} else {
|
} else {
|
||||||
console.log(`${uploadResults.length}张图像全部上传成功`)
|
console.log(`${uploadResults.length}张图像全部上传成功`);
|
||||||
addToast('图像已成功上传', 'success', 3000)
|
addToast('图像已成功上传', 'success', 3000);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传图像时出错:', error)
|
console.error('上传图像时出错:', error);
|
||||||
addToast('图像上传失败', 'error', 5000)
|
addToast('图像上传失败', 'error', 5000);
|
||||||
uploadResults = undefined
|
uploadResults = undefined;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('未找到accessToken,跳过上传')
|
console.warn('未找到accessToken,跳过上传');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示Token消耗信息(如果可用)
|
// 显示Token消耗信息(如果可用)
|
||||||
if (usageMetadata?.totalTokenCount) {
|
if (usageMetadata?.totalTokenCount) {
|
||||||
addToast(`本次生成消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000)
|
addToast(`本次生成消耗 ${usageMetadata.totalTokenCount} Tokens`, 'info', 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const generation: Generation = {
|
const generation: Generation = {
|
||||||
@@ -173,28 +169,26 @@ export const useImageGeneration = () => {
|
|||||||
parameters: {
|
parameters: {
|
||||||
aspectRatio: '1:1',
|
aspectRatio: '1:1',
|
||||||
seed: request.seed,
|
seed: request.seed,
|
||||||
temperature: request.temperature,
|
temperature: request.temperature
|
||||||
},
|
},
|
||||||
sourceAssets: request.referenceImages
|
sourceAssets: request.referenceImages ? await Promise.all(request.referenceImages.map(async (blob) => {
|
||||||
? await Promise.all(
|
|
||||||
request.referenceImages.map(async blob => {
|
|
||||||
// 将参考图像转换为Blob URL
|
// 将参考图像转换为Blob URL
|
||||||
const blobUrl = useAppStore.getState().addBlob(blob)
|
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||||
|
|
||||||
// 生成校验和(使用Blob的一部分数据)
|
// 生成校验和(使用Blob的一部分数据)
|
||||||
const checksum = await (async () => {
|
const checksum = await (async () => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer()
|
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||||
const uint8Array = new Uint8Array(arrayBuffer)
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
let checksum = ''
|
let checksum = '';
|
||||||
for (let i = 0; i < uint8Array.length; i++) {
|
for (let i = 0; i < uint8Array.length; i++) {
|
||||||
checksum += uint8Array[i].toString(16).padStart(2, '0')
|
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||||
}
|
}
|
||||||
return checksum || generateId().slice(0, 32)
|
return checksum || generateId().slice(0, 32);
|
||||||
} catch {
|
} catch {
|
||||||
return generateId().slice(0, 32)
|
return generateId().slice(0, 32);
|
||||||
}
|
}
|
||||||
})()
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -203,34 +197,32 @@ export const useImageGeneration = () => {
|
|||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
width: 1024,
|
width: 1024,
|
||||||
height: 1024,
|
height: 1024,
|
||||||
checksum,
|
checksum
|
||||||
}
|
};
|
||||||
})
|
})) : [],
|
||||||
)
|
|
||||||
: [],
|
|
||||||
outputAssets,
|
outputAssets,
|
||||||
modelVersion: 'gemini-2.5-flash-image-preview',
|
modelVersion: 'gemini-2.5-flash-image-preview',
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
uploadResults: uploadResults,
|
uploadResults: uploadResults,
|
||||||
usageMetadata: usageMetadata, // 保存usageMetadata到历史记录
|
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
|
||||||
}
|
};
|
||||||
|
|
||||||
addGeneration(generation)
|
addGeneration(generation);
|
||||||
|
|
||||||
// 调试日志:检查outputAssets
|
// 调试日志:检查outputAssets
|
||||||
console.log('生成完成,outputAssets:', outputAssets)
|
console.log('生成完成,outputAssets:', outputAssets);
|
||||||
if (outputAssets && outputAssets.length > 0) {
|
if (outputAssets && outputAssets.length > 0) {
|
||||||
console.log('第一个输出资产URL:', outputAssets[0].url)
|
console.log('第一个输出资产URL:', outputAssets[0].url);
|
||||||
setCanvasImage(outputAssets[0].url)
|
setCanvasImage(outputAssets[0].url);
|
||||||
} else {
|
} else {
|
||||||
console.error('生成完成但没有输出资产')
|
console.error('生成完成但没有输出资产');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动选择新生成的记录
|
// 自动选择新生成的记录
|
||||||
const { selectGeneration } = useAppStore.getState()
|
const { selectGeneration } = useAppStore.getState();
|
||||||
selectGeneration(generation.id)
|
selectGeneration(generation.id);
|
||||||
}
|
}
|
||||||
setIsGenerating(false)
|
setIsGenerating(false);
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => {
|
||||||
console.error('生成失败:', error)
|
console.error('生成失败:', error)
|
||||||
@@ -239,10 +231,10 @@ export const useImageGeneration = () => {
|
|||||||
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
addToast(`图像生成失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
// 保持参考图像不变,以便用户可以重新尝试生成
|
// 保持参考图像不变,以便用户可以重新尝试生成
|
||||||
console.log('生成失败,但参考图像已保留,用户可以重新尝试生成')
|
console.log('生成失败,但参考图像已保留,用户可以重新尝试生成');
|
||||||
// 如果有参考图像数据,确保它们不会被清除
|
// 如果有参考图像数据,确保它们不会被清除
|
||||||
if (referenceImageBlobsRef.current.length > 0) {
|
if (referenceImageBlobsRef.current.length > 0) {
|
||||||
console.log(`保留了 ${referenceImageBlobsRef.current.length} 个参考图像,用户可以重新尝试生成`)
|
console.log(`保留了 ${referenceImageBlobsRef.current.length} 个参考图像,用户可以重新尝试生成`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -253,9 +245,6 @@ export const useImageGeneration = () => {
|
|||||||
if (abortControllerRef.current) {
|
if (abortControllerRef.current) {
|
||||||
abortControllerRef.current.abort()
|
abortControllerRef.current.abort()
|
||||||
}
|
}
|
||||||
// 重置连续生成状态
|
|
||||||
const { setIsContinuousGenerating } = useAppStore.getState()
|
|
||||||
setIsContinuousGenerating(false)
|
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
addToast('生成已中断', 'info', 3000)
|
addToast('生成已中断', 'info', 3000)
|
||||||
}
|
}
|
||||||
@@ -293,102 +282,102 @@ export const useImageEditing = () => {
|
|||||||
if (!sourceImage) throw new Error('没有要编辑的图像')
|
if (!sourceImage) throw new Error('没有要编辑的图像')
|
||||||
|
|
||||||
// 将画布图像转换为Blob
|
// 将画布图像转换为Blob
|
||||||
let originalImageBlob: Blob
|
let originalImageBlob: Blob;
|
||||||
if (sourceImage.startsWith('blob:')) {
|
if (sourceImage.startsWith('blob:')) {
|
||||||
// 从Blob URL获取Blob数据
|
// 从Blob URL获取Blob数据
|
||||||
const blob = useAppStore.getState().getBlob(sourceImage)
|
const blob = useAppStore.getState().getBlob(sourceImage);
|
||||||
if (!blob) throw new Error('无法从Blob URL获取图像数据')
|
if (!blob) throw new Error('无法从Blob URL获取图像数据');
|
||||||
originalImageBlob = blob
|
originalImageBlob = blob;
|
||||||
} else if (sourceImage.includes('base64,')) {
|
} else if (sourceImage.includes('base64,')) {
|
||||||
// 从base64数据创建Blob
|
// 从base64数据创建Blob
|
||||||
const base64 = sourceImage.split('base64,')[1]
|
const base64 = sourceImage.split('base64,')[1];
|
||||||
const byteString = atob(base64)
|
const byteString = atob(base64);
|
||||||
const mimeString = 'image/png'
|
const mimeString = 'image/png';
|
||||||
const ab = new ArrayBuffer(byteString.length)
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
const ia = new Uint8Array(ab)
|
const ia = new Uint8Array(ab);
|
||||||
for (let i = 0; i < byteString.length; i++) {
|
for (let i = 0; i < byteString.length; i++) {
|
||||||
ia[i] = byteString.charCodeAt(i)
|
ia[i] = byteString.charCodeAt(i);
|
||||||
}
|
}
|
||||||
originalImageBlob = new Blob([ab], { type: mimeString })
|
originalImageBlob = new Blob([ab], { type: mimeString });
|
||||||
} else {
|
} else {
|
||||||
// 从URL获取Blob
|
// 从URL获取Blob
|
||||||
const response = await fetch(sourceImage)
|
const response = await fetch(sourceImage);
|
||||||
originalImageBlob = await response.blob()
|
originalImageBlob = await response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用于样式指导的参考图像
|
// 获取用于样式指导的参考图像
|
||||||
let referenceImageBlobs: Blob[] = []
|
let referenceImageBlobs: Blob[] = [];
|
||||||
const updatedReferenceImageUrls: string[] = [...editReferenceImages] // 保存更新后的URL
|
const updatedReferenceImageUrls: string[] = [...editReferenceImages]; // 保存更新后的URL
|
||||||
|
|
||||||
for (let i = 0; i < editReferenceImages.length; i++) {
|
for (let i = 0; i < editReferenceImages.length; i++) {
|
||||||
const img = editReferenceImages[i]
|
const img = editReferenceImages[i];
|
||||||
if (img.startsWith('blob:')) {
|
if (img.startsWith('blob:')) {
|
||||||
// 从Blob URL获取Blob数据
|
// 从Blob URL获取Blob数据
|
||||||
const blob = useAppStore.getState().getBlob(img)
|
const blob = useAppStore.getState().getBlob(img);
|
||||||
if (blob) {
|
if (blob) {
|
||||||
referenceImageBlobs.push(blob)
|
referenceImageBlobs.push(blob);
|
||||||
} else {
|
} else {
|
||||||
// 如果在AppStore中找不到Blob,尝试重新获取
|
// 如果在AppStore中找不到Blob,尝试重新获取
|
||||||
try {
|
try {
|
||||||
const response = await fetch(img)
|
const response = await fetch(img);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const blob = await response.blob()
|
const blob = await response.blob();
|
||||||
referenceImageBlobs.push(blob)
|
referenceImageBlobs.push(blob);
|
||||||
// 重新添加到AppStore
|
// 重新添加到AppStore
|
||||||
const newUrl = useAppStore.getState().addBlob(blob)
|
const newUrl = useAppStore.getState().addBlob(blob);
|
||||||
// 更新editReferenceImages中的URL(但不立即修改状态)
|
// 更新editReferenceImages中的URL(但不立即修改状态)
|
||||||
updatedReferenceImageUrls[i] = newUrl
|
updatedReferenceImageUrls[i] = newUrl;
|
||||||
} else {
|
} else {
|
||||||
// 即使无法重新获取,也要保留原始URL并在下次尝试时重新获取
|
// 即使无法重新获取,也要保留原始URL并在下次尝试时重新获取
|
||||||
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img)
|
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 即使出现错误,也要保留原始URL并在下次尝试时重新获取
|
// 即使出现错误,也要保留原始URL并在下次尝试时重新获取
|
||||||
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error)
|
console.warn('无法重新获取参考图像,将在下次尝试时重新获取:', img, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (img.includes('base64,')) {
|
} else if (img.includes('base64,')) {
|
||||||
// 从base64数据创建Blob
|
// 从base64数据创建Blob
|
||||||
const base64 = img.split('base64,')[1]
|
const base64 = img.split('base64,')[1];
|
||||||
const byteString = atob(base64)
|
const byteString = atob(base64);
|
||||||
const mimeString = 'image/png'
|
const mimeString = 'image/png';
|
||||||
const ab = new ArrayBuffer(byteString.length)
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
const ia = new Uint8Array(ab)
|
const ia = new Uint8Array(ab);
|
||||||
for (let j = 0; j < byteString.length; j++) {
|
for (let j = 0; j < byteString.length; j++) {
|
||||||
ia[j] = byteString.charCodeAt(j)
|
ia[j] = byteString.charCodeAt(j);
|
||||||
}
|
}
|
||||||
referenceImageBlobs.push(new Blob([ab], { type: mimeString }))
|
referenceImageBlobs.push(new Blob([ab], { type: mimeString }));
|
||||||
} else {
|
} else {
|
||||||
// 从URL获取Blob
|
// 从URL获取Blob
|
||||||
try {
|
try {
|
||||||
const response = await fetch(img)
|
const response = await fetch(img);
|
||||||
const blob = await response.blob()
|
const blob = await response.blob();
|
||||||
referenceImageBlobs.push(blob)
|
referenceImageBlobs.push(blob);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('无法获取参考图像:', img, error)
|
console.warn('无法获取参考图像:', img, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过滤掉无效的Blob,只保留有效的参考图像
|
// 过滤掉无效的Blob,只保留有效的参考图像
|
||||||
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0)
|
const validBlobs = referenceImageBlobs.filter(blob => blob.size > 0);
|
||||||
|
|
||||||
// 更新editReferenceImages状态(如果需要)
|
// 更新editReferenceImages状态(如果需要)
|
||||||
if (updatedReferenceImageUrls.some((url, index) => url !== editReferenceImages[index])) {
|
if (updatedReferenceImageUrls.some((url, index) => url !== editReferenceImages[index])) {
|
||||||
const { clearEditReferenceImages, addEditReferenceImage } = useAppStore.getState()
|
const { clearEditReferenceImages, addEditReferenceImage } = useAppStore.getState();
|
||||||
clearEditReferenceImages()
|
clearEditReferenceImages();
|
||||||
updatedReferenceImageUrls.forEach(imageUrl => {
|
updatedReferenceImageUrls.forEach(imageUrl => {
|
||||||
if (imageUrl) {
|
if (imageUrl) {
|
||||||
addEditReferenceImage(imageUrl)
|
addEditReferenceImage(imageUrl);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用有效的参考图像Blob
|
// 使用有效的参考图像Blob
|
||||||
referenceImageBlobs = validBlobs
|
referenceImageBlobs = validBlobs;
|
||||||
|
|
||||||
let maskImageBlob: Blob | undefined
|
let maskImageBlob: Blob | undefined;
|
||||||
let maskedReferenceImage: string | undefined
|
let maskedReferenceImage: string | undefined;
|
||||||
|
|
||||||
// 如果存在画笔描边,则从描边创建遮罩
|
// 如果存在画笔描边,则从描边创建遮罩
|
||||||
if (brushStrokes.length > 0) {
|
if (brushStrokes.length > 0) {
|
||||||
@@ -429,14 +418,14 @@ export const useImageEditing = () => {
|
|||||||
|
|
||||||
// 将遮罩转换为Blob
|
// 将遮罩转换为Blob
|
||||||
maskImageBlob = await new Promise<Blob>((resolve, reject) => {
|
maskImageBlob = await new Promise<Blob>((resolve, reject) => {
|
||||||
canvas.toBlob(blob => {
|
canvas.toBlob((blob) => {
|
||||||
if (blob) {
|
if (blob) {
|
||||||
resolve(blob)
|
resolve(blob);
|
||||||
} else {
|
} else {
|
||||||
reject(new Error('无法创建遮罩图像Blob'))
|
reject(new Error('无法创建遮罩图像Blob'));
|
||||||
}
|
}
|
||||||
}, 'image/png')
|
}, 'image/png');
|
||||||
})
|
});
|
||||||
|
|
||||||
// 创建遮罩参考图像(带遮罩叠加的原始图像)
|
// 创建遮罩参考图像(带遮罩叠加的原始图像)
|
||||||
const maskedCanvas = document.createElement('canvas')
|
const maskedCanvas = document.createElement('canvas')
|
||||||
@@ -476,7 +465,7 @@ export const useImageEditing = () => {
|
|||||||
maskedReferenceImage = maskedDataUrl.split('base64,')[1]
|
maskedReferenceImage = maskedDataUrl.split('base64,')[1]
|
||||||
|
|
||||||
// 将遮罩图像作为参考添加到模型中
|
// 将遮罩图像作为参考添加到模型中
|
||||||
referenceImageBlobs = [maskImageBlob, ...referenceImageBlobs]
|
referenceImageBlobs = [maskImageBlob, ...referenceImageBlobs];
|
||||||
}
|
}
|
||||||
|
|
||||||
const request: EditRequest = {
|
const request: EditRequest = {
|
||||||
@@ -486,7 +475,7 @@ export const useImageEditing = () => {
|
|||||||
maskImage: maskImageBlob,
|
maskImage: maskImageBlob,
|
||||||
temperature,
|
temperature,
|
||||||
seed: seed !== null ? seed : undefined,
|
seed: seed !== null ? seed : undefined,
|
||||||
abortSignal: abortControllerRef.current.signal,
|
abortSignal: abortControllerRef.current.signal
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await geminiService.editImage(request)
|
const result = await geminiService.editImage(request)
|
||||||
@@ -502,28 +491,27 @@ export const useImageEditing = () => {
|
|||||||
setIsGenerating(true)
|
setIsGenerating(true)
|
||||||
},
|
},
|
||||||
onSuccess: async ({ result, maskedReferenceImage }, instruction) => {
|
onSuccess: async ({ result, maskedReferenceImage }, instruction) => {
|
||||||
const { images, usageMetadata } = result
|
const { images, usageMetadata } = result;
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
// 直接使用Blob并创建URL,避免存储base64数据
|
// 直接使用Blob并创建URL,避免存储base64数据
|
||||||
const outputAssets: Asset[] = await Promise.all(
|
const outputAssets: Asset[] = await Promise.all(images.map(async (blob) => {
|
||||||
images.map(async blob => {
|
|
||||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||||
const blobUrl = useAppStore.getState().addBlob(blob)
|
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||||
|
|
||||||
// 生成校验和(使用Blob的一部分数据)
|
// 生成校验和(使用Blob的一部分数据)
|
||||||
const checksum = await (async () => {
|
const checksum = await (async () => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer()
|
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||||
const uint8Array = new Uint8Array(arrayBuffer)
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
let checksum = ''
|
let checksum = '';
|
||||||
for (let i = 0; i < uint8Array.length; i++) {
|
for (let i = 0; i < uint8Array.length; i++) {
|
||||||
checksum += uint8Array[i].toString(16).padStart(2, '0')
|
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||||
}
|
}
|
||||||
return checksum || generateId().slice(0, 32)
|
return checksum || generateId().slice(0, 32);
|
||||||
} catch {
|
} catch {
|
||||||
return generateId().slice(0, 32)
|
return generateId().slice(0, 32);
|
||||||
}
|
}
|
||||||
})()
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -532,41 +520,39 @@ export const useImageEditing = () => {
|
|||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
width: 1024,
|
width: 1024,
|
||||||
height: 1024,
|
height: 1024,
|
||||||
checksum,
|
checksum
|
||||||
}
|
};
|
||||||
})
|
}));
|
||||||
)
|
|
||||||
|
|
||||||
// 如果有遮罩参考图像则创建遮罩参考资产
|
// 如果有遮罩参考图像则创建遮罩参考资产
|
||||||
const maskReferenceAsset: Asset | undefined = maskedReferenceImage
|
const maskReferenceAsset: Asset | undefined = maskedReferenceImage ? await (async () => {
|
||||||
? await (async () => {
|
|
||||||
// 将base64转换为Blob
|
// 将base64转换为Blob
|
||||||
const byteString = atob(maskedReferenceImage)
|
const byteString = atob(maskedReferenceImage);
|
||||||
const mimeString = 'image/png'
|
const mimeString = 'image/png';
|
||||||
const ab = new ArrayBuffer(byteString.length)
|
const ab = new ArrayBuffer(byteString.length);
|
||||||
const ia = new Uint8Array(ab)
|
const ia = new Uint8Array(ab);
|
||||||
for (let i = 0; i < byteString.length; i++) {
|
for (let i = 0; i < byteString.length; i++) {
|
||||||
ia[i] = byteString.charCodeAt(i)
|
ia[i] = byteString.charCodeAt(i);
|
||||||
}
|
}
|
||||||
const blob = new Blob([ab], { type: mimeString })
|
const blob = new Blob([ab], { type: mimeString });
|
||||||
|
|
||||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||||
const blobUrl = useAppStore.getState().addBlob(blob)
|
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||||
|
|
||||||
// 生成校验和(使用Blob的一部分数据)
|
// 生成校验和(使用Blob的一部分数据)
|
||||||
const checksum = await (async () => {
|
const checksum = await (async () => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = await blob.slice(0, 32).arrayBuffer()
|
const arrayBuffer = await blob.slice(0, 32).arrayBuffer();
|
||||||
const uint8Array = new Uint8Array(arrayBuffer)
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
let checksum = ''
|
let checksum = '';
|
||||||
for (let i = 0; i < uint8Array.length; i++) {
|
for (let i = 0; i < uint8Array.length; i++) {
|
||||||
checksum += uint8Array[i].toString(16).padStart(2, '0')
|
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||||
}
|
}
|
||||||
return checksum || generateId().slice(0, 32)
|
return checksum || generateId().slice(0, 32);
|
||||||
} catch {
|
} catch {
|
||||||
return generateId().slice(0, 32)
|
return generateId().slice(0, 32);
|
||||||
}
|
}
|
||||||
})()
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -575,30 +561,29 @@ export const useImageEditing = () => {
|
|||||||
mime: 'image/png',
|
mime: 'image/png',
|
||||||
width: 1024,
|
width: 1024,
|
||||||
height: 1024,
|
height: 1024,
|
||||||
checksum,
|
checksum
|
||||||
}
|
};
|
||||||
})()
|
})() : undefined;
|
||||||
: undefined
|
|
||||||
|
|
||||||
// 为编辑操作创建参考资产
|
// 为编辑操作创建参考资产
|
||||||
const sourceAssets: Asset[] = referenceImageBlobs.map(blob => {
|
const sourceAssets: Asset[] = referenceImageBlobs.map((blob) => {
|
||||||
// 使用AppStore的addBlob方法存储Blob并获取URL
|
// 使用AppStore的addBlob方法存储Blob并获取URL
|
||||||
const blobUrl = useAppStore.getState().addBlob(blob)
|
const blobUrl = useAppStore.getState().addBlob(blob);
|
||||||
|
|
||||||
// 生成校验和(使用Blob的一部分数据)
|
// 生成校验和(使用Blob的一部分数据)
|
||||||
const checksum = (() => {
|
const checksum = (() => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = blob.slice(0, 32).arrayBuffer()
|
const arrayBuffer = blob.slice(0, 32).arrayBuffer();
|
||||||
const uint8Array = new Uint8Array(arrayBuffer)
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
let checksum = ''
|
let checksum = '';
|
||||||
for (let i = 0; i < uint8Array.length; i++) {
|
for (let i = 0; i < uint8Array.length; i++) {
|
||||||
checksum += uint8Array[i].toString(16).padStart(2, '0')
|
checksum += uint8Array[i].toString(16).padStart(2, '0');
|
||||||
}
|
}
|
||||||
return checksum || generateId().slice(0, 32)
|
return checksum || generateId().slice(0, 32);
|
||||||
} catch {
|
} catch {
|
||||||
return generateId().slice(0, 32)
|
return generateId().slice(0, 32);
|
||||||
}
|
}
|
||||||
})()
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -607,56 +592,54 @@ export const useImageEditing = () => {
|
|||||||
mime: blob.type || 'image/png',
|
mime: blob.type || 'image/png',
|
||||||
width: 1024,
|
width: 1024,
|
||||||
height: 1024,
|
height: 1024,
|
||||||
checksum,
|
checksum
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
// 获取accessToken用于上传
|
// 获取accessToken用于上传
|
||||||
const accessToken = localStorage.getItem('VITE_ACCESS_TOKEN') || ''
|
const accessToken = (import.meta as unknown as { env: { VITE_ACCESS_TOKEN?: string } }).env.VITE_ACCESS_TOKEN || '';
|
||||||
let uploadResults: Array<{ success: boolean; url?: string; error?: string; timestamp: number }> | undefined
|
let uploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> | undefined;
|
||||||
|
|
||||||
// 上传编辑后的图像和参考图像
|
// 上传编辑后的图像和参考图像
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
try {
|
try {
|
||||||
// 上传生成的图像(跳过缓存,因为这些是新生成的图像)
|
// 上传生成的图像(跳过缓存,因为这些是新生成的图像)
|
||||||
const imageUrls = outputAssets.map(asset => asset.url)
|
const imageUrls = outputAssets.map(asset => asset.url);
|
||||||
const outputUploadResults = await uploadImages(imageUrls, accessToken, true)
|
const outputUploadResults = await uploadImages(imageUrls, accessToken, true);
|
||||||
|
|
||||||
// 上传参考图像(如果存在,使用缓存机制)
|
// 上传参考图像(如果存在,使用缓存机制)
|
||||||
let referenceUploadResults: Array<{ success: boolean; url?: string; error?: string; timestamp: number }> = []
|
let referenceUploadResults: Array<{success: boolean, url?: string, error?: string, timestamp: number}> = [];
|
||||||
if (referenceImageBlobs.length > 0) {
|
if (referenceImageBlobs.length > 0) {
|
||||||
// 将参考图像转换为base64字符串格式上传(与老版本保持一致)
|
// 将参考图像转换为base64字符串格式上传(与老版本保持一致)
|
||||||
const referenceBase64s = await Promise.all(
|
const referenceBase64s = await Promise.all(referenceImageBlobs.map(async (blob) => {
|
||||||
referenceImageBlobs.map(async blob => {
|
return new Promise<string>((resolve) => {
|
||||||
return new Promise<string>(resolve => {
|
const reader = new FileReader();
|
||||||
const reader = new FileReader()
|
reader.onload = () => resolve(reader.result as string);
|
||||||
reader.onload = () => resolve(reader.result as string)
|
reader.readAsDataURL(blob);
|
||||||
reader.readAsDataURL(blob)
|
});
|
||||||
})
|
}));
|
||||||
})
|
referenceUploadResults = await uploadImages(referenceBase64s, accessToken, false);
|
||||||
)
|
|
||||||
referenceUploadResults = await uploadImages(referenceBase64s, accessToken, false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并上传结果
|
// 合并上传结果
|
||||||
uploadResults = [...outputUploadResults, ...referenceUploadResults]
|
uploadResults = [...outputUploadResults, ...referenceUploadResults];
|
||||||
|
|
||||||
// 检查上传结果
|
// 检查上传结果
|
||||||
const failedUploads = uploadResults.filter(r => !r.success)
|
const failedUploads = uploadResults.filter(r => !r.success);
|
||||||
if (failedUploads.length > 0) {
|
if (failedUploads.length > 0) {
|
||||||
console.warn(`${failedUploads.length}张图像上传失败`)
|
console.warn(`${failedUploads.length}张图像上传失败`);
|
||||||
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000)
|
addToast(`${failedUploads.length}张图像上传失败`, 'warning', 5000);
|
||||||
} else {
|
} else {
|
||||||
console.log(`${uploadResults.length}张图像全部上传成功`)
|
console.log(`${uploadResults.length}张图像全部上传成功`);
|
||||||
addToast('图像已成功上传', 'success', 3000)
|
addToast('图像已成功上传', 'success', 3000);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传图像时出错:', error)
|
console.error('上传图像时出错:', error);
|
||||||
addToast('图像上传失败', 'error', 5000)
|
addToast('图像上传失败', 'error', 5000);
|
||||||
uploadResults = undefined
|
uploadResults = undefined;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.warn('未找到accessToken,跳过上传')
|
console.warn('未找到accessToken,跳过上传');
|
||||||
}
|
}
|
||||||
|
|
||||||
const edit: Edit = {
|
const edit: Edit = {
|
||||||
@@ -671,20 +654,20 @@ export const useImageEditing = () => {
|
|||||||
uploadResults: uploadResults,
|
uploadResults: uploadResults,
|
||||||
parameters: {
|
parameters: {
|
||||||
seed: seed || undefined,
|
seed: seed || undefined,
|
||||||
temperature: temperature,
|
temperature: temperature
|
||||||
},
|
},
|
||||||
usageMetadata: usageMetadata, // 保存usageMetadata到历史记录
|
usageMetadata: usageMetadata // 保存usageMetadata到历史记录
|
||||||
}
|
};
|
||||||
|
|
||||||
addEdit(edit)
|
addEdit(edit);
|
||||||
|
|
||||||
// 自动在画布中加载编辑后的图像
|
// 自动在画布中加载编辑后的图像
|
||||||
const { selectEdit, selectGeneration } = useAppStore.getState()
|
const { selectEdit, selectGeneration } = useAppStore.getState();
|
||||||
setCanvasImage(outputAssets[0].url)
|
setCanvasImage(outputAssets[0].url);
|
||||||
selectEdit(edit.id)
|
selectEdit(edit.id);
|
||||||
selectGeneration(null)
|
selectGeneration(null);
|
||||||
}
|
}
|
||||||
setIsGenerating(false)
|
setIsGenerating(false);
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: error => {
|
||||||
console.error('编辑失败:', error)
|
console.error('编辑失败:', error)
|
||||||
@@ -693,7 +676,7 @@ export const useImageEditing = () => {
|
|||||||
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
addToast(`图像编辑失败: ${errorMessage}`, 'error', 5000, errorDetails)
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
// 保持参考图像不变,以便用户可以重新尝试编辑
|
// 保持参考图像不变,以便用户可以重新尝试编辑
|
||||||
console.log('编辑失败,但参考图像已保留,用户可以重新尝试编辑')
|
console.log('编辑失败,但参考图像已保留,用户可以重新尝试编辑');
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -703,9 +686,6 @@ export const useImageEditing = () => {
|
|||||||
if (abortControllerRef.current) {
|
if (abortControllerRef.current) {
|
||||||
abortControllerRef.current.abort()
|
abortControllerRef.current.abort()
|
||||||
}
|
}
|
||||||
// 重置连续生成状态(如果正在运行)
|
|
||||||
const { setIsContinuousGenerating } = useAppStore.getState()
|
|
||||||
setIsContinuousGenerating(false)
|
|
||||||
setIsGenerating(false)
|
setIsGenerating(false)
|
||||||
addToast('编辑已中断', 'info', 3000)
|
addToast('编辑已中断', 'info', 3000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,52 +233,3 @@ body {
|
|||||||
.card-lg {
|
.card-lg {
|
||||||
@apply shadow-card-lg;
|
@apply shadow-card-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Electron window drag regions */
|
|
||||||
.draggable {
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.non-draggable {
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 窗口控制按钮 */
|
|
||||||
.window-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-end;
|
|
||||||
padding: 0 10px;
|
|
||||||
height: 30px;
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-controls button {
|
|
||||||
width: 45px;
|
|
||||||
height: 30px;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: #000;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-controls button:hover {
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-controls .close:hover {
|
|
||||||
background: #ff5f56;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-controls .minimize:hover {
|
|
||||||
background: #ffbd2e;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.window-controls .maximize:hover {
|
|
||||||
background: #27c93f;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
import { GoogleGenAI } from '@google/genai'
|
import { GoogleGenAI } from '@google/genai'
|
||||||
|
|
||||||
// 注意:在生产环境中,这应该通过后端代理处理
|
// 注意:在生产环境中,这应该通过后端代理处理
|
||||||
// 优先使用localStorage中的API密钥,如果没有则使用环境变量中的,最后使用默认值
|
const API_KEY = import.meta.env.VITE_GEMINI_API_KEY || 'demo-key'
|
||||||
const API_KEY = localStorage.getItem('VITE_GEMINI_API_KEY') || import.meta.env.VITE_GEMINI_API_KEY || 'demo-key'
|
|
||||||
// 优先使用localStorage中的模型名称,如果没有则使用环境变量中的,最后使用默认值
|
|
||||||
const MODEL_NAME = localStorage.getItem('VITE_GEMINI_MODEL_NAME') || import.meta.env.VITE_GEMINI_MODEL_NAME || 'gemini-2.5-flash-image-preview'
|
|
||||||
const genAI = new GoogleGenAI({ apiKey: API_KEY })
|
const genAI = new GoogleGenAI({ apiKey: API_KEY })
|
||||||
|
|
||||||
export interface GenerationRequest {
|
export interface GenerationRequest {
|
||||||
@@ -79,11 +76,6 @@ export class GeminiService {
|
|||||||
|
|
||||||
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
|
async generateImage(request: GenerationRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
|
||||||
try {
|
try {
|
||||||
// 在开始之前检查是否已中断
|
|
||||||
if (request.abortSignal?.aborted) {
|
|
||||||
throw new Error('生成已取消')
|
|
||||||
}
|
|
||||||
|
|
||||||
const contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: request.prompt }]
|
const contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [{ text: request.prompt }]
|
||||||
|
|
||||||
// 如果提供了参考图像则添加
|
// 如果提供了参考图像则添加
|
||||||
@@ -93,11 +85,6 @@ export class GeminiService {
|
|||||||
|
|
||||||
// 为每个参考图像生成或获取base64数据
|
// 为每个参考图像生成或获取base64数据
|
||||||
for (const blob of request.referenceImages) {
|
for (const blob of request.referenceImages) {
|
||||||
// 在处理每个图像之前检查中断
|
|
||||||
if (request.abortSignal?.aborted) {
|
|
||||||
throw new Error('生成已取消')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成Blob的唯一标识符
|
// 生成Blob的唯一标识符
|
||||||
const blobId = await this.generateBlobId(blob)
|
const blobId = await this.generateBlobId(blob)
|
||||||
|
|
||||||
@@ -126,11 +113,6 @@ export class GeminiService {
|
|||||||
base64Images.push(base64)
|
base64Images.push(base64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 再次检查中断状态
|
|
||||||
if (request.abortSignal?.aborted) {
|
|
||||||
throw new Error('生成已取消')
|
|
||||||
}
|
|
||||||
|
|
||||||
base64Images.forEach(image => {
|
base64Images.forEach(image => {
|
||||||
// 确保图像数据不为空
|
// 确保图像数据不为空
|
||||||
if (image && image.length > 0) {
|
if (image && image.length > 0) {
|
||||||
@@ -161,7 +143,7 @@ export class GeminiService {
|
|||||||
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
|
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
|
||||||
config?: { httpOptions: { abortSignal: AbortSignal } }
|
config?: { httpOptions: { abortSignal: AbortSignal } }
|
||||||
} = {
|
} = {
|
||||||
model: MODEL_NAME,
|
model: 'gemini-2.5-flash-image-preview',
|
||||||
contents,
|
contents,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,11 +239,6 @@ export class GeminiService {
|
|||||||
|
|
||||||
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
|
async editImage(request: EditRequest): Promise<{ images: Blob[]; usageMetadata?: UsageMetadata }> {
|
||||||
try {
|
try {
|
||||||
// 在开始之前检查是否已中断
|
|
||||||
if (request.abortSignal?.aborted) {
|
|
||||||
throw new Error('编辑已取消')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将原始图像Blob转换为base64以发送到API
|
// 将原始图像Blob转换为base64以发送到API
|
||||||
let originalImageBase64: string
|
let originalImageBase64: string
|
||||||
|
|
||||||
@@ -405,7 +382,7 @@ export class GeminiService {
|
|||||||
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
|
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
|
||||||
config?: { httpOptions: { abortSignal: AbortSignal } }
|
config?: { httpOptions: { abortSignal: AbortSignal } }
|
||||||
} = {
|
} = {
|
||||||
model: MODEL_NAME,
|
model: 'gemini-2.5-flash-image-preview',
|
||||||
contents,
|
contents,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,7 +551,7 @@ export class GeminiService {
|
|||||||
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
|
contents: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }>
|
||||||
config?: { httpOptions: { abortSignal: AbortSignal } }
|
config?: { httpOptions: { abortSignal: AbortSignal } }
|
||||||
} = {
|
} = {
|
||||||
model: MODEL_NAME,
|
model: 'gemini-2.5-flash-image-preview',
|
||||||
contents: prompt,
|
contents: prompt,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import { UploadResult } from '../types'
|
import { UploadResult } from '../types'
|
||||||
|
|
||||||
// 上传接口URL
|
// 上传接口URL
|
||||||
// 优先使用localStorage中的URL,如果没有则使用环境变量中的
|
const UPLOAD_URL = 'https://api.pandorastudio.cn/auth/OSSupload'
|
||||||
const UPLOAD_URL = localStorage.getItem('VITE_UPLOAD_API') || import.meta.env.VITE_UPLOAD_API || 'https://api.pandorastudio.cn/auth/OSSupload'
|
|
||||||
|
|
||||||
// 创建一个Map来缓存已上传的图像
|
// 创建一个Map来缓存已上传的图像
|
||||||
const uploadCache = new Map<string, UploadResult>()
|
const uploadCache = new Map<string, UploadResult>()
|
||||||
@@ -199,8 +198,8 @@ export const uploadImage = async (imageData: string | Blob, accessToken: string,
|
|||||||
|
|
||||||
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
|
// 根据返回格式处理结果: {"code": 200,"msg": "上传成功","data": "9ecbaa0a0.jpg"}
|
||||||
if (result.code === 200) {
|
if (result.code === 200) {
|
||||||
// 使用localStorage中的VITE_UPLOAD_ASSET_URL作为前缀,如果没有则使用环境变量中的
|
// 使用环境变量中的VITE_UPLOAD_ASSET_URL作为前缀
|
||||||
const uploadAssetUrl = localStorage.getItem('VITE_UPLOAD_ASSET_URL') || import.meta.env.VITE_UPLOAD_ASSET_URL || '';
|
const uploadAssetUrl = import.meta.env.VITE_UPLOAD_ASSET_URL || '';
|
||||||
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data;
|
const fullUrl = uploadAssetUrl ? `${uploadAssetUrl}/${result.data}` : result.data;
|
||||||
|
|
||||||
// 清理过期缓存
|
// 清理过期缓存
|
||||||
|
|||||||
@@ -54,41 +54,7 @@ export function generateId(): string {
|
|||||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadImage(imageData: string, filename: string): Promise<void> {
|
export function downloadImage(imageData: string, filename: string): void {
|
||||||
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:')) {
|
if (imageData.startsWith('blob:')) {
|
||||||
// 对于Blob URL,我们需要获取实际的Blob数据
|
// 对于Blob URL,我们需要获取实际的Blob数据
|
||||||
fetch(imageData)
|
fetch(imageData)
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": [
|
|
||||||
"ES2023"
|
|
||||||
],
|
|
||||||
"module": "commonjs",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"outDir": "electron/dist",
|
|
||||||
"rootDir": "electron",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"types": [
|
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"typeRoots": [
|
|
||||||
"node_modules/@types"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"electron/**/*"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import * as path from 'path';
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: './', // 添加这行以确保资源路径正确
|
|
||||||
publicDir: path.resolve(__dirname, 'public'), // 使用绝对路径指定 public 目录
|
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
exclude: ['lucide-react'],
|
exclude: ['lucide-react'],
|
||||||
@@ -17,20 +14,4 @@ export default defineConfig({
|
|||||||
'react-day-picker/locale': 'date-fns/locale',
|
'react-day-picker/locale': 'date-fns/locale',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
|
||||||
outDir: 'dist',
|
|
||||||
assetsDir: 'assets',
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
entryFileNames: 'assets/[name].[hash].js',
|
|
||||||
chunkFileNames: 'assets/[name].[hash].js',
|
|
||||||
assetFileNames: 'assets/[name].[hash].[ext]',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 确保 public 目录下的文件被复制到 dist 目录
|
|
||||||
copyPublicDir: true,
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
'import.meta.env.VITE_DEV_SERVER_URL': JSON.stringify(process.env.VITE_DEV_SERVER_URL),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user