新增 现在支持编译为Windows桌面端

This commit is contained in:
2025-10-05 05:45:34 +08:00
parent e30e5b4fe2
commit 29d4152e81
21 changed files with 6008 additions and 3453 deletions

2
.gitignore vendored
View File

@@ -23,3 +23,5 @@ dist-ssr
*.sln
*.sw?
.env
release

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/

54
build/README.md Normal file
View File

@@ -0,0 +1,54 @@
# 应用图标
此目录包含用于打包桌面应用程序的图标文件。
## 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 Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

14
copy-favicon.js Normal file
View File

@@ -0,0 +1,14 @@
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');

56
electron/dev-runner.js Normal file
View File

@@ -0,0 +1,56 @@
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,
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);
}
});

3
electron/index.js Normal file
View File

@@ -0,0 +1,3 @@
// Electron 入口文件
// 使用 require 而不是 import 来避免 ES 模块问题
require('./dist/main.js');

59
electron/main.js Normal file
View File

@@ -0,0 +1,59 @@
import { app, BrowserWindow } from 'electron';
import * as path from 'path';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
app.quit();
}
const createWindow = () => {
// Create the browser window.
const mainWindow = new BrowserWindow({
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.

93
electron/main.ts Normal file
View File

@@ -0,0 +1,93 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import * as path from 'path';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require('electron-squirrel-startup')) {
app.quit();
}
let mainWindow: BrowserWindow | null = null;
const createWindow = () => {
// Create the browser window.
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
frame: false, // 隐藏默认的窗口框架
icon: path.join(__dirname, '../../build/icon.ico'), // 设置应用图标
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 {
// 使用绝对路径从项目根目录的 dist 文件夹加载
const indexPath = path.join(__dirname, '../../dist/index.html');
console.log('Loading index.html from:', indexPath);
mainWindow.loadFile(indexPath);
}
// Open the DevTools.
if (process.env.VITE_DEV_SERVER_URL) {
mainWindow.webContents.openDevTools();
}
};
// 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();
// 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.

3
electron/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "commonjs"
}

20
electron/preload.js Normal file
View File

@@ -0,0 +1,20 @@
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;

52
electron/prod-runner.js Normal file
View File

@@ -0,0 +1,52 @@
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'
});
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);
}
});

View File

@@ -47,7 +47,7 @@
-->
<meta charset="UTF-8" />
<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>" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nano Banana AI 图像编辑器 - AI 图像生成器和编辑器</title>
<meta name="description" content="由 Gemini 2.5 Flash Image 提供支持的专业 AI 图像生成和对话式编辑。使用自然语言提示创建、编辑和增强图像。" />

8833
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,24 @@
"type": "git",
"url": "https://git.pandorastudio.cn/yuantao/Nano-Banana-AI-Image-Editor.git"
},
"author": {
"name": "Mark Fulton",
"email": "markfulton@example.com",
"url": "https://markfulton.com"
},
"main": "electron/index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node copy-favicon.js",
"lint": "eslint .",
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch"
"test:watch": "jest --watch",
"electron:dev": "node electron/dev-runner.js",
"electron:build": "vite build && copy public\\favicon.svg dist\\ && 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": {
"@google/genai": "^1.16.0",
@@ -25,6 +36,8 @@
"@tanstack/react-query": "^5.85.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"electron-squirrel-startup": "^1.0.1",
"fabric": "^6.7.1",
"idb-keyval": "^6.2.2",
"konva": "^9.3.22",
@@ -46,6 +59,8 @@
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"electron": "^38.2.1",
"electron-builder": "^26.0.12",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
@@ -53,10 +68,38 @@
"identity-obj-proxy": "^3.0.0",
"jest-environment-jsdom": "^30.1.2",
"postcss": "^8.4.35",
"pump": "^3.0.3",
"tailwindcss": "^3.4.1",
"tar": "^7.4.3",
"ts-jest": "^29.4.3",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
},
"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"
}
}
}

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 201 B

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cn } from './utils/cn';
import { Header } from './components/Header';
import { CustomTitleBar } from './components/CustomTitleBar';
import { PromptComposer } from './components/PromptComposer';
import { ImageCanvas } from './components/ImageCanvas';
import { HistoryPanel } from './components/HistoryPanel';
@@ -91,9 +91,7 @@ function AppContent() {
return (
<div className="h-screen bg-gray-50 text-gray-900 flex flex-col font-sans">
<div className="card card-lg rounded-none">
<Header />
</div>
<CustomTitleBar />
<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")}>

View File

@@ -0,0 +1,107 @@
import React, { useState, useEffect } from 'react';
import { Button } from './ui/Button';
import { HelpCircle, Minus, Square, X } from 'lucide-react';
import { InfoModal } from './InfoModal';
export const CustomTitleBar: React.FC = () => {
const [showInfoModal, setShowInfoModal] = 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={() => 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} />
</>
);
};

View File

@@ -232,4 +232,53 @@ body {
.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;
}

30
tsconfig.electron.json Normal file
View File

@@ -0,0 +1,30 @@
{
"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"
]
}

View File

@@ -1,8 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import * as path from 'path';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import * as path from 'path';
// https://vitejs.dev/config/
export default defineConfig({
base: './', // 添加这行以确保资源路径正确
publicDir: path.resolve(__dirname, 'public'), // 使用绝对路径指定 public 目录
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
@@ -14,4 +21,20 @@ export default defineConfig({
'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),
},
});