nanobanana初版
0 parents
Showing
12 changed files
with
1949 additions
and
0 deletions
AGENTS.md
0 → 100644
| 1 | <!-- OPENSPEC:START --> | ||
| 2 | # OpenSpec Instructions | ||
| 3 | |||
| 4 | These instructions are for AI assistants working in this project. | ||
| 5 | |||
| 6 | Always open `@/openspec/AGENTS.md` when the request: | ||
| 7 | - Mentions planning or proposals (words like proposal, spec, change, plan) | ||
| 8 | - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work | ||
| 9 | - Sounds ambiguous and you need the authoritative spec before coding | ||
| 10 | |||
| 11 | Use `@/openspec/AGENTS.md` to learn: | ||
| 12 | - How to create and apply change proposals | ||
| 13 | - Spec format and conventions | ||
| 14 | - Project structure and guidelines | ||
| 15 | |||
| 16 | Keep this managed block so 'openspec update' can refresh the instructions. | ||
| 17 | |||
| 18 | <!-- OPENSPEC:END --> | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
CLAUDE.md
0 → 100644
| 1 | <!-- OPENSPEC:START --> | ||
| 2 | # OpenSpec Instructions | ||
| 3 | |||
| 4 | These instructions are for AI assistants working in this project. | ||
| 5 | |||
| 6 | Always open `@/openspec/AGENTS.md` when the request: | ||
| 7 | - Mentions planning or proposals (words like proposal, spec, change, plan) | ||
| 8 | - Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work | ||
| 9 | - Sounds ambiguous and you need the authoritative spec before coding | ||
| 10 | |||
| 11 | Use `@/openspec/AGENTS.md` to learn: | ||
| 12 | - How to create and apply change proposals | ||
| 13 | - Spec format and conventions | ||
| 14 | - Project structure and guidelines | ||
| 15 | |||
| 16 | Keep this managed block so 'openspec update' can refresh the instructions. | ||
| 17 | |||
| 18 | <!-- OPENSPEC:END --> | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
README.md
0 → 100644
| 1 | # Gemini Image Generator | ||
| 2 | |||
| 3 | 简洁的跨平台桌面应用,使用 Google Gemini API 生成图片。 | ||
| 4 | |||
| 5 | ## 功能特性 | ||
| 6 | |||
| 7 | - **用户认证**: 基于 MySQL 的登录系统,确保只有授权用户可访问 | ||
| 8 | - 输入文本 Prompt 生成图片 | ||
| 9 | - 支持上传多张参考图片 | ||
| 10 | - 多种图片比例选择(1:1, 16:9, 4:3 等) | ||
| 11 | - 多种分辨率选择(1K, 2K, 4K) | ||
| 12 | - 支持两种模型:gemini-2.5-flash-image 和 gemini-3-pro-image-preview | ||
| 13 | - 实时预览生成的图片 | ||
| 14 | - 一键下载生成的图片 | ||
| 15 | - 跨平台支持(Windows 和 macOS) | ||
| 16 | |||
| 17 | ## 系统要求 | ||
| 18 | |||
| 19 | - Python 3.8 或更高版本 | ||
| 20 | - 有效的 Google AI API 密钥 | ||
| 21 | - MySQL 数据库访问权限(用于用户认证) | ||
| 22 | |||
| 23 | ## 快速开始 | ||
| 24 | |||
| 25 | ### 开发模式运行 | ||
| 26 | |||
| 27 | 1. 安装依赖: | ||
| 28 | ```bash | ||
| 29 | pip install -r requirements.txt | ||
| 30 | ``` | ||
| 31 | |||
| 32 | 2. 配置数据库和 API 密钥: | ||
| 33 | - 编辑 `config.json` 文件,配置以下字段: | ||
| 34 | ```json | ||
| 35 | { | ||
| 36 | "api_key": "你的Google AI API密钥", | ||
| 37 | "db_config": { | ||
| 38 | "host": "你的MySQL主机地址", | ||
| 39 | "port": 3306, | ||
| 40 | "user": "数据库用户名", | ||
| 41 | "password": "数据库密码", | ||
| 42 | "database": "数据库名", | ||
| 43 | "table": "nano_banana_users" | ||
| 44 | }, | ||
| 45 | "last_user": "" | ||
| 46 | } | ||
| 47 | ``` | ||
| 48 | |||
| 49 | 3. 创建数据库表: | ||
| 50 | ```sql | ||
| 51 | CREATE TABLE `nano_banana_users` ( | ||
| 52 | `user_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, | ||
| 53 | `passwd` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, | ||
| 54 | `status` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL | ||
| 55 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; | ||
| 56 | ``` | ||
| 57 | |||
| 58 | 4. 添加初始用户: | ||
| 59 | ```bash | ||
| 60 | python user_util.py add admin your_password | ||
| 61 | ``` | ||
| 62 | |||
| 63 | 5. 运行应用: | ||
| 64 | ```bash | ||
| 65 | python image_generator.py | ||
| 66 | ``` | ||
| 67 | |||
| 68 | 首次运行会显示登录界面,使用创建的账户登录。 | ||
| 69 | |||
| 70 | ### 打包为可执行文件 | ||
| 71 | |||
| 72 | #### Windows | ||
| 73 | |||
| 74 | 双击运行 `build_windows.bat` 或在命令行执行: | ||
| 75 | ```cmd | ||
| 76 | build_windows.bat | ||
| 77 | ``` | ||
| 78 | |||
| 79 | 生成的可执行文件位于 `dist\GeminiImageGenerator.exe` | ||
| 80 | |||
| 81 | #### macOS | ||
| 82 | |||
| 83 | 在终端执行: | ||
| 84 | ```bash | ||
| 85 | chmod +x build_mac.sh | ||
| 86 | ./build_mac.sh | ||
| 87 | ``` | ||
| 88 | |||
| 89 | 生成的应用程序位于 `dist/GeminiImageGenerator.app` | ||
| 90 | |||
| 91 | ## 配置文件位置 | ||
| 92 | |||
| 93 | 应用会根据运行模式自动选择配置文件位置: | ||
| 94 | |||
| 95 | **开发模式**(直接运行 Python): | ||
| 96 | - 配置文件:`./config.json`(当前目录) | ||
| 97 | |||
| 98 | **打包后的应用**: | ||
| 99 | - **macOS**: `~/Library/Application Support/ZB100ImageGenerator/config.json` | ||
| 100 | - **Windows**: `%APPDATA%\ZB100ImageGenerator\config.json` | ||
| 101 | - **Linux**: `~/.config/zb100imagegenerator/config.json` | ||
| 102 | |||
| 103 | 首次运行打包应用时,会自动从打包的模板复制 API 密钥到用户目录。 | ||
| 104 | |||
| 105 | ## 用户管理 | ||
| 106 | |||
| 107 | 应用内置用户管理工具 `user_util.py`(仅管理员使用,不随客户端分发)。 | ||
| 108 | |||
| 109 | ### 用户管理命令 | ||
| 110 | |||
| 111 | ```bash | ||
| 112 | # 添加新用户 | ||
| 113 | python user_util.py add <username> <password> | ||
| 114 | |||
| 115 | # 列出所有用户 | ||
| 116 | python user_util.py list | ||
| 117 | |||
| 118 | # 禁用用户 | ||
| 119 | python user_util.py disable <username> | ||
| 120 | |||
| 121 | # 启用用户 | ||
| 122 | python user_util.py enable <username> | ||
| 123 | |||
| 124 | # 重置密码 | ||
| 125 | python user_util.py reset <username> <new_password> | ||
| 126 | ``` | ||
| 127 | |||
| 128 | ### 示例 | ||
| 129 | |||
| 130 | ```bash | ||
| 131 | # 添加管理员账户 | ||
| 132 | python user_util.py add admin MySecurePass123 | ||
| 133 | |||
| 134 | # 查看所有用户 | ||
| 135 | python user_util.py list | ||
| 136 | |||
| 137 | # 禁用某个用户 | ||
| 138 | python user_util.py disable testuser | ||
| 139 | |||
| 140 | # 重置用户密码 | ||
| 141 | python user_util.py reset admin NewPassword456 | ||
| 142 | ``` | ||
| 143 | |||
| 144 | ### 安全说明 | ||
| 145 | |||
| 146 | - 密码使用 SHA256 哈希存储,数据库和本地均不保存明文 | ||
| 147 | - 所有数据库操作使用参数化查询,防止 SQL 注入 | ||
| 148 | - user_util.py 工具仅供管理员使用,不应分发给普通用户 | ||
| 149 | - 应用会记住上次登录的用户名(可选),但不会保存密码 | ||
| 150 | |||
| 151 | ## 使用说明 | ||
| 152 | |||
| 153 | 1. **登录应用** | ||
| 154 | - 应用启动时显示登录界面 | ||
| 155 | - 输入用户名和密码 | ||
| 156 | - 可勾选"记住用户名"选项,下次自动填充用户名 | ||
| 157 | - 登录成功后进入主界面 | ||
| 158 | |||
| 159 | 2. **配置 API 密钥** | ||
| 160 | - 编辑配置文件中的 `api_key` 字段 | ||
| 161 | - 或通过应用界面的收藏提示词功能自动保存 | ||
| 162 | |||
| 163 | 3. **上传参考图片(可选)** | ||
| 164 | - 点击 "添加图片" 按钮选择一张或多张参考图片 | ||
| 165 | - 这些图片将作为生成图片的参考 | ||
| 166 | - 可以单独删除每张图片 | ||
| 167 | |||
| 168 | 3. **输入 Prompt** | ||
| 169 | - 在提示词文本框中输入你想生成的图片描述 | ||
| 170 | - 可以使用 "收藏" 功能保存常用提示词 | ||
| 171 | - 从下拉菜单快速选择已保存的提示词 | ||
| 172 | |||
| 173 | 4. **选择生成参数** | ||
| 174 | - **宽高比**: 选择图片的宽高比(1:1, 16:9 等) | ||
| 175 | - **图片尺寸**: 选择图片的分辨率(1K/2K/4K) | ||
| 176 | - **AI 模型**: 选择使用的模型 | ||
| 177 | |||
| 178 | 5. **生成图片** | ||
| 179 | - 点击 "生成图片" 按钮 | ||
| 180 | - 等待生成完成,生成的图片会显示在预览区域 | ||
| 181 | - 双击预览图可用系统默认查看器打开 | ||
| 182 | |||
| 183 | 6. **下载图片** | ||
| 184 | - 点击 "下载图片" 按钮 | ||
| 185 | - 选择保存位置和文件格式(PNG/JPEG) | ||
| 186 | |||
| 187 | ## 项目结构 | ||
| 188 | |||
| 189 | ``` | ||
| 190 | Nano_Banana_App/ | ||
| 191 | ├── image_generator.py # 主程序文件(含登录界面和数据库认证) | ||
| 192 | ├── user_util.py # 用户管理工具(管理员专用) | ||
| 193 | ├── requirements.txt # Python 依赖 | ||
| 194 | ├── config.json # 配置文件(API密钥+数据库配置) | ||
| 195 | ├── build_windows.bat # Windows 打包脚本 | ||
| 196 | ├── build_mac.sh # macOS 打包脚本 | ||
| 197 | └── README.md # 本文件 | ||
| 198 | ``` | ||
| 199 | |||
| 200 | ## 技术栈 | ||
| 201 | |||
| 202 | - **GUI 框架**: Tkinter(Python 内置) | ||
| 203 | - **图片处理**: Pillow | ||
| 204 | - **API 客户端**: google-genai | ||
| 205 | - **数据库**: PyMySQL | ||
| 206 | - **打包工具**: PyInstaller | ||
| 207 | |||
| 208 | ## 获取 API 密钥 | ||
| 209 | |||
| 210 | 访问 [Google AI Studio](https://makersuite.google.com/app/apikey) 获取免费的 API 密钥。 | ||
| 211 | |||
| 212 | ## 注意事项 | ||
| 213 | |||
| 214 | - API 密钥会保存在 `config.json` 文件中,请妥善保管 | ||
| 215 | - 使用 API 可能会产生费用,请查看 Google AI 的定价信息 | ||
| 216 | - 生成高分辨率图片(4K)需要更多时间和 API 配额 | ||
| 217 | |||
| 218 | ## 故障排查 | ||
| 219 | |||
| 220 | ### 登录相关问题 | ||
| 221 | |||
| 222 | **无法登录 / 数据库连接失败** | ||
| 223 | - 检查 `config.json` 中的 `db_config` 配置是否正确 | ||
| 224 | - 确认数据库服务器可访问(检查防火墙/网络) | ||
| 225 | - 验证数据库用户名和密码是否正确 | ||
| 226 | - 确认表 `nano_banana_users` 已创建 | ||
| 227 | |||
| 228 | **"未找到数据库配置" 错误** | ||
| 229 | - 确保 `config.json` 包含 `db_config` 字段 | ||
| 230 | - 参考快速开始章节的配置示例 | ||
| 231 | |||
| 232 | **"用户名或密码错误" 提示** | ||
| 233 | - 使用 `python user_util.py list` 查看用户列表 | ||
| 234 | - 确认用户状态为 'active' | ||
| 235 | - 使用 `user_util.py` 重置密码或创建新用户 | ||
| 236 | |||
| 237 | **密码不匹配** | ||
| 238 | - 确保数据库中存储的是 SHA256 哈希值,而非明文密码 | ||
| 239 | - 使用 `user_util.py add` 添加用户,会自动计算哈希 | ||
| 240 | |||
| 241 | ### 配置文件只读错误(macOS/Windows 打包应用) | ||
| 242 | **问题**: 提示 "read-only file system: config.json" | ||
| 243 | |||
| 244 | **原因**: 打包后的应用资源目录是只读的,无法在应用包内写入文件 | ||
| 245 | |||
| 246 | **解决方案**: | ||
| 247 | - ✅ 已修复:应用现在会自动将配置保存到用户目录 | ||
| 248 | - macOS: `~/Library/Application Support/ZB100ImageGenerator/config.json` | ||
| 249 | - Windows: `%APPDATA%\ZB100ImageGenerator\config.json` | ||
| 250 | - 首次运行会自动创建配置文件夹和文件 | ||
| 251 | |||
| 252 | ### 生成失败 | ||
| 253 | - 检查 API 密钥是否正确 | ||
| 254 | - 检查网络连接是否正常 | ||
| 255 | - 查看错误信息,确认是否超出配额 | ||
| 256 | |||
| 257 | ### 找不到 API 密钥 | ||
| 258 | - 开发模式:检查项目目录下的 `config.json` 文件 | ||
| 259 | - 打包应用:检查用户目录下的配置文件(见上方配置文件位置) | ||
| 260 | - 手动创建配置文件并添加完整配置(参考快速开始章节) | ||
| 261 | |||
| 262 | ### 打包失败 | ||
| 263 | - 确保安装了所有依赖:`pip install -r requirements.txt` | ||
| 264 | - 检查 Python 版本是否符合要求(3.8+) | ||
| 265 | - Windows: 确保有 `zb100_kehuan.ico` 图标文件(或修改打包脚本移除 `--icon` 参数) | ||
| 266 | - 注意: `user_util.py` 不应打包进客户端版本(仅管理员使用) | ||
| 267 | |||
| 268 | ## 技术设计原则 | ||
| 269 | |||
| 270 | 本项目遵循 Linus Torvalds 的设计哲学: | ||
| 271 | |||
| 272 | - **简洁至上**: 使用 Tkinter 内置 GUI,避免重型框架 | ||
| 273 | - **零特殊情况**: 统一的错误处理和数据流 | ||
| 274 | - **实用主义**: 直接解决问题,不过度设计 | ||
| 275 | - **清晰数据结构**: 简单的配置管理和图片数据流 | ||
| 276 | |||
| 277 | ## 许可证 | ||
| 278 | |||
| 279 | MIT License |
ZB100ImageGenerator.spec
0 → 100644
| 1 | # -*- mode: python ; coding: utf-8 -*- | ||
| 2 | |||
| 3 | |||
| 4 | a = Analysis( | ||
| 5 | ['image_generator.py'], | ||
| 6 | pathex=[], | ||
| 7 | binaries=[], | ||
| 8 | datas=[('config.json', '.')], | ||
| 9 | hiddenimports=[], | ||
| 10 | hookspath=[], | ||
| 11 | hooksconfig={}, | ||
| 12 | runtime_hooks=[], | ||
| 13 | excludes=[], | ||
| 14 | noarchive=False, | ||
| 15 | optimize=0, | ||
| 16 | ) | ||
| 17 | pyz = PYZ(a.pure) | ||
| 18 | |||
| 19 | exe = EXE( | ||
| 20 | pyz, | ||
| 21 | a.scripts, | ||
| 22 | a.binaries, | ||
| 23 | a.datas, | ||
| 24 | [], | ||
| 25 | name='ZB100ImageGenerator', | ||
| 26 | debug=False, | ||
| 27 | bootloader_ignore_signals=False, | ||
| 28 | strip=False, | ||
| 29 | upx=True, | ||
| 30 | upx_exclude=[], | ||
| 31 | runtime_tmpdir=None, | ||
| 32 | console=False, | ||
| 33 | disable_windowed_traceback=False, | ||
| 34 | argv_emulation=False, | ||
| 35 | target_arch=None, | ||
| 36 | codesign_identity=None, | ||
| 37 | entitlements_file=None, | ||
| 38 | icon=['zb100_kehuan.ico'], | ||
| 39 | ) |
build_mac.sh
0 → 100644
| 1 | #!/bin/bash | ||
| 2 | # macOS Build Script for Gemini Image Generator | ||
| 3 | |||
| 4 | echo "================================" | ||
| 5 | echo "Building Gemini Image Generator" | ||
| 6 | echo "================================" | ||
| 7 | |||
| 8 | # Check if virtual environment exists | ||
| 9 | if [ ! -d "venv" ]; then | ||
| 10 | echo "Creating virtual environment..." | ||
| 11 | python3 -m venv venv | ||
| 12 | fi | ||
| 13 | |||
| 14 | # Activate virtual environment | ||
| 15 | echo "Activating virtual environment..." | ||
| 16 | source venv/bin/activate | ||
| 17 | |||
| 18 | # Install dependencies | ||
| 19 | echo "Installing dependencies..." | ||
| 20 | pip install --upgrade pip | ||
| 21 | pip install -r requirements.txt | ||
| 22 | |||
| 23 | # Clean previous builds | ||
| 24 | echo "Cleaning previous builds..." | ||
| 25 | rm -rf build dist *.spec | ||
| 26 | |||
| 27 | # Build executable | ||
| 28 | echo "Building executable..." | ||
| 29 | pyinstaller --name="ZB100ImageGenerator" \ | ||
| 30 | --onefile \ | ||
| 31 | --windowed \ | ||
| 32 | --add-data "config.json:." \ | ||
| 33 | image_generator_qt.py | ||
| 34 | |||
| 35 | # Check if build was successful | ||
| 36 | if [ -f "dist/ZB100ImageGenerator.app/Contents/MacOS/ZB100ImageGenerator" ] || [ -f "dist/ZB100ImageGenerator" ]; then | ||
| 37 | echo "================================" | ||
| 38 | echo "Build successful!" | ||
| 39 | echo "Application: dist/ZB100ImageGenerator.app (or dist/ZB100ImageGenerator)" | ||
| 40 | echo "================================" | ||
| 41 | else | ||
| 42 | echo "================================" | ||
| 43 | echo "Build failed!" | ||
| 44 | echo "================================" | ||
| 45 | fi |
build_windows.bat
0 → 100644
| 1 | @echo off | ||
| 2 | REM Windows Build Script for Gemini Image Generator | ||
| 3 | |||
| 4 | echo ================================ | ||
| 5 | echo Building Gemini Image Generator | ||
| 6 | echo ================================ | ||
| 7 | |||
| 8 | REM Check if virtual environment exists | ||
| 9 | if not exist "venv" ( | ||
| 10 | echo Creating virtual environment... | ||
| 11 | python -m venv venv | ||
| 12 | ) | ||
| 13 | |||
| 14 | REM Activate virtual environment | ||
| 15 | echo Activating virtual environment... | ||
| 16 | call venv\Scripts\activate.bat | ||
| 17 | |||
| 18 | REM Install dependencies | ||
| 19 | echo Installing dependencies... | ||
| 20 | pip install --upgrade pip | ||
| 21 | pip install -r requirements.txt | ||
| 22 | |||
| 23 | REM Clean previous builds | ||
| 24 | echo Cleaning previous builds... | ||
| 25 | if exist "build" rd /s /q build | ||
| 26 | if exist "dist" rd /s /q dist | ||
| 27 | if exist "*.spec" del /q *.spec | ||
| 28 | |||
| 29 | REM Build executable | ||
| 30 | echo Building executable... | ||
| 31 | pyinstaller --name="ZB100ImageGenerator" ^ | ||
| 32 | --onefile ^ | ||
| 33 | --windowed ^ | ||
| 34 | --icon=zb100_kehuan.ico ^ | ||
| 35 | --add-data "config.json;." ^ | ||
| 36 | image_generator_qt.py | ||
| 37 | |||
| 38 | REM Check if build was successful | ||
| 39 | if exist "dist\ZB100ImageGenerator.exe" ( | ||
| 40 | echo ================================ | ||
| 41 | echo Build successful! | ||
| 42 | echo Executable: dist\ZB100ImageGenerator.exe | ||
| 43 | echo ================================ | ||
| 44 | ) else ( | ||
| 45 | echo ================================ | ||
| 46 | echo Build failed! | ||
| 47 | echo ================================ | ||
| 48 | ) | ||
| 49 | |||
| 50 | pause |
export_requirements.bat
0 → 100644
image_generator.py
0 → 100644
| 1 | #!/usr/bin/env python3 | ||
| 2 | """ | ||
| 3 | Gemini Image Generator App | ||
| 4 | Simple GUI application for generating images using Google's Gemini API | ||
| 5 | """ | ||
| 6 | |||
| 7 | import tkinter as tk | ||
| 8 | from tkinter import ttk, filedialog, messagebox, scrolledtext | ||
| 9 | from PIL import Image, ImageTk | ||
| 10 | import base64 | ||
| 11 | import io | ||
| 12 | import json | ||
| 13 | import os | ||
| 14 | import sys | ||
| 15 | import tempfile | ||
| 16 | import subprocess | ||
| 17 | import platform | ||
| 18 | from pathlib import Path | ||
| 19 | from google import genai | ||
| 20 | from google.genai import types | ||
| 21 | import threading | ||
| 22 | import hashlib | ||
| 23 | import pymysql | ||
| 24 | |||
| 25 | |||
| 26 | def hash_password(password: str) -> str: | ||
| 27 | """使用 SHA256 哈希密码""" | ||
| 28 | return hashlib.sha256(password.encode('utf-8')).hexdigest() | ||
| 29 | |||
| 30 | |||
| 31 | class DatabaseManager: | ||
| 32 | """数据库连接管理类""" | ||
| 33 | def __init__(self, db_config): | ||
| 34 | self.config = db_config | ||
| 35 | |||
| 36 | def authenticate(self, username, password): | ||
| 37 | """ | ||
| 38 | 验证用户凭证 | ||
| 39 | 返回: (success: bool, message: str) | ||
| 40 | """ | ||
| 41 | try: | ||
| 42 | # 计算密码哈希 | ||
| 43 | password_hash = hash_password(password) | ||
| 44 | |||
| 45 | # 连接数据库 | ||
| 46 | conn = pymysql.connect( | ||
| 47 | host=self.config['host'], | ||
| 48 | port=self.config.get('port', 3306), | ||
| 49 | user=self.config['user'], | ||
| 50 | password=self.config['password'], | ||
| 51 | database=self.config['database'], | ||
| 52 | connect_timeout=5 | ||
| 53 | ) | ||
| 54 | |||
| 55 | try: | ||
| 56 | with conn.cursor() as cursor: | ||
| 57 | # 使用参数化查询防止 SQL 注入 | ||
| 58 | sql = f"SELECT * FROM {self.config['table']} WHERE user_name=%s AND passwd=%s AND status='active'" | ||
| 59 | cursor.execute(sql, (username, password_hash)) | ||
| 60 | result = cursor.fetchone() | ||
| 61 | |||
| 62 | if result: | ||
| 63 | return True, "认证成功" | ||
| 64 | else: | ||
| 65 | return False, "用户名或密码错误" | ||
| 66 | finally: | ||
| 67 | conn.close() | ||
| 68 | |||
| 69 | except pymysql.OperationalError as e: | ||
| 70 | return False, "无法连接到服务器,请检查网络连接" | ||
| 71 | except Exception as e: | ||
| 72 | return False, f"认证失败: {str(e)}" | ||
| 73 | |||
| 74 | |||
| 75 | class LoginWindow: | ||
| 76 | """登录窗口类""" | ||
| 77 | def __init__(self, db_config, last_user="", saved_password_hash=""): | ||
| 78 | self.db_config = db_config | ||
| 79 | self.last_user = last_user | ||
| 80 | self.saved_password_hash = saved_password_hash | ||
| 81 | self.success = False | ||
| 82 | self.authenticated_user = "" | ||
| 83 | self.password_changed = False # 标记密码是否被修改 | ||
| 84 | |||
| 85 | # 创建登录窗口 | ||
| 86 | self.root = tk.Tk() | ||
| 87 | self.root.title("登录 - AI 图像生成器") | ||
| 88 | self.root.geometry("400x400") | ||
| 89 | self.root.resizable(False, False) | ||
| 90 | |||
| 91 | # 创建 BooleanVar (必须在 Tk 根窗口创建之后) | ||
| 92 | self.remember_user = tk.BooleanVar(value=bool(last_user)) | ||
| 93 | self.remember_password = tk.BooleanVar(value=bool(saved_password_hash)) | ||
| 94 | |||
| 95 | # 设置窗口居中 | ||
| 96 | self.center_window() | ||
| 97 | |||
| 98 | # 设置样式 | ||
| 99 | self.setup_styles() | ||
| 100 | |||
| 101 | # 创建UI | ||
| 102 | self.setup_ui() | ||
| 103 | |||
| 104 | # 绑定回车键 | ||
| 105 | self.root.bind('<Return>', lambda e: self.on_login()) | ||
| 106 | |||
| 107 | # 处理窗口关闭 | ||
| 108 | self.root.protocol("WM_DELETE_WINDOW", self.on_close) | ||
| 109 | |||
| 110 | def center_window(self): | ||
| 111 | """窗口居中显示""" | ||
| 112 | self.root.update_idletasks() | ||
| 113 | width = self.root.winfo_width() | ||
| 114 | height = self.root.winfo_height() | ||
| 115 | x = (self.root.winfo_screenwidth() // 2) - (width // 2) | ||
| 116 | y = (self.root.winfo_screenheight() // 2) - (height // 2) | ||
| 117 | self.root.geometry(f'{width}x{height}+{x}+{y}') | ||
| 118 | |||
| 119 | def setup_styles(self): | ||
| 120 | """设置样式""" | ||
| 121 | style = ttk.Style() | ||
| 122 | style.theme_use('clam') | ||
| 123 | |||
| 124 | bg_color = '#ffffff' | ||
| 125 | accent_color = '#007AFF' | ||
| 126 | |||
| 127 | self.root.configure(bg=bg_color) | ||
| 128 | |||
| 129 | style.configure('Login.TButton', | ||
| 130 | background=accent_color, | ||
| 131 | foreground='white', | ||
| 132 | borderwidth=0, | ||
| 133 | focuscolor='none', | ||
| 134 | font=('Segoe UI', 10, 'bold'), | ||
| 135 | padding=(20, 10)) | ||
| 136 | |||
| 137 | style.map('Login.TButton', | ||
| 138 | background=[('active', '#0051D5'), ('pressed', '#0051D5')]) | ||
| 139 | |||
| 140 | def setup_ui(self): | ||
| 141 | """创建登录界面""" | ||
| 142 | # 主容器 | ||
| 143 | main_frame = ttk.Frame(self.root, padding=40) | ||
| 144 | main_frame.pack(fill="both", expand=True) | ||
| 145 | |||
| 146 | # 标题 | ||
| 147 | title_label = tk.Label(main_frame, text="登录", | ||
| 148 | font=('Segoe UI', 20, 'bold'), | ||
| 149 | bg='white', fg='#1d1d1f') | ||
| 150 | title_label.pack(pady=(0, 30)) | ||
| 151 | |||
| 152 | # 用户名 | ||
| 153 | username_frame = ttk.Frame(main_frame) | ||
| 154 | username_frame.pack(fill="x", pady=(0, 15)) | ||
| 155 | |||
| 156 | username_label = tk.Label(username_frame, text="用户名", | ||
| 157 | font=('Segoe UI', 10), | ||
| 158 | bg='white', fg='#666666') | ||
| 159 | username_label.pack(anchor="w") | ||
| 160 | |||
| 161 | self.username_entry = tk.Entry(username_frame, | ||
| 162 | font=('Segoe UI', 11), | ||
| 163 | relief='solid', | ||
| 164 | borderwidth=1) | ||
| 165 | self.username_entry.pack(fill="x", ipady=8, pady=(5, 0)) | ||
| 166 | self.username_entry.insert(0, self.last_user) | ||
| 167 | |||
| 168 | # 密码 | ||
| 169 | password_frame = ttk.Frame(main_frame) | ||
| 170 | password_frame.pack(fill="x", pady=(0, 15)) | ||
| 171 | |||
| 172 | password_label = tk.Label(password_frame, text="密码", | ||
| 173 | font=('Segoe UI', 10), | ||
| 174 | bg='white', fg='#666666') | ||
| 175 | password_label.pack(anchor="w") | ||
| 176 | |||
| 177 | self.password_entry = tk.Entry(password_frame, | ||
| 178 | font=('Segoe UI', 11), | ||
| 179 | relief='solid', | ||
| 180 | borderwidth=1, | ||
| 181 | show='*') | ||
| 182 | self.password_entry.pack(fill="x", ipady=8, pady=(5, 0)) | ||
| 183 | |||
| 184 | # 如果有保存的密码,显示占位符 | ||
| 185 | if self.saved_password_hash: | ||
| 186 | self.password_entry.insert(0, "••••••••") | ||
| 187 | self.password_entry.config(fg='#999999') | ||
| 188 | |||
| 189 | # 监听密码框变化 | ||
| 190 | self.password_entry.bind('<KeyPress>', self.on_password_change) | ||
| 191 | self.password_entry.bind('<Return>', lambda e: self.on_login()) | ||
| 192 | |||
| 193 | # 复选框容器 | ||
| 194 | checkbox_frame = ttk.Frame(main_frame) | ||
| 195 | checkbox_frame.pack(fill="x", pady=(0, 20)) | ||
| 196 | |||
| 197 | # 记住用户名复选框 | ||
| 198 | remember_user_check = tk.Checkbutton(checkbox_frame, | ||
| 199 | text="记住用户名", | ||
| 200 | variable=self.remember_user, | ||
| 201 | font=('Segoe UI', 9), | ||
| 202 | bg='white', | ||
| 203 | activebackground='white') | ||
| 204 | remember_user_check.pack(side="left") | ||
| 205 | |||
| 206 | # 记住密码复选框 | ||
| 207 | remember_password_check = tk.Checkbutton(checkbox_frame, | ||
| 208 | text="记住密码", | ||
| 209 | variable=self.remember_password, | ||
| 210 | font=('Segoe UI', 9), | ||
| 211 | bg='white', | ||
| 212 | activebackground='white') | ||
| 213 | remember_password_check.pack(side="left", padx=(20, 0)) | ||
| 214 | |||
| 215 | # 登录按钮 | ||
| 216 | self.login_button = ttk.Button(main_frame, | ||
| 217 | text="登录", | ||
| 218 | style='Login.TButton', | ||
| 219 | command=self.on_login) | ||
| 220 | self.login_button.pack(fill="x") | ||
| 221 | |||
| 222 | # 错误提示标签 | ||
| 223 | self.error_label = tk.Label(main_frame, | ||
| 224 | text="", | ||
| 225 | font=('Segoe UI', 9), | ||
| 226 | bg='white', | ||
| 227 | fg='#ff3b30') | ||
| 228 | self.error_label.pack(pady=(15, 0)) | ||
| 229 | |||
| 230 | # 焦点设置 | ||
| 231 | if self.last_user: | ||
| 232 | self.password_entry.focus() | ||
| 233 | else: | ||
| 234 | self.username_entry.focus() | ||
| 235 | |||
| 236 | def on_password_change(self, event): | ||
| 237 | """监听密码框变化""" | ||
| 238 | if not self.password_changed and self.saved_password_hash: | ||
| 239 | # 首次修改密码,清空占位符 | ||
| 240 | self.password_entry.delete(0, tk.END) | ||
| 241 | self.password_entry.config(fg='#000000') | ||
| 242 | self.password_changed = True | ||
| 243 | |||
| 244 | def on_login(self): | ||
| 245 | """处理登录""" | ||
| 246 | print("[DEBUG] 登录按钮被点击") | ||
| 247 | username = self.username_entry.get().strip() | ||
| 248 | password_input = self.password_entry.get() | ||
| 249 | |||
| 250 | print(f"[DEBUG] 用户名: {username}") | ||
| 251 | print(f"[DEBUG] 密码输入长度: {len(password_input)}") | ||
| 252 | print(f"[DEBUG] 密码已修改: {self.password_changed}") | ||
| 253 | print(f"[DEBUG] 有保存的哈希: {bool(self.saved_password_hash)}") | ||
| 254 | |||
| 255 | # 验证输入 | ||
| 256 | if not username: | ||
| 257 | print("[DEBUG] 用户名为空") | ||
| 258 | self.show_error("请输入用户名") | ||
| 259 | return | ||
| 260 | |||
| 261 | if not password_input: | ||
| 262 | print("[DEBUG] 密码为空") | ||
| 263 | self.show_error("请输入密码") | ||
| 264 | return | ||
| 265 | |||
| 266 | # 禁用按钮,防止重复点击 | ||
| 267 | self.login_button.config(state='disabled') | ||
| 268 | self.error_label.config(text="正在验证...", fg='#666666') | ||
| 269 | self.root.update() | ||
| 270 | |||
| 271 | # 判断使用保存的密码还是新输入的密码 | ||
| 272 | if not self.password_changed and self.saved_password_hash: | ||
| 273 | # 使用保存的密码哈希 | ||
| 274 | print("[DEBUG] 使用保存的密码哈希") | ||
| 275 | password_hash = self.saved_password_hash | ||
| 276 | else: | ||
| 277 | # 计算新密码的哈希 | ||
| 278 | print("[DEBUG] 计算新密码的哈希") | ||
| 279 | password_hash = hash_password(password_input) | ||
| 280 | |||
| 281 | # 直接使用哈希值进行数据库验证 | ||
| 282 | try: | ||
| 283 | print(f"[DEBUG] 开始连接数据库: {self.db_config['host']}") | ||
| 284 | conn = pymysql.connect( | ||
| 285 | host=self.db_config['host'], | ||
| 286 | port=self.db_config.get('port', 3306), | ||
| 287 | user=self.db_config['user'], | ||
| 288 | password=self.db_config['password'], | ||
| 289 | database=self.db_config['database'], | ||
| 290 | connect_timeout=5 | ||
| 291 | ) | ||
| 292 | print("[DEBUG] 数据库连接成功") | ||
| 293 | |||
| 294 | try: | ||
| 295 | with conn.cursor() as cursor: | ||
| 296 | sql = f"SELECT * FROM {self.db_config['table']} WHERE user_name=%s AND passwd=%s AND status='active'" | ||
| 297 | print(f"[DEBUG] 执行查询,用户名: {username}, 哈希前8位: {password_hash[:8]}...") | ||
| 298 | cursor.execute(sql, (username, password_hash)) | ||
| 299 | result = cursor.fetchone() | ||
| 300 | print(f"[DEBUG] 查询结果: {'找到用户' if result else '未找到匹配'}") | ||
| 301 | |||
| 302 | if result: | ||
| 303 | print("[DEBUG] 登录成功") | ||
| 304 | self.success = True | ||
| 305 | self.authenticated_user = username | ||
| 306 | # 保存密码哈希用于下次登录 | ||
| 307 | self.current_password_hash = password_hash | ||
| 308 | self.root.quit() | ||
| 309 | self.root.destroy() | ||
| 310 | else: | ||
| 311 | print("[DEBUG] 用户名或密码错误") | ||
| 312 | self.show_error("用户名或密码错误") | ||
| 313 | self.password_entry.delete(0, tk.END) | ||
| 314 | self.password_changed = False | ||
| 315 | self.login_button.config(state='normal') | ||
| 316 | finally: | ||
| 317 | conn.close() | ||
| 318 | print("[DEBUG] 数据库连接已关闭") | ||
| 319 | |||
| 320 | except pymysql.OperationalError as e: | ||
| 321 | print(f"[DEBUG] 数据库连接失败: {e}") | ||
| 322 | self.show_error("无法连接到服务器,请检查网络连接") | ||
| 323 | self.login_button.config(state='normal') | ||
| 324 | except Exception as e: | ||
| 325 | print(f"[DEBUG] 认证异常: {e}") | ||
| 326 | self.show_error(f"认证失败: {str(e)}") | ||
| 327 | self.login_button.config(state='normal') | ||
| 328 | |||
| 329 | def show_error(self, message): | ||
| 330 | """显示错误信息""" | ||
| 331 | self.error_label.config(text=message, fg='#ff3b30') | ||
| 332 | |||
| 333 | def on_close(self): | ||
| 334 | """处理窗口关闭""" | ||
| 335 | self.success = False | ||
| 336 | self.root.quit() | ||
| 337 | self.root.destroy() | ||
| 338 | |||
| 339 | def run(self): | ||
| 340 | """运行登录窗口""" | ||
| 341 | self.root.mainloop() | ||
| 342 | return (self.success, | ||
| 343 | self.authenticated_user, | ||
| 344 | self.remember_user.get(), | ||
| 345 | self.remember_password.get(), | ||
| 346 | getattr(self, 'current_password_hash', '')) | ||
| 347 | |||
| 348 | |||
| 349 | class ImageGeneratorApp: | ||
| 350 | def __init__(self, root): | ||
| 351 | self.root = root | ||
| 352 | self.root.title("AI 图像生成器") | ||
| 353 | self.root.geometry("1200x850") | ||
| 354 | self.root.minsize(1000, 700) | ||
| 355 | |||
| 356 | # Configure modern styling | ||
| 357 | self.setup_styles() | ||
| 358 | |||
| 359 | self.api_key = "" | ||
| 360 | self.uploaded_images = [] # List of (file_path, PhotoImage) tuples | ||
| 361 | self.generated_image_data = None | ||
| 362 | self.generated_image_bytes = None | ||
| 363 | self.saved_prompts = [] # Store favorite prompts | ||
| 364 | |||
| 365 | self.load_config() | ||
| 366 | self.setup_ui() | ||
| 367 | |||
| 368 | def setup_styles(self): | ||
| 369 | """Setup modern macOS-inspired UI styles""" | ||
| 370 | style = ttk.Style() | ||
| 371 | style.theme_use('clam') | ||
| 372 | |||
| 373 | # macOS-inspired color palette | ||
| 374 | bg_color = '#ffffff' | ||
| 375 | secondary_bg = '#f6f6f6' | ||
| 376 | accent_color = '#007AFF' | ||
| 377 | hover_color = '#0051D5' | ||
| 378 | border_color = '#e5e5e5' | ||
| 379 | text_color = '#1d1d1f' | ||
| 380 | |||
| 381 | self.root.configure(bg=bg_color) | ||
| 382 | |||
| 383 | # Primary button style | ||
| 384 | style.configure('Accent.TButton', | ||
| 385 | background=accent_color, | ||
| 386 | foreground='white', | ||
| 387 | borderwidth=0, | ||
| 388 | focuscolor='none', | ||
| 389 | font=('Segoe UI', 10), | ||
| 390 | padding=(18, 8)) | ||
| 391 | |||
| 392 | style.map('Accent.TButton', | ||
| 393 | background=[('active', hover_color), ('pressed', hover_color)], | ||
| 394 | foreground=[('disabled', '#999999')]) | ||
| 395 | |||
| 396 | # Secondary button style | ||
| 397 | style.configure('Secondary.TButton', | ||
| 398 | background=secondary_bg, | ||
| 399 | foreground=text_color, | ||
| 400 | borderwidth=1, | ||
| 401 | relief='flat', | ||
| 402 | font=('Segoe UI', 9), | ||
| 403 | padding=(12, 6)) | ||
| 404 | |||
| 405 | style.map('Secondary.TButton', | ||
| 406 | background=[('active', '#e8e8e8'), ('pressed', '#d8d8d8')]) | ||
| 407 | |||
| 408 | # Icon button style (small, subtle) | ||
| 409 | style.configure('Icon.TButton', | ||
| 410 | background=bg_color, | ||
| 411 | foreground='#666666', | ||
| 412 | borderwidth=0, | ||
| 413 | font=('Segoe UI', 9), | ||
| 414 | padding=(4, 4)) | ||
| 415 | |||
| 416 | style.map('Icon.TButton', | ||
| 417 | background=[('active', secondary_bg)], | ||
| 418 | foreground=[('active', accent_color)]) | ||
| 419 | |||
| 420 | # Delete button style (visible with red hover) | ||
| 421 | style.configure('Delete.TButton', | ||
| 422 | background='#ff4444', | ||
| 423 | foreground='#ffffff', | ||
| 424 | borderwidth=1, | ||
| 425 | relief='solid', | ||
| 426 | font=('Segoe UI', 9, 'bold'), | ||
| 427 | padding=(3, 1)) | ||
| 428 | |||
| 429 | style.map('Delete.TButton', | ||
| 430 | background=[('active', '#FF3B30'), ('pressed', '#cc0000')], | ||
| 431 | foreground=[('active', 'white'), ('pressed', 'white')], | ||
| 432 | borderwidth=[('active', 1)], | ||
| 433 | relief=[('active', 'solid')]) | ||
| 434 | |||
| 435 | style.configure('TLabelframe', background=bg_color, borderwidth=0, relief='flat') | ||
| 436 | style.configure('TLabelframe.Label', background=bg_color, font=('Segoe UI', 10, 'bold'), foreground=text_color) | ||
| 437 | style.configure('TLabel', background=bg_color, font=('Segoe UI', 9), foreground=text_color) | ||
| 438 | style.configure('TFrame', background=bg_color) | ||
| 439 | style.configure('Card.TFrame', background=secondary_bg, relief='flat') | ||
| 440 | style.configure('TCombobox', font=('Segoe UI', 9)) | ||
| 441 | |||
| 442 | def get_config_dir(self): | ||
| 443 | """Get the appropriate directory for config files based on platform and mode""" | ||
| 444 | # Check if running as a bundled app (PyInstaller) | ||
| 445 | if getattr(sys, 'frozen', False): | ||
| 446 | # Running as bundled app - use user data directory | ||
| 447 | system = platform.system() | ||
| 448 | if system == 'Darwin': # macOS | ||
| 449 | config_dir = Path.home() / 'Library' / 'Application Support' / 'ZB100ImageGenerator' | ||
| 450 | elif system == 'Windows': | ||
| 451 | config_dir = Path(os.getenv('APPDATA', Path.home())) / 'ZB100ImageGenerator' | ||
| 452 | else: # Linux and others | ||
| 453 | config_dir = Path.home() / '.config' / 'zb100imagegenerator' | ||
| 454 | else: | ||
| 455 | # Running in development mode - use current directory | ||
| 456 | config_dir = Path('.') | ||
| 457 | |||
| 458 | # Create directory if it doesn't exist | ||
| 459 | config_dir.mkdir(parents=True, exist_ok=True) | ||
| 460 | return config_dir | ||
| 461 | |||
| 462 | def get_config_path(self): | ||
| 463 | """Get the full path to config.json""" | ||
| 464 | return self.get_config_dir() / 'config.json' | ||
| 465 | |||
| 466 | def load_config(self): | ||
| 467 | """Load API key, saved prompts, and db config from config file""" | ||
| 468 | config_path = self.get_config_path() | ||
| 469 | |||
| 470 | # 初始化默认值 | ||
| 471 | self.db_config = None | ||
| 472 | self.last_user = "" | ||
| 473 | |||
| 474 | # Try to load from user config first | ||
| 475 | if config_path.exists(): | ||
| 476 | try: | ||
| 477 | with open(config_path, 'r', encoding='utf-8') as f: | ||
| 478 | config = json.load(f) | ||
| 479 | self.api_key = config.get("api_key", "") | ||
| 480 | self.saved_prompts = config.get("saved_prompts", []) | ||
| 481 | self.db_config = config.get("db_config") | ||
| 482 | self.last_user = config.get("last_user", "") | ||
| 483 | except Exception as e: | ||
| 484 | print(f"Failed to load config from {config_path}: {e}") | ||
| 485 | |||
| 486 | # If no config found and we're in bundled mode, try to load from bundled resources | ||
| 487 | if not self.api_key and getattr(sys, 'frozen', False): | ||
| 488 | try: | ||
| 489 | # PyInstaller creates a temp folder and stores path in _MEIPASS | ||
| 490 | bundle_dir = Path(sys._MEIPASS) | ||
| 491 | bundled_config = bundle_dir / 'config.json' | ||
| 492 | if bundled_config.exists(): | ||
| 493 | with open(bundled_config, 'r', encoding='utf-8') as f: | ||
| 494 | config = json.load(f) | ||
| 495 | self.api_key = config.get("api_key", "") | ||
| 496 | self.db_config = config.get("db_config") | ||
| 497 | # Don't load saved_prompts from bundle, only API key | ||
| 498 | # Save to user config for future use | ||
| 499 | self.save_config() | ||
| 500 | except Exception as e: | ||
| 501 | print(f"Failed to load bundled config: {e}") | ||
| 502 | |||
| 503 | if not self.api_key: | ||
| 504 | messagebox.showwarning("警告", f"未找到API密钥\n配置文件位置: {config_path}\n\n请在应用中输入API密钥或手动编辑配置文件") | ||
| 505 | |||
| 506 | def save_config(self, last_user=None): | ||
| 507 | """Save configuration to file""" | ||
| 508 | config_path = self.get_config_path() | ||
| 509 | |||
| 510 | try: | ||
| 511 | config = { | ||
| 512 | "api_key": self.api_key, | ||
| 513 | "saved_prompts": self.saved_prompts | ||
| 514 | } | ||
| 515 | |||
| 516 | # 添加数据库配置(如果存在) | ||
| 517 | if self.db_config: | ||
| 518 | config["db_config"] = self.db_config | ||
| 519 | |||
| 520 | # 添加最后登录用户 | ||
| 521 | if last_user is not None: | ||
| 522 | config["last_user"] = last_user | ||
| 523 | elif hasattr(self, 'last_user'): | ||
| 524 | config["last_user"] = self.last_user | ||
| 525 | else: | ||
| 526 | config["last_user"] = "" | ||
| 527 | |||
| 528 | # Ensure directory exists | ||
| 529 | config_path.parent.mkdir(parents=True, exist_ok=True) | ||
| 530 | |||
| 531 | with open(config_path, 'w', encoding='utf-8') as f: | ||
| 532 | json.dump(config, f, indent=2, ensure_ascii=False) | ||
| 533 | except Exception as e: | ||
| 534 | messagebox.showerror("错误", f"保存配置失败: {str(e)}\n路径: {config_path}") | ||
| 535 | |||
| 536 | def setup_ui(self): | ||
| 537 | """Setup the user interface with macOS-inspired design""" | ||
| 538 | # Main container | ||
| 539 | main_container = ttk.Frame(self.root, padding=20) | ||
| 540 | main_container.pack(fill="both", expand=True) | ||
| 541 | |||
| 542 | # Reference Images Section with preview | ||
| 543 | ref_frame = ttk.LabelFrame(main_container, text="参考图片", padding=12) | ||
| 544 | ref_frame.pack(fill="x", pady=(0, 15)) | ||
| 545 | |||
| 546 | # Upload button and info | ||
| 547 | upload_header = ttk.Frame(ref_frame) | ||
| 548 | upload_header.pack(fill="x", pady=(0, 8)) | ||
| 549 | |||
| 550 | upload_btn = ttk.Button(upload_header, text="+ 添加图片", | ||
| 551 | command=self.upload_images, | ||
| 552 | style='Secondary.TButton') | ||
| 553 | upload_btn.pack(side="left") | ||
| 554 | self.bind_hover_effect(upload_btn) | ||
| 555 | |||
| 556 | self.image_count_label = ttk.Label(upload_header, text="已选择 0 张", foreground='#666666') | ||
| 557 | self.image_count_label.pack(side="left", padx=12) | ||
| 558 | |||
| 559 | # Image preview container (horizontal scrollable) | ||
| 560 | preview_container = ttk.Frame(ref_frame, style='Card.TFrame', height=140) | ||
| 561 | preview_container.pack(fill="x", pady=(0, 0)) | ||
| 562 | preview_container.pack_propagate(False) | ||
| 563 | |||
| 564 | # Canvas for horizontal scrolling (without visible scrollbar) | ||
| 565 | self.img_canvas = tk.Canvas(preview_container, height=110, bg='#f6f6f6', | ||
| 566 | highlightthickness=0, bd=0) | ||
| 567 | self.img_preview_frame = ttk.Frame(self.img_canvas, style='Card.TFrame') | ||
| 568 | |||
| 569 | self.img_preview_frame.bind("<Configure>", | ||
| 570 | lambda e: self.img_canvas.configure(scrollregion=self.img_canvas.bbox("all"))) | ||
| 571 | |||
| 572 | self.img_canvas.create_window((0, 0), window=self.img_preview_frame, anchor="nw") | ||
| 573 | |||
| 574 | # Enable mouse wheel scrolling | ||
| 575 | self.img_canvas.bind('<MouseWheel>', self._on_mousewheel) | ||
| 576 | self.img_canvas.bind('<Shift-MouseWheel>', self._on_mousewheel) | ||
| 577 | |||
| 578 | self.img_canvas.pack(fill="both", expand=True) | ||
| 579 | |||
| 580 | # Content area: Prompt (left) + Settings (right) | ||
| 581 | content_row = ttk.Frame(main_container) | ||
| 582 | content_row.pack(fill="x", pady=(0, 15)) | ||
| 583 | |||
| 584 | # Left: Prompt Section | ||
| 585 | prompt_container = ttk.LabelFrame(content_row, text="提示词", padding=12) | ||
| 586 | prompt_container.pack(side="left", fill="both", expand=True, padx=(0, 10)) | ||
| 587 | |||
| 588 | # Prompt toolbar | ||
| 589 | prompt_toolbar = ttk.Frame(prompt_container) | ||
| 590 | prompt_toolbar.pack(fill="x", pady=(0, 8)) | ||
| 591 | |||
| 592 | self.save_prompt_btn = ttk.Button(prompt_toolbar, text="⭐ 收藏", | ||
| 593 | command=self.save_prompt, | ||
| 594 | style='Icon.TButton') | ||
| 595 | self.save_prompt_btn.pack(side="left", padx=(0, 5)) | ||
| 596 | self.bind_hover_effect(self.save_prompt_btn) | ||
| 597 | |||
| 598 | # Saved prompts dropdown | ||
| 599 | ttk.Label(prompt_toolbar, text="快速选择:", foreground='#666666').pack(side="left", padx=(10, 5)) | ||
| 600 | self.saved_prompts_combo = ttk.Combobox(prompt_toolbar, width=30, state="readonly") | ||
| 601 | self.saved_prompts_combo.pack(side="left") | ||
| 602 | self.saved_prompts_combo.bind('<<ComboboxSelected>>', self.load_saved_prompt) | ||
| 603 | self.update_saved_prompts_list() | ||
| 604 | |||
| 605 | # Delete saved prompt button | ||
| 606 | delete_prompt_btn = ttk.Button(prompt_toolbar, text="🗑️ 删除", | ||
| 607 | command=self.delete_saved_prompt, | ||
| 608 | style='Icon.TButton') | ||
| 609 | delete_prompt_btn.pack(side="left", padx=(5, 0)) | ||
| 610 | self.bind_hover_effect(delete_prompt_btn) | ||
| 611 | |||
| 612 | # Prompt text area | ||
| 613 | self.prompt_text = scrolledtext.ScrolledText(prompt_container, height=8, wrap=tk.WORD, | ||
| 614 | font=('Segoe UI', 10), | ||
| 615 | borderwidth=1, relief='solid', | ||
| 616 | bg='#fafafa') | ||
| 617 | self.prompt_text.pack(fill="both", expand=True) | ||
| 618 | self.prompt_text.insert("1.0", "一幅美丽的风景画,有山有湖,日落时分") | ||
| 619 | |||
| 620 | # Right: Settings Section | ||
| 621 | settings_container = ttk.LabelFrame(content_row, text="生成设置", padding=12) | ||
| 622 | settings_container.pack(side="right", fill="y") | ||
| 623 | |||
| 624 | # Aspect Ratio | ||
| 625 | ttk.Label(settings_container, text="宽高比", foreground='#666666').pack(anchor="w", pady=(0, 4)) | ||
| 626 | self.aspect_ratio = ttk.Combobox(settings_container, width=18, state="readonly") | ||
| 627 | self.aspect_ratio['values'] = ("1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9") | ||
| 628 | self.aspect_ratio.current(0) | ||
| 629 | self.aspect_ratio.pack(fill="x", pady=(0, 12)) | ||
| 630 | |||
| 631 | # Image Size | ||
| 632 | ttk.Label(settings_container, text="图片尺寸", foreground='#666666').pack(anchor="w", pady=(0, 4)) | ||
| 633 | self.image_size = ttk.Combobox(settings_container, width=18, state="readonly") | ||
| 634 | self.image_size['values'] = ("1K", "2K", "4K") | ||
| 635 | self.image_size.current(1) | ||
| 636 | self.image_size.pack(fill="x", pady=(0, 12)) | ||
| 637 | |||
| 638 | # Model | ||
| 639 | ttk.Label(settings_container, text="AI 模型", foreground='#666666').pack(anchor="w", pady=(0, 4)) | ||
| 640 | self.model = ttk.Combobox(settings_container, width=18, state="readonly") | ||
| 641 | self.model['values'] = ("gemini-3-pro-image-preview") | ||
| 642 | self.model.current(0) | ||
| 643 | self.model.pack(fill="x", pady=(0, 0)) | ||
| 644 | |||
| 645 | # Action buttons | ||
| 646 | action_frame = ttk.Frame(main_container) | ||
| 647 | action_frame.pack(fill="x", pady=(0, 15)) | ||
| 648 | |||
| 649 | self.generate_btn = ttk.Button(action_frame, text="生成图片", | ||
| 650 | command=self.generate_image_async, | ||
| 651 | style='Accent.TButton') | ||
| 652 | self.generate_btn.pack(side="left", padx=(0, 10)) | ||
| 653 | self.bind_hover_effect(self.generate_btn, scale=True) | ||
| 654 | |||
| 655 | self.download_btn = ttk.Button(action_frame, text="下载图片", | ||
| 656 | command=self.download_image, | ||
| 657 | state="disabled", | ||
| 658 | style='Secondary.TButton') | ||
| 659 | self.download_btn.pack(side="left", padx=(0, 10)) | ||
| 660 | self.bind_hover_effect(self.download_btn) | ||
| 661 | |||
| 662 | self.status_label = ttk.Label(action_frame, text="● 就绪", | ||
| 663 | font=('Segoe UI', 9), | ||
| 664 | foreground='#007AFF') | ||
| 665 | self.status_label.pack(side="left", padx=15) | ||
| 666 | |||
| 667 | # Preview Section (expands to fill remaining space) | ||
| 668 | preview_frame = ttk.LabelFrame(main_container, text="预览", padding=12) | ||
| 669 | preview_frame.pack(fill="both", expand=True) | ||
| 670 | |||
| 671 | # Create a frame to center the image | ||
| 672 | preview_inner = ttk.Frame(preview_frame) | ||
| 673 | preview_inner.pack(fill="both", expand=True) | ||
| 674 | |||
| 675 | self.preview_label = ttk.Label(preview_inner, text="生成的图片将在这里显示\n双击用系统查看器打开", | ||
| 676 | anchor="center", | ||
| 677 | font=('Segoe UI', 10), | ||
| 678 | foreground='#999999') | ||
| 679 | self.preview_label.place(relx=0.5, rely=0.5, anchor="center") | ||
| 680 | |||
| 681 | # Bind double-click to open with system viewer | ||
| 682 | self.preview_label.bind('<Double-Button-1>', self.open_fullsize_view) | ||
| 683 | |||
| 684 | def _on_mousewheel(self, event): | ||
| 685 | """Handle horizontal scrolling with mouse wheel""" | ||
| 686 | # Shift+Wheel or just Wheel for horizontal scroll | ||
| 687 | self.img_canvas.xview_scroll(int(-1 * (event.delta / 120)), "units") | ||
| 688 | |||
| 689 | def bind_hover_effect(self, widget, scale=False): | ||
| 690 | """Add smooth macOS-style hover effect""" | ||
| 691 | original_cursor = widget['cursor'] | ||
| 692 | |||
| 693 | def on_enter(e): | ||
| 694 | widget['cursor'] = 'hand2' | ||
| 695 | # Subtle scale effect for primary buttons | ||
| 696 | if scale and hasattr(widget, 'configure'): | ||
| 697 | try: | ||
| 698 | widget.configure(padding=(19, 9)) | ||
| 699 | except: | ||
| 700 | pass | ||
| 701 | |||
| 702 | def on_leave(e): | ||
| 703 | widget['cursor'] = original_cursor | ||
| 704 | if scale and hasattr(widget, 'configure'): | ||
| 705 | try: | ||
| 706 | widget.configure(padding=(18, 8)) | ||
| 707 | except: | ||
| 708 | pass | ||
| 709 | |||
| 710 | widget.bind('<Enter>', on_enter) | ||
| 711 | widget.bind('<Leave>', on_leave) | ||
| 712 | |||
| 713 | def update_saved_prompts_list(self): | ||
| 714 | """Update the saved prompts dropdown""" | ||
| 715 | if self.saved_prompts: | ||
| 716 | # Show first 50 chars of each prompt | ||
| 717 | display_prompts = [p[:50] + "..." if len(p) > 50 else p for p in self.saved_prompts] | ||
| 718 | self.saved_prompts_combo['values'] = display_prompts | ||
| 719 | else: | ||
| 720 | self.saved_prompts_combo['values'] = [] | ||
| 721 | |||
| 722 | def save_prompt(self): | ||
| 723 | """Save current prompt to favorites""" | ||
| 724 | prompt = self.prompt_text.get("1.0", tk.END).strip() | ||
| 725 | if not prompt: | ||
| 726 | self.status_label.config(text="● 提示词不能为空", foreground='#FF3B30') | ||
| 727 | return | ||
| 728 | |||
| 729 | if prompt in self.saved_prompts: | ||
| 730 | self.status_label.config(text="● 该提示词已收藏", foreground='#FF9500') | ||
| 731 | self.save_prompt_btn.config(text="✓ 已收藏") | ||
| 732 | # 2秒后恢复按钮文本 | ||
| 733 | self.root.after(2000, lambda: self.save_prompt_btn.config(text="⭐ 收藏")) | ||
| 734 | return | ||
| 735 | |||
| 736 | self.saved_prompts.append(prompt) | ||
| 737 | self.save_config() | ||
| 738 | self.update_saved_prompts_list() | ||
| 739 | self.status_label.config(text="● 已收藏提示词", foreground='#34C759') | ||
| 740 | self.save_prompt_btn.config(text="✓ 已收藏") | ||
| 741 | # 2秒后恢复按钮文本 | ||
| 742 | self.root.after(2000, lambda: self.save_prompt_btn.config(text="⭐ 收藏")) | ||
| 743 | |||
| 744 | def load_saved_prompt(self, event): | ||
| 745 | """Load a saved prompt""" | ||
| 746 | index = self.saved_prompts_combo.current() | ||
| 747 | if index >= 0 and index < len(self.saved_prompts): | ||
| 748 | self.prompt_text.delete("1.0", tk.END) | ||
| 749 | self.prompt_text.insert("1.0", self.saved_prompts[index]) | ||
| 750 | self.status_label.config(text="● 已加载提示词", foreground='#007AFF') | ||
| 751 | |||
| 752 | def delete_saved_prompt(self): | ||
| 753 | """Delete the currently selected saved prompt""" | ||
| 754 | index = self.saved_prompts_combo.current() | ||
| 755 | |||
| 756 | if index < 0 or index >= len(self.saved_prompts): | ||
| 757 | self.status_label.config(text="● 请先选择要删除的提示词", foreground='#FF9500') | ||
| 758 | return | ||
| 759 | |||
| 760 | # Delete without confirmation - just do it | ||
| 761 | self.saved_prompts.pop(index) | ||
| 762 | self.save_config() | ||
| 763 | self.update_saved_prompts_list() | ||
| 764 | self.saved_prompts_combo.set('') # Clear selection | ||
| 765 | self.status_label.config(text="● 已删除提示词", foreground='#34C759') | ||
| 766 | |||
| 767 | def upload_images(self): | ||
| 768 | """Upload reference images with preview""" | ||
| 769 | files = filedialog.askopenfilenames( | ||
| 770 | title="选择参考图片", | ||
| 771 | filetypes=[("图片文件", "*.png *.jpg *.jpeg *.gif *.bmp"), ("所有文件", "*.*")] | ||
| 772 | ) | ||
| 773 | |||
| 774 | if files: | ||
| 775 | for file_path in files: | ||
| 776 | try: | ||
| 777 | # Load and create thumbnail with uniform size | ||
| 778 | img = Image.open(file_path) | ||
| 779 | |||
| 780 | # Create 100x100 square thumbnail with center crop | ||
| 781 | thumb_size = 100 | ||
| 782 | img_copy = img.copy() | ||
| 783 | |||
| 784 | # Calculate crop box for center crop | ||
| 785 | width, height = img_copy.size | ||
| 786 | aspect = width / height | ||
| 787 | |||
| 788 | if aspect > 1: # Landscape | ||
| 789 | new_width = int(height * 1) | ||
| 790 | left = (width - new_width) // 2 | ||
| 791 | crop_box = (left, 0, left + new_width, height) | ||
| 792 | elif aspect < 1: # Portrait | ||
| 793 | new_height = int(width * 1) | ||
| 794 | top = (height - new_height) // 2 | ||
| 795 | crop_box = (0, top, width, top + new_height) | ||
| 796 | else: # Square | ||
| 797 | crop_box = (0, 0, width, height) | ||
| 798 | |||
| 799 | # Crop to square and resize | ||
| 800 | img_square = img_copy.crop(crop_box) | ||
| 801 | img_square = img_square.resize((thumb_size, thumb_size), Image.Resampling.LANCZOS) | ||
| 802 | photo = ImageTk.PhotoImage(img_square) | ||
| 803 | |||
| 804 | # Add to list maintaining order | ||
| 805 | self.uploaded_images.append((file_path, photo)) | ||
| 806 | except Exception as e: | ||
| 807 | messagebox.showerror("错误", f"无法加载图片: {file_path}\n{str(e)}") | ||
| 808 | |||
| 809 | self.update_image_preview() | ||
| 810 | self.image_count_label.config(text=f"已选择 {len(self.uploaded_images)} 张") | ||
| 811 | self.status_label.config(text=f"● 已添加 {len(files)} 张参考图片", foreground='#34C759') | ||
| 812 | |||
| 813 | def update_image_preview(self): | ||
| 814 | """Update the image preview panel""" | ||
| 815 | # Clear existing previews | ||
| 816 | for widget in self.img_preview_frame.winfo_children(): | ||
| 817 | widget.destroy() | ||
| 818 | |||
| 819 | # Nothing to show if no images | ||
| 820 | if not self.uploaded_images: | ||
| 821 | return | ||
| 822 | |||
| 823 | # Add each image with delete button | ||
| 824 | for idx, (file_path, photo) in enumerate(self.uploaded_images): | ||
| 825 | # Container for each image | ||
| 826 | img_container = ttk.Frame(self.img_preview_frame, style='Card.TFrame') | ||
| 827 | img_container.pack(side="left", padx=5, pady=5) | ||
| 828 | |||
| 829 | # Store reference in container to prevent garbage collection | ||
| 830 | img_container._photo_ref = photo | ||
| 831 | |||
| 832 | # Image label | ||
| 833 | img_label = ttk.Label(img_container, image=photo, relief='solid', borderwidth=1) | ||
| 834 | img_label.image = photo # Keep reference | ||
| 835 | img_label.pack() | ||
| 836 | |||
| 837 | # Info frame (index + delete button) | ||
| 838 | info_frame = ttk.Frame(img_container, style='Card.TFrame') | ||
| 839 | info_frame.pack(fill="x", pady=(2, 0)) | ||
| 840 | |||
| 841 | # Image index | ||
| 842 | index_label = ttk.Label(info_frame, text=f"图 {idx + 1}", | ||
| 843 | font=('Segoe UI', 8), foreground='#666666') | ||
| 844 | index_label.pack(side="left", padx=2) | ||
| 845 | |||
| 846 | # Delete button with enhanced visibility | ||
| 847 | del_btn = ttk.Button(info_frame, text="✕", width=3, | ||
| 848 | command=lambda i=idx: self.delete_image(i), | ||
| 849 | style='Delete.TButton') | ||
| 850 | del_btn.pack(side="right") | ||
| 851 | |||
| 852 | # Enhanced hover effect for delete button | ||
| 853 | def on_delete_hover(e, btn=del_btn): | ||
| 854 | btn['cursor'] = 'hand2' | ||
| 855 | |||
| 856 | def on_delete_leave(e, btn=del_btn): | ||
| 857 | btn['cursor'] = '' | ||
| 858 | |||
| 859 | del_btn.bind('<Enter>', on_delete_hover) | ||
| 860 | del_btn.bind('<Leave>', on_delete_leave) | ||
| 861 | |||
| 862 | # Force canvas to update scrollregion | ||
| 863 | self.img_canvas.update_idletasks() | ||
| 864 | |||
| 865 | def delete_image(self, index): | ||
| 866 | """Delete a specific image by index""" | ||
| 867 | if 0 <= index < len(self.uploaded_images): | ||
| 868 | self.uploaded_images.pop(index) | ||
| 869 | self.update_image_preview() | ||
| 870 | self.image_count_label.config(text=f"已选择 {len(self.uploaded_images)} 张") | ||
| 871 | self.status_label.config(text="● 已删除图片", foreground='#FF9500') | ||
| 872 | |||
| 873 | |||
| 874 | |||
| 875 | def image_to_base64(self, image_path): | ||
| 876 | """Convert image file to base64 string""" | ||
| 877 | with open(image_path, 'rb') as f: | ||
| 878 | return base64.b64encode(f.read()).decode('utf-8') | ||
| 879 | |||
| 880 | def generate_image_async(self): | ||
| 881 | """Start image generation in a separate thread""" | ||
| 882 | thread = threading.Thread(target=self.generate_image, daemon=True) | ||
| 883 | thread.start() | ||
| 884 | |||
| 885 | def generate_image(self): | ||
| 886 | """Generate image using Gemini API""" | ||
| 887 | prompt = self.prompt_text.get("1.0", tk.END).strip() | ||
| 888 | |||
| 889 | if not prompt: | ||
| 890 | self.root.after(0, lambda: messagebox.showerror("错误", "请输入图片描述!")) | ||
| 891 | return | ||
| 892 | |||
| 893 | if not self.api_key: | ||
| 894 | self.root.after(0, lambda: messagebox.showerror("错误", "未找到API密钥,请在config.json中配置!")) | ||
| 895 | return | ||
| 896 | |||
| 897 | self.root.after(0, lambda: self.status_label.config(text="● 正在生成图片...", foreground='#FF9500')) | ||
| 898 | self.root.after(0, lambda: self.generate_btn.config(state="disabled")) | ||
| 899 | self.root.after(0, lambda: self.download_btn.config(state="disabled")) | ||
| 900 | |||
| 901 | try: | ||
| 902 | client = genai.Client(api_key=self.api_key) | ||
| 903 | |||
| 904 | # Build content parts | ||
| 905 | content_parts = [prompt] | ||
| 906 | |||
| 907 | # Add reference images if uploaded | ||
| 908 | for img_path, _ in self.uploaded_images: | ||
| 909 | img_data = self.image_to_base64(img_path) | ||
| 910 | mime_type = "image/png" | ||
| 911 | if img_path.lower().endswith('.jpg') or img_path.lower().endswith('.jpeg'): | ||
| 912 | mime_type = "image/jpeg" | ||
| 913 | |||
| 914 | content_parts.append( | ||
| 915 | types.Part.from_bytes( | ||
| 916 | data=base64.b64decode(img_data), | ||
| 917 | mime_type=mime_type | ||
| 918 | ) | ||
| 919 | ) | ||
| 920 | |||
| 921 | # Generation config - using snake_case field names | ||
| 922 | config = types.GenerateContentConfig( | ||
| 923 | response_modalities=["IMAGE"], | ||
| 924 | image_config=types.ImageConfig( | ||
| 925 | aspect_ratio=self.aspect_ratio.get(), | ||
| 926 | image_size=self.image_size.get() | ||
| 927 | ) | ||
| 928 | ) | ||
| 929 | |||
| 930 | # Generate | ||
| 931 | response = client.models.generate_content( | ||
| 932 | model=self.model.get(), | ||
| 933 | contents=content_parts, | ||
| 934 | config=config | ||
| 935 | ) | ||
| 936 | |||
| 937 | # Extract image - Fixed for proper data handling | ||
| 938 | for part in response.parts: | ||
| 939 | if hasattr(part, 'inline_data') and part.inline_data: | ||
| 940 | # Store both base64 string and raw bytes | ||
| 941 | if isinstance(part.inline_data.data, bytes): | ||
| 942 | self.generated_image_bytes = part.inline_data.data | ||
| 943 | self.generated_image_data = base64.b64encode(part.inline_data.data).decode('utf-8') | ||
| 944 | else: | ||
| 945 | self.generated_image_data = part.inline_data.data | ||
| 946 | self.generated_image_bytes = base64.b64decode(part.inline_data.data) | ||
| 947 | |||
| 948 | self.root.after(0, self.display_image) | ||
| 949 | self.root.after(0, lambda: self.download_btn.config(state="normal")) | ||
| 950 | self.root.after(0, lambda: self.status_label.config(text="● 图片生成成功", foreground='#34C759')) | ||
| 951 | return | ||
| 952 | |||
| 953 | raise Exception("响应中没有图片数据") | ||
| 954 | |||
| 955 | except Exception as e: | ||
| 956 | error_msg = str(e) | ||
| 957 | self.root.after(0, lambda: messagebox.showerror("错误", f"生成失败: {error_msg}")) | ||
| 958 | self.root.after(0, lambda: self.status_label.config(text="● 生成失败", foreground='#FF3B30')) | ||
| 959 | finally: | ||
| 960 | self.root.after(0, lambda: self.generate_btn.config(state="normal")) | ||
| 961 | |||
| 962 | def display_image(self): | ||
| 963 | """Display generated image in preview with proper scaling""" | ||
| 964 | if not self.generated_image_bytes: | ||
| 965 | return | ||
| 966 | |||
| 967 | try: | ||
| 968 | # Use raw bytes directly for display | ||
| 969 | image = Image.open(io.BytesIO(self.generated_image_bytes)) | ||
| 970 | |||
| 971 | # Get available space (account for padding and labels) | ||
| 972 | preview_frame = self.preview_label.master | ||
| 973 | self.root.update_idletasks() | ||
| 974 | available_width = preview_frame.winfo_width() - 40 | ||
| 975 | available_height = preview_frame.winfo_height() - 40 | ||
| 976 | |||
| 977 | # Ensure minimum size | ||
| 978 | available_width = max(available_width, 400) | ||
| 979 | available_height = max(available_height, 300) | ||
| 980 | |||
| 981 | # Calculate scale to fit while maintaining aspect ratio | ||
| 982 | img_width, img_height = image.size | ||
| 983 | scale_w = available_width / img_width | ||
| 984 | scale_h = available_height / img_height | ||
| 985 | scale = min(scale_w, scale_h, 1.0) # Don't upscale | ||
| 986 | |||
| 987 | new_width = int(img_width * scale) | ||
| 988 | new_height = int(img_height * scale) | ||
| 989 | |||
| 990 | # Resize image | ||
| 991 | image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) | ||
| 992 | |||
| 993 | photo = ImageTk.PhotoImage(image) | ||
| 994 | self.preview_label.config(image=photo, text="", cursor="hand2") | ||
| 995 | self.preview_label.image = photo | ||
| 996 | except Exception as e: | ||
| 997 | error_msg = str(e) | ||
| 998 | messagebox.showerror("错误", f"图片显示失败: {error_msg}") | ||
| 999 | |||
| 1000 | def open_fullsize_view(self, event=None): | ||
| 1001 | """Open generated image with system default viewer""" | ||
| 1002 | if not self.generated_image_bytes: | ||
| 1003 | return | ||
| 1004 | |||
| 1005 | try: | ||
| 1006 | # Create temporary file | ||
| 1007 | with tempfile.NamedTemporaryFile(delete=False, suffix='.png', mode='wb') as tmp_file: | ||
| 1008 | tmp_file.write(self.generated_image_bytes) | ||
| 1009 | tmp_path = tmp_file.name | ||
| 1010 | |||
| 1011 | # Open with system default viewer | ||
| 1012 | system = platform.system() | ||
| 1013 | if system == 'Windows': | ||
| 1014 | os.startfile(tmp_path) | ||
| 1015 | elif system == 'Darwin': # macOS | ||
| 1016 | subprocess.run(['open', tmp_path], check=True) | ||
| 1017 | else: # Linux and others | ||
| 1018 | subprocess.run(['xdg-open', tmp_path], check=True) | ||
| 1019 | |||
| 1020 | self.status_label.config(text="● 已用系统查看器打开", foreground='#007AFF') | ||
| 1021 | |||
| 1022 | except Exception as e: | ||
| 1023 | messagebox.showerror("错误", f"无法打开系统图片查看器: {str(e)}") | ||
| 1024 | |||
| 1025 | def download_image(self): | ||
| 1026 | """Download generated image""" | ||
| 1027 | if not self.generated_image_bytes: | ||
| 1028 | messagebox.showerror("错误", "没有可下载的图片!") | ||
| 1029 | return | ||
| 1030 | |||
| 1031 | file_path = filedialog.asksaveasfilename( | ||
| 1032 | defaultextension=".png", | ||
| 1033 | filetypes=[("PNG 文件", "*.png"), ("JPEG 文件", "*.jpg"), ("所有文件", "*.*")], | ||
| 1034 | title="保存图片" | ||
| 1035 | ) | ||
| 1036 | |||
| 1037 | if file_path: | ||
| 1038 | try: | ||
| 1039 | # Use raw bytes directly for saving | ||
| 1040 | with open(file_path, 'wb') as f: | ||
| 1041 | f.write(self.generated_image_bytes) | ||
| 1042 | |||
| 1043 | file_size = len(self.generated_image_bytes) | ||
| 1044 | messagebox.showinfo("成功", f"图片已保存到:\n{file_path}\n\n文件大小: {file_size:,} 字节") | ||
| 1045 | self.status_label.config(text="● 图片已保存", foreground='#34C759') | ||
| 1046 | except Exception as e: | ||
| 1047 | messagebox.showerror("错误", f"保存失败: {str(e)}") | ||
| 1048 | |||
| 1049 | |||
| 1050 | def main(): | ||
| 1051 | # 首先加载配置以获取数据库信息 | ||
| 1052 | config_dir = Path('.') | ||
| 1053 | if getattr(sys, 'frozen', False): | ||
| 1054 | system = platform.system() | ||
| 1055 | if system == 'Darwin': | ||
| 1056 | config_dir = Path.home() / 'Library' / 'Application Support' / 'ZB100ImageGenerator' | ||
| 1057 | elif system == 'Windows': | ||
| 1058 | config_dir = Path(os.getenv('APPDATA', Path.home())) / 'ZB100ImageGenerator' | ||
| 1059 | else: | ||
| 1060 | config_dir = Path.home() / '.config' / 'zb100imagegenerator' | ||
| 1061 | |||
| 1062 | config_dir.mkdir(parents=True, exist_ok=True) | ||
| 1063 | config_path = config_dir / 'config.json' | ||
| 1064 | |||
| 1065 | db_config = None | ||
| 1066 | last_user = "" | ||
| 1067 | saved_password_hash = "" | ||
| 1068 | |||
| 1069 | if config_path.exists(): | ||
| 1070 | try: | ||
| 1071 | with open(config_path, 'r', encoding='utf-8') as f: | ||
| 1072 | config = json.load(f) | ||
| 1073 | db_config = config.get("db_config") | ||
| 1074 | last_user = config.get("last_user", "") | ||
| 1075 | saved_password_hash = config.get("saved_password_hash", "") | ||
| 1076 | except Exception as e: | ||
| 1077 | print(f"Failed to load config: {e}") | ||
| 1078 | |||
| 1079 | # 如果没有数据库配置,显示错误并退出 | ||
| 1080 | if not db_config: | ||
| 1081 | root = tk.Tk() | ||
| 1082 | root.withdraw() | ||
| 1083 | messagebox.showerror("配置错误", | ||
| 1084 | f"未找到数据库配置\n配置文件: {config_path}\n\n" | ||
| 1085 | "请确保 config.json 包含 db_config 字段") | ||
| 1086 | return | ||
| 1087 | |||
| 1088 | # 显示登录窗口 | ||
| 1089 | login_window = LoginWindow(db_config, last_user, saved_password_hash) | ||
| 1090 | success, authenticated_user, remember_user, remember_password, password_hash = login_window.run() | ||
| 1091 | |||
| 1092 | # 如果登录失败,退出应用 | ||
| 1093 | if not success: | ||
| 1094 | return | ||
| 1095 | |||
| 1096 | # 保存/清除 last_user 和密码哈希 | ||
| 1097 | if config_path.exists(): | ||
| 1098 | try: | ||
| 1099 | with open(config_path, 'r', encoding='utf-8') as f: | ||
| 1100 | config = json.load(f) | ||
| 1101 | |||
| 1102 | if remember_user: | ||
| 1103 | config["last_user"] = authenticated_user | ||
| 1104 | else: | ||
| 1105 | config["last_user"] = "" | ||
| 1106 | |||
| 1107 | if remember_password: | ||
| 1108 | config["saved_password_hash"] = password_hash | ||
| 1109 | else: | ||
| 1110 | config["saved_password_hash"] = "" | ||
| 1111 | |||
| 1112 | with open(config_path, 'w', encoding='utf-8') as f: | ||
| 1113 | json.dump(config, f, indent=2, ensure_ascii=False) | ||
| 1114 | except Exception as e: | ||
| 1115 | print(f"Failed to save config: {e}") | ||
| 1116 | |||
| 1117 | # 登录成功,启动主应用 | ||
| 1118 | root = tk.Tk() | ||
| 1119 | app = ImageGeneratorApp(root) | ||
| 1120 | root.mainloop() | ||
| 1121 | |||
| 1122 | |||
| 1123 | if __name__ == "__main__": | ||
| 1124 | main() |
requirements.txt
0 → 100644
setup_mac.sh
0 → 100644
| 1 | #!/bin/bash | ||
| 2 | # Mac 环境设置脚本 | ||
| 3 | |||
| 4 | echo "================================" | ||
| 5 | echo "Mac 环境设置 - Qt 版本" | ||
| 6 | echo "================================" | ||
| 7 | |||
| 8 | # 检查 Python 版本 | ||
| 9 | python_version=$(python3 --version 2>&1) | ||
| 10 | echo "Python 版本: $python_version" | ||
| 11 | |||
| 12 | # 创建虚拟环境 | ||
| 13 | if [ ! -d "venv" ]; then | ||
| 14 | echo "创建虚拟环境..." | ||
| 15 | python3 -m venv venv | ||
| 16 | fi | ||
| 17 | |||
| 18 | # 激活虚拟环境 | ||
| 19 | echo "激活虚拟环境..." | ||
| 20 | source venv/bin/activate | ||
| 21 | |||
| 22 | # 升级 pip | ||
| 23 | echo "升级 pip..." | ||
| 24 | pip install --upgrade pip | ||
| 25 | |||
| 26 | # 卸载可能冲突的旧版本 | ||
| 27 | echo "清理旧版本..." | ||
| 28 | pip uninstall -y google-genai Pillow PyQt5 pyinstaller 2>/dev/null | ||
| 29 | |||
| 30 | # 安装依赖 | ||
| 31 | echo "================================" | ||
| 32 | echo "安装依赖..." | ||
| 33 | echo "================================" | ||
| 34 | |||
| 35 | # 如果有锁定版本文件,优先使用 | ||
| 36 | if [ -f "requirements-lock.txt" ]; then | ||
| 37 | echo "使用锁定版本 (requirements-lock.txt)..." | ||
| 38 | pip install -r requirements-lock.txt | ||
| 39 | else | ||
| 40 | echo "使用范围版本 (requirements.txt)..." | ||
| 41 | pip install -r requirements.txt | ||
| 42 | fi | ||
| 43 | |||
| 44 | # 验证安装 | ||
| 45 | echo "================================" | ||
| 46 | echo "验证安装..." | ||
| 47 | echo "================================" | ||
| 48 | |||
| 49 | pip list | grep -E "google-genai|Pillow|PyQt5|pyinstaller" | ||
| 50 | |||
| 51 | echo "================================" | ||
| 52 | echo "设置完成!" | ||
| 53 | echo "================================" | ||
| 54 | echo "" | ||
| 55 | echo "运行应用: python3 image_generator_qt.py" | ||
| 56 | echo "" |
user_util.py
0 → 100644
| 1 | #!/usr/bin/env python3 | ||
| 2 | # -*- coding: utf-8 -*- | ||
| 3 | """ | ||
| 4 | 用户管理工具 - 管理员专用 | ||
| 5 | |||
| 6 | 用法: | ||
| 7 | python user_util.py add <username> <password> # 添加用户 | ||
| 8 | python user_util.py list # 列出用户 | ||
| 9 | python user_util.py disable <username> # 禁用用户 | ||
| 10 | python user_util.py enable <username> # 启用用户 | ||
| 11 | python user_util.py reset <username> <password> # 重置密码 | ||
| 12 | |||
| 13 | 安全提示: | ||
| 14 | - 此工具仅供管理员使用,请勿分发给用户 | ||
| 15 | - 避免在命令行直接输入密码(可被 shell 历史记录) | ||
| 16 | - 建议使用环境变量或交互式输入密码 | ||
| 17 | """ | ||
| 18 | |||
| 19 | import hashlib | ||
| 20 | import pymysql | ||
| 21 | import json | ||
| 22 | import sys | ||
| 23 | import os | ||
| 24 | from pathlib import Path | ||
| 25 | |||
| 26 | # Windows 控制台编码修复 | ||
| 27 | if sys.platform == 'win32': | ||
| 28 | import codecs | ||
| 29 | sys.stdout = codecs.getwriter('utf-8')(sys.stdout.buffer, 'strict') | ||
| 30 | sys.stderr = codecs.getwriter('utf-8')(sys.stderr.buffer, 'strict') | ||
| 31 | |||
| 32 | |||
| 33 | def hash_password(password: str) -> str: | ||
| 34 | """使用 SHA256 哈希密码""" | ||
| 35 | return hashlib.sha256(password.encode('utf-8')).hexdigest() | ||
| 36 | |||
| 37 | |||
| 38 | class UserManager: | ||
| 39 | """用户管理类""" | ||
| 40 | def __init__(self, db_config): | ||
| 41 | self.config = db_config | ||
| 42 | |||
| 43 | def add_user(self, username, password): | ||
| 44 | """添加新用户""" | ||
| 45 | hashed = hash_password(password) | ||
| 46 | conn = None | ||
| 47 | try: | ||
| 48 | conn = pymysql.connect( | ||
| 49 | host=self.config['host'], | ||
| 50 | port=self.config.get('port', 3306), | ||
| 51 | user=self.config['user'], | ||
| 52 | password=self.config['password'], | ||
| 53 | database=self.config['database'], | ||
| 54 | connect_timeout=5 | ||
| 55 | ) | ||
| 56 | |||
| 57 | with conn.cursor() as cursor: | ||
| 58 | sql = f"INSERT INTO {self.config['table']} (user_name, passwd, status) VALUES (%s, %s, %s)" | ||
| 59 | cursor.execute(sql, (username, hashed, 'active')) | ||
| 60 | conn.commit() | ||
| 61 | print(f"✓ 用户 '{username}' 添加成功") | ||
| 62 | print(f" 密码哈希: {hashed[:16]}...") | ||
| 63 | |||
| 64 | except pymysql.IntegrityError: | ||
| 65 | print(f"✗ 用户 '{username}' 已存在") | ||
| 66 | except pymysql.OperationalError as e: | ||
| 67 | print(f"✗ 数据库连接失败: {e}") | ||
| 68 | except Exception as e: | ||
| 69 | print(f"✗ 添加用户失败: {e}") | ||
| 70 | finally: | ||
| 71 | if conn: | ||
| 72 | conn.close() | ||
| 73 | |||
| 74 | def list_users(self): | ||
| 75 | """列出所有用户""" | ||
| 76 | conn = None | ||
| 77 | try: | ||
| 78 | conn = pymysql.connect( | ||
| 79 | host=self.config['host'], | ||
| 80 | port=self.config.get('port', 3306), | ||
| 81 | user=self.config['user'], | ||
| 82 | password=self.config['password'], | ||
| 83 | database=self.config['database'], | ||
| 84 | connect_timeout=5 | ||
| 85 | ) | ||
| 86 | |||
| 87 | with conn.cursor() as cursor: | ||
| 88 | sql = f"SELECT user_name, passwd, status FROM {self.config['table']}" | ||
| 89 | cursor.execute(sql) | ||
| 90 | results = cursor.fetchall() | ||
| 91 | |||
| 92 | if not results: | ||
| 93 | print("没有找到任何用户") | ||
| 94 | return | ||
| 95 | |||
| 96 | # 打印表格 | ||
| 97 | print("\n用户列表:") | ||
| 98 | print(" ┌────────────────────────┬──────────┬──────────────────┐") | ||
| 99 | print(" │ 用户名 │ 状态 │ 密码哈希(前8位) │") | ||
| 100 | print(" ├────────────────────────┼──────────┼──────────────────┤") | ||
| 101 | |||
| 102 | for row in results: | ||
| 103 | username = row[0] or "" | ||
| 104 | passwd_hash = row[1] or "" | ||
| 105 | status = row[2] or "NULL" | ||
| 106 | |||
| 107 | # 填充空格使对齐 | ||
| 108 | username_display = username[:20].ljust(20) | ||
| 109 | status_display = status[:8].ljust(8) | ||
| 110 | hash_display = passwd_hash[:16] if passwd_hash else "N/A".ljust(16) | ||
| 111 | |||
| 112 | print(f" │ {username_display} │ {status_display} │ {hash_display} │") | ||
| 113 | |||
| 114 | print(" └────────────────────────┴──────────┴──────────────────┘") | ||
| 115 | print(f"\n总计: {len(results)} 个用户\n") | ||
| 116 | |||
| 117 | except pymysql.OperationalError as e: | ||
| 118 | print(f"✗ 数据库连接失败: {e}") | ||
| 119 | except Exception as e: | ||
| 120 | print(f"✗ 查询用户失败: {e}") | ||
| 121 | finally: | ||
| 122 | if conn: | ||
| 123 | conn.close() | ||
| 124 | |||
| 125 | def disable_user(self, username): | ||
| 126 | """禁用用户""" | ||
| 127 | self._update_user_status(username, 'disabled', "已禁用") | ||
| 128 | |||
| 129 | def enable_user(self, username): | ||
| 130 | """启用用户""" | ||
| 131 | self._update_user_status(username, 'active', "已启用") | ||
| 132 | |||
| 133 | def _update_user_status(self, username, status, action_name): | ||
| 134 | """更新用户状态""" | ||
| 135 | conn = None | ||
| 136 | try: | ||
| 137 | conn = pymysql.connect( | ||
| 138 | host=self.config['host'], | ||
| 139 | port=self.config.get('port', 3306), | ||
| 140 | user=self.config['user'], | ||
| 141 | password=self.config['password'], | ||
| 142 | database=self.config['database'], | ||
| 143 | connect_timeout=5 | ||
| 144 | ) | ||
| 145 | |||
| 146 | with conn.cursor() as cursor: | ||
| 147 | sql = f"UPDATE {self.config['table']} SET status=%s WHERE user_name=%s" | ||
| 148 | affected = cursor.execute(sql, (status, username)) | ||
| 149 | |||
| 150 | if affected > 0: | ||
| 151 | conn.commit() | ||
| 152 | print(f"✓ 用户 '{username}' {action_name}") | ||
| 153 | else: | ||
| 154 | print(f"✗ 用户 '{username}' 不存在") | ||
| 155 | |||
| 156 | except pymysql.OperationalError as e: | ||
| 157 | print(f"✗ 数据库连接失败: {e}") | ||
| 158 | except Exception as e: | ||
| 159 | print(f"✗ 更新用户状态失败: {e}") | ||
| 160 | finally: | ||
| 161 | if conn: | ||
| 162 | conn.close() | ||
| 163 | |||
| 164 | def reset_password(self, username, new_password): | ||
| 165 | """重置用户密码""" | ||
| 166 | hashed = hash_password(new_password) | ||
| 167 | conn = None | ||
| 168 | try: | ||
| 169 | conn = pymysql.connect( | ||
| 170 | host=self.config['host'], | ||
| 171 | port=self.config.get('port', 3306), | ||
| 172 | user=self.config['user'], | ||
| 173 | password=self.config['password'], | ||
| 174 | database=self.config['database'], | ||
| 175 | connect_timeout=5 | ||
| 176 | ) | ||
| 177 | |||
| 178 | with conn.cursor() as cursor: | ||
| 179 | sql = f"UPDATE {self.config['table']} SET passwd=%s WHERE user_name=%s" | ||
| 180 | affected = cursor.execute(sql, (hashed, username)) | ||
| 181 | |||
| 182 | if affected > 0: | ||
| 183 | conn.commit() | ||
| 184 | print(f"✓ 用户 '{username}' 密码已重置") | ||
| 185 | print(f" 新密码哈希: {hashed[:16]}...") | ||
| 186 | else: | ||
| 187 | print(f"✗ 用户 '{username}' 不存在") | ||
| 188 | |||
| 189 | except pymysql.OperationalError as e: | ||
| 190 | print(f"✗ 数据库连接失败: {e}") | ||
| 191 | except Exception as e: | ||
| 192 | print(f"✗ 重置密码失败: {e}") | ||
| 193 | finally: | ||
| 194 | if conn: | ||
| 195 | conn.close() | ||
| 196 | |||
| 197 | |||
| 198 | def load_db_config(): | ||
| 199 | """从 config.json 加载数据库配置""" | ||
| 200 | config_path = Path('config.json') | ||
| 201 | |||
| 202 | if not config_path.exists(): | ||
| 203 | print(f"✗ 配置文件不存在: {config_path}") | ||
| 204 | print(f" 请确保在项目目录下运行此工具") | ||
| 205 | return None | ||
| 206 | |||
| 207 | try: | ||
| 208 | with open(config_path, 'r', encoding='utf-8') as f: | ||
| 209 | config = json.load(f) | ||
| 210 | db_config = config.get('db_config') | ||
| 211 | |||
| 212 | if not db_config: | ||
| 213 | print("✗ 配置文件中未找到 db_config 字段") | ||
| 214 | return None | ||
| 215 | |||
| 216 | required_fields = ['host', 'user', 'password', 'database', 'table'] | ||
| 217 | missing = [f for f in required_fields if f not in db_config] | ||
| 218 | |||
| 219 | if missing: | ||
| 220 | print(f"✗ db_config 缺少必需字段: {', '.join(missing)}") | ||
| 221 | return None | ||
| 222 | |||
| 223 | return db_config | ||
| 224 | |||
| 225 | except json.JSONDecodeError: | ||
| 226 | print(f"✗ 配置文件格式错误: {config_path}") | ||
| 227 | return None | ||
| 228 | except Exception as e: | ||
| 229 | print(f"✗ 加载配置失败: {e}") | ||
| 230 | return None | ||
| 231 | |||
| 232 | |||
| 233 | def print_help(): | ||
| 234 | """打印帮助信息""" | ||
| 235 | print(__doc__) | ||
| 236 | |||
| 237 | |||
| 238 | def main(): | ||
| 239 | """主函数""" | ||
| 240 | if len(sys.argv) < 2: | ||
| 241 | print_help() | ||
| 242 | sys.exit(1) | ||
| 243 | |||
| 244 | command = sys.argv[1].lower() | ||
| 245 | |||
| 246 | # 加载数据库配置 | ||
| 247 | db_config = load_db_config() | ||
| 248 | if not db_config: | ||
| 249 | sys.exit(1) | ||
| 250 | |||
| 251 | manager = UserManager(db_config) | ||
| 252 | |||
| 253 | # 处理命令 | ||
| 254 | if command == 'add': | ||
| 255 | if len(sys.argv) != 4: | ||
| 256 | print("用法: python user_util.py add <username> <password>") | ||
| 257 | sys.exit(1) | ||
| 258 | username = sys.argv[2] | ||
| 259 | password = sys.argv[3] | ||
| 260 | manager.add_user(username, password) | ||
| 261 | |||
| 262 | elif command == 'list': | ||
| 263 | manager.list_users() | ||
| 264 | |||
| 265 | elif command == 'disable': | ||
| 266 | if len(sys.argv) != 3: | ||
| 267 | print("用法: python user_util.py disable <username>") | ||
| 268 | sys.exit(1) | ||
| 269 | username = sys.argv[2] | ||
| 270 | manager.disable_user(username) | ||
| 271 | |||
| 272 | elif command == 'enable': | ||
| 273 | if len(sys.argv) != 3: | ||
| 274 | print("用法: python user_util.py enable <username>") | ||
| 275 | sys.exit(1) | ||
| 276 | username = sys.argv[2] | ||
| 277 | manager.enable_user(username) | ||
| 278 | |||
| 279 | elif command == 'reset': | ||
| 280 | if len(sys.argv) != 4: | ||
| 281 | print("用法: python user_util.py reset <username> <password>") | ||
| 282 | sys.exit(1) | ||
| 283 | username = sys.argv[2] | ||
| 284 | new_password = sys.argv[3] | ||
| 285 | manager.reset_password(username, new_password) | ||
| 286 | |||
| 287 | else: | ||
| 288 | print(f"✗ 未知命令: {command}") | ||
| 289 | print_help() | ||
| 290 | sys.exit(1) | ||
| 291 | |||
| 292 | |||
| 293 | if __name__ == '__main__': | ||
| 294 | main() |
zb100_kehuan.ico
0 → 100644
No preview for this file type
-
Please register or sign in to post a comment