afae62c5 by 柴进

打包windows版本内容

1 parent 15a2dba1
......@@ -10,7 +10,6 @@ tags: [openspec, change]
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
**Steps**
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
......
......@@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.9 (GoogleNanoBananaApp) (2)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.11 (GoogleNanoBananaApp)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
......
......@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.9 (GoogleNanoBananaApp) (2)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9 (GoogleNanoBananaApp) (2)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (GoogleNanoBananaApp)" project-jdk-type="Python SDK" />
</project>
\ No newline at end of file
......
......@@ -5,11 +5,7 @@ a = Analysis(
['image_generator.py'],
pathex=[],
binaries=[],
datas=[
('config.json', '.'),
('zb100_windows.ico', '.'),
('zb100_mac.icns', '.')
],
datas=[('config.json', '.'), ('zb100_windows.ico', '.')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
......
......@@ -33,6 +33,7 @@ pyinstaller --name="ZB100ImageGenerator" ^
--windowed ^
--icon=zb100_windows.ico ^
--add-data "config.json;." ^
--add-data "zb100_windows.ico;." ^
image_generator.py
REM Check if build was successful
......
......@@ -15,5 +15,13 @@
"table": "nano_banana_users"
},
"last_user": "testuser",
"saved_password_hash": "50630320e4a550f2dba371820dad9d9301d456d101aca4d5ad8f4f3bcc9c1ed9"
"saved_password_hash": "50630320e4a550f2dba371820dad9d9301d456d101aca4d5ad8f4f3bcc9c1ed9",
"logging_config": {
"enabled": true,
"level": "INFO",
"log_to_console": true
},
"history_config": {
"max_history_count": 100
}
}
\ No newline at end of file
......
......@@ -8,10 +8,12 @@ from PySide6.QtWidgets import (
QApplication, QMainWindow, QDialog, QWidget,
QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout,
QLabel, QLineEdit, QPushButton, QCheckBox, QTextEdit,
QComboBox, QScrollArea, QGroupBox, QFileDialog, QMessageBox
QComboBox, QScrollArea, QGroupBox, QFileDialog, QMessageBox,
QListWidget, QListWidgetItem, QTabWidget, QSplitter,
QMenu, QProgressBar
)
from PySide6.QtCore import Qt, QThread, Signal, QSize
from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices
from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer
from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices, QAction, QImage
from PySide6.QtCore import QUrl
import base64
......@@ -22,6 +24,7 @@ import sys
import shutil
import tempfile
import platform
import logging
from pathlib import Path
from google import genai
from google.genai import types
......@@ -30,6 +33,73 @@ import pymysql
import socket
import requests
from datetime import datetime
from dataclasses import dataclass, asdict
from typing import List, Optional, Dict, Any
def init_logging(log_level=logging.INFO):
"""
初始化简单的日志系统
创建logs目录并配置基本的文件日志记录
"""
try:
# 获取脚本所在目录
script_dir = Path(__file__).parent
# 尝试加载日志配置
config_path = script_dir / "config.json"
logging_config = {
"enabled": True,
"level": "INFO",
"log_to_console": True
}
if config_path.exists():
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
logging_config = config.get("logging_config", logging_config)
except Exception as e:
print(f"加载日志配置失败: {e}")
# 如果日志被禁用,直接返回成功
if not logging_config.get("enabled", True):
print("日志系统已禁用")
return True
# 解析日志级别
level_str = logging_config.get("level", "INFO").upper()
log_level = getattr(logging, level_str, logging.INFO)
# 创建logs目录
logs_dir = script_dir / "logs"
logs_dir.mkdir(exist_ok=True)
# 配置日志文件路径
log_file = logs_dir / "app.log"
# 配置日志格式
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# 配置处理器
handlers = [logging.FileHandler(log_file, encoding='utf-8')]
if logging_config.get("log_to_console", True):
handlers.append(logging.StreamHandler())
# 配置日志系统
logging.basicConfig(
level=log_level,
format=log_format,
handlers=handlers,
force=True # 强制重新配置
)
logging.info(f"日志系统初始化完成 - 级别: {level_str}, 文件: {log_file}")
return True
except Exception as e:
print(f"日志系统初始化失败: {e}")
return False
def hash_password(password: str) -> str:
......@@ -41,6 +111,7 @@ class DatabaseManager:
"""数据库连接管理类"""
def __init__(self, db_config):
self.config = db_config
self.logger = logging.getLogger(__name__)
def authenticate(self, username, password):
"""
......@@ -48,10 +119,13 @@ class DatabaseManager:
返回: (success: bool, message: str)
"""
try:
self.logger.info(f"开始用户认证: {username}")
# 计算密码哈希
password_hash = hash_password(password)
# 连接数据库
self.logger.debug(f"连接数据库: {self.config['host']}:{self.config.get('port', 3306)}")
conn = pymysql.connect(
host=self.config['host'],
port=self.config.get('port', 3306),
......@@ -69,16 +143,325 @@ class DatabaseManager:
result = cursor.fetchone()
if result:
self.logger.info(f"用户认证成功: {username}")
return True, "认证成功"
else:
self.logger.warning(f"用户认证失败: {username} - 用户名或密码错误")
return False, "用户名或密码错误"
finally:
conn.close()
except pymysql.OperationalError as e:
return False, "无法连接到服务器,请检查网络连接"
error_msg = "无法连接到服务器,请检查网络连接"
self.logger.error(f"数据库连接失败: {e}")
return False, error_msg
except Exception as e:
error_msg = f"认证失败: {str(e)}"
self.logger.error(f"认证过程异常: {e}")
return False, error_msg
@dataclass
class HistoryItem:
"""历史记录项数据结构"""
timestamp: str
prompt: str
generated_image_path: Path
reference_image_paths: List[Path]
aspect_ratio: str
image_size: str
model: str
created_at: datetime
def to_dict(self) -> Dict[str, Any]:
"""转换为字典格式"""
return {
'timestamp': self.timestamp,
'prompt': self.prompt,
'generated_image_path': str(self.generated_image_path),
'reference_image_paths': [str(p) for p in self.reference_image_paths],
'aspect_ratio': self.aspect_ratio,
'image_size': self.image_size,
'model': self.model,
'created_at': self.created_at.isoformat()
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'HistoryItem':
"""从字典创建实例"""
return cls(
timestamp=data['timestamp'],
prompt=data['prompt'],
generated_image_path=Path(data['generated_image_path']),
reference_image_paths=[Path(p) for p in data['reference_image_paths']],
aspect_ratio=data['aspect_ratio'],
image_size=data['image_size'],
model=data['model'],
created_at=datetime.fromisoformat(data['created_at'])
)
def get_app_data_path() -> Path:
"""获取应用数据存储路径 - 智能选择,优先使用当前目录"""
# 定义多个备选路径,按优先级排序
def get_candidate_paths():
"""获取候选路径列表"""
system = platform.system()
candidates = []
# 1. 优先尝试:当前目录的images文件夹
if getattr(sys, 'frozen', False):
# 打包环境:使用可执行文件所在目录
candidates.append(Path(sys.executable).parent / "images")
else:
# 开发环境:使用脚本所在目录
candidates.append(Path(__file__).parent / "images")
# 2. 备选方案:用户目录
if system == "Darwin": # macOS
candidates.append(Path.home() / "Library/Application Support/ZB100ImageGenerator/images")
candidates.append(Path.home() / "Documents/ZB100ImageGenerator/images")
elif system == "Windows":
candidates.append(Path(os.environ.get("APPDATA", "")) / "ZB100ImageGenerator/images")
candidates.append(Path.home() / "Documents/ZB100ImageGenerator/images")
else: # Linux
candidates.append(Path.home() / ".config/zb100imagegenerator/images")
candidates.append(Path.home() / "Documents/ZB100ImageGenerator/images")
return candidates
# 测试路径可用性
def test_path_write_access(path: Path) -> bool:
"""测试路径是否有写入权限"""
try:
# 尝试创建目录
path.mkdir(parents=True, exist_ok=True)
# 测试写入权限
test_file = path / ".write_test"
test_file.write_text("test")
test_file.unlink() # 删除测试文件
return True
except (PermissionError, OSError) as e:
print(f"路径 {path} 无写入权限: {e}")
return False
except Exception as e:
print(f"路径 {path} 测试失败: {e}")
return False
# 按优先级测试每个候选路径
candidates = get_candidate_paths()
for path in candidates:
if test_path_write_access(path):
print(f"使用图片存储路径: {path}")
return path
# 如果所有路径都失败,使用最后的备选方案
fallback_path = get_candidate_paths()[0] # 使用第一个候选路径
try:
fallback_path.mkdir(parents=True, exist_ok=True)
print(f"使用备选路径: {fallback_path}")
return fallback_path
except Exception as e:
print(f"警告: 无法创建存储路径,将在当前目录操作: {e}")
return Path.cwd() / "images"
class HistoryManager:
"""历史记录管理器"""
def __init__(self, base_path: Optional[Path] = None):
"""初始化历史记录管理器
Args:
base_path: 历史记录存储基础路径,默认使用get_app_data_path()的结果
"""
self.logger = logging.getLogger(__name__)
self.base_path = base_path or get_app_data_path()
self.base_path.mkdir(parents=True, exist_ok=True)
self.history_index_file = self.base_path / "history_index.json"
self.max_history_count = 100 # 默认最大历史记录数量
self.logger.debug(f"历史记录管理器初始化完成,存储路径: {self.base_path}")
def save_generation(self, image_bytes: bytes, prompt: str, reference_images: List[bytes],
aspect_ratio: str, image_size: str, model: str) -> str:
"""保存生成的图片到历史记录
Args:
image_bytes: 生成的图片字节数据
prompt: 使用的提示词
reference_images: 参考图片字节数据列表
aspect_ratio: 宽高比
image_size: 图片尺寸
model: 使用的模型
Returns:
历史记录的时间戳
"""
self.logger.info(f"开始保存历史记录 - 模型: {model}, 尺寸: {image_size}")
# 生成时间戳目录
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
record_dir = self.base_path / timestamp
record_dir.mkdir(exist_ok=True)
# 保存生成的图片
generated_image_path = record_dir / "generated.png"
with open(generated_image_path, 'wb') as f:
f.write(image_bytes)
# 保存参考图片
reference_image_paths = []
for i, ref_img_bytes in enumerate(reference_images):
ref_path = record_dir / f"reference_{i+1}.png"
with open(ref_path, 'wb') as f:
f.write(ref_img_bytes)
reference_image_paths.append(ref_path)
# 保存元数据
metadata = {
'timestamp': timestamp,
'prompt': prompt,
'aspect_ratio': aspect_ratio,
'image_size': image_size,
'model': model,
'created_at': datetime.now().isoformat()
}
metadata_path = record_dir / "metadata.json"
with open(metadata_path, 'w', encoding='utf-8') as f:
json.dump(metadata, f, ensure_ascii=False, indent=2)
# 更新历史记录索引
history_item = HistoryItem(
timestamp=timestamp,
prompt=prompt,
generated_image_path=generated_image_path,
reference_image_paths=reference_image_paths,
aspect_ratio=aspect_ratio,
image_size=image_size,
model=model,
created_at=datetime.now()
)
self._update_history_index(history_item)
# 清理旧记录
self._cleanup_old_records()
return timestamp
def load_history_index(self) -> List[HistoryItem]:
"""加载历史记录索引
Returns:
历史记录项列表,按时间戳倒序排列
"""
if not self.history_index_file.exists():
return []
try:
with open(self.history_index_file, 'r', encoding='utf-8') as f:
data = json.load(f)
history_items = [HistoryItem.from_dict(item) for item in data]
# 按时间戳倒序排列
history_items.sort(key=lambda x: x.timestamp, reverse=True)
return history_items
except Exception as e:
print(f"加载历史记录索引失败: {e}")
return []
def get_history_item(self, timestamp: str) -> Optional[HistoryItem]:
"""获取指定时间戳的历史记录项
Args:
timestamp: 时间戳
Returns:
历史记录项,如果不存在则返回None
"""
history_items = self.load_history_index()
for item in history_items:
if item.timestamp == timestamp:
return item
return None
def delete_history_item(self, timestamp: str) -> bool:
"""删除指定的历史记录
Args:
timestamp: 要删除的时间戳
Returns:
删除是否成功
"""
try:
# 删除文件目录
record_dir = self.base_path / timestamp
if record_dir.exists():
shutil.rmtree(record_dir)
# 更新索引文件
history_items = self.load_history_index()
history_items = [item for item in history_items if item.timestamp != timestamp]
self._save_history_index(history_items)
return True
except Exception as e:
print(f"删除历史记录失败: {e}")
return False
def _update_history_index(self, history_item: HistoryItem):
"""更新历史记录索引
Args:
history_item: 要添加的历史记录项
"""
history_items = self.load_history_index()
# 检查是否已存在相同时间戳的记录,如果存在则替换
history_items = [item for item in history_items if item.timestamp != history_item.timestamp]
history_items.insert(0, history_item) # 插入到开头
self._save_history_index(history_items)
def _save_history_index(self, history_items: List[HistoryItem]):
"""保存历史记录索引到文件
Args:
history_items: 历史记录项列表
"""
try:
data = [item.to_dict() for item in history_items]
with open(self.history_index_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"保存历史记录索引失败: {e}")
def _cleanup_old_records(self):
"""清理旧的历史记录,保持最大数量限制"""
history_items = self.load_history_index()
if len(history_items) > self.max_history_count:
# 保留最新的记录
items_to_keep = history_items[:self.max_history_count]
items_to_remove = history_items[self.max_history_count:]
# 删除多余记录的文件
for item in items_to_remove:
record_dir = self.base_path / item.timestamp
if record_dir.exists():
try:
shutil.rmtree(record_dir)
except Exception as e:
return False, f"认证失败: {str(e)}"
print(f"删除旧历史记录失败 {item.timestamp}: {e}")
# 更新索引文件
self._save_history_index(items_to_keep)
class LoginDialog(QDialog):
......@@ -101,6 +484,9 @@ class LoginDialog(QDialog):
def set_window_icon(self):
"""Set window icon based on platform"""
try:
icon_path = None
if getattr(sys, 'frozen', False):
# Running as compiled executable
if platform.system() == 'Windows':
......@@ -108,22 +494,24 @@ class LoginDialog(QDialog):
elif platform.system() == 'Darwin':
icon_path = os.path.join(sys._MEIPASS, 'zb100_mac.icns')
else:
icon_path = None
else:
# Running as script
if platform.system() == 'Windows':
icon_path = 'zb100_windows.ico'
elif platform.system() == 'Darwin':
icon_path = 'zb100_mac.icns'
else:
icon_path = None
if icon_path and os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path))
app_icon = QIcon(icon_path)
if not app_icon.isNull():
self.setWindowIcon(app_icon)
else:
print(f"警告:图标文件无效: {icon_path}")
except Exception as e:
print(f"设置窗口图标失败: {e}")
def setup_ui(self):
"""Build login dialog UI"""
self.setWindowTitle("登录 - AI 图像生成器")
self.setWindowTitle("登录 - 珠宝壹佰图像生成器")
self.setFixedSize(400, 400)
# Main layout
......@@ -447,6 +835,9 @@ class ImageGeneratorWindow(QMainWindow):
def __init__(self):
super().__init__()
self.logger = logging.getLogger(__name__)
self.logger.info("应用程序启动")
self.api_key = ""
self.uploaded_images = [] # List of file paths
self.generated_image_data = None
......@@ -458,11 +849,20 @@ class ImageGeneratorWindow(QMainWindow):
self.load_config()
self.set_window_icon()
# Initialize history manager
self.history_manager = HistoryManager()
self.setup_ui()
self.apply_styles()
self.logger.info("应用程序初始化完成")
def set_window_icon(self):
"""Set window icon based on platform"""
try:
icon_path = None
if getattr(sys, 'frozen', False):
# Running as compiled executable
if platform.system() == 'Windows':
......@@ -470,18 +870,24 @@ class ImageGeneratorWindow(QMainWindow):
elif platform.system() == 'Darwin':
icon_path = os.path.join(sys._MEIPASS, 'zb100_mac.icns')
else:
icon_path = None
else:
# Running as script
if platform.system() == 'Windows':
icon_path = 'zb100_windows.ico'
elif platform.system() == 'Darwin':
icon_path = 'zb100_mac.icns'
else:
icon_path = None
if icon_path and os.path.exists(icon_path):
self.setWindowIcon(QIcon(icon_path))
app_icon = QIcon(icon_path)
if not app_icon.isNull():
self.setWindowIcon(app_icon)
self.logger.debug(f"主窗口图标设置成功: {icon_path}")
else:
self.logger.warning(f"图标文件无效: {icon_path}")
else:
self.logger.debug(f"图标文件不存在,跳过设置: {icon_path}")
except Exception as e:
self.logger.error(f"设置窗口图标失败: {e}")
def get_config_dir(self):
"""Get the appropriate directory for config files based on platform and mode"""
......@@ -506,7 +912,10 @@ class ImageGeneratorWindow(QMainWindow):
def load_config(self):
"""Load API key, saved prompts, and db config from config file"""
config_path = self.get_config_path()
self.logger.debug(f"加载配置文件: {config_path}")
# Try to load from user directory first
config_loaded = False
if config_path.exists():
try:
with open(config_path, 'r', encoding='utf-8') as f:
......@@ -516,9 +925,80 @@ class ImageGeneratorWindow(QMainWindow):
self.db_config = config.get("db_config")
self.last_user = config.get("last_user", "")
self.saved_password_hash = config.get("saved_password_hash", "")
# Load history configuration
history_config = config.get("history_config", {})
if hasattr(self, 'history_manager'):
self.history_manager.max_history_count = history_config.get("max_history_count", 100)
self.logger.info("配置文件加载成功")
config_loaded = True
except Exception as e:
self.logger.error(f"配置文件加载失败: {e}")
print(f"Failed to load config from {config_path}: {e}")
# If user config doesn't exist or failed to load, try bundled config
if not config_loaded and getattr(sys, 'frozen', False):
bundled_config_paths = [
Path(sys.executable).parent / 'config.json', # Same directory as exe
Path(sys._MEIPASS) / 'config.json', # PyInstaller temp directory
]
for bundled_path in bundled_config_paths:
if bundled_path.exists():
try:
with open(bundled_path, 'r', encoding='utf-8') as f:
config = json.load(f)
self.api_key = config.get("api_key", "")
self.saved_prompts = config.get("saved_prompts", [])
self.db_config = config.get("db_config")
self.last_user = config.get("last_user", "")
self.saved_password_hash = config.get("saved_password_hash", "")
# Load history configuration
history_config = config.get("history_config", {})
if hasattr(self, 'history_manager'):
self.history_manager.max_history_count = history_config.get("max_history_count", 100)
self.logger.info(f"从打包配置文件加载成功: {bundled_path}")
config_loaded = True
break
except Exception as e:
self.logger.error(f"打包配置文件加载失败 {bundled_path}: {e}")
continue
# If still no config loaded, try current directory
if not config_loaded:
current_config = Path('.') / 'config.json'
if current_config.exists():
try:
with open(current_config, 'r', encoding='utf-8') as f:
config = json.load(f)
self.api_key = config.get("api_key", "")
self.saved_prompts = config.get("saved_prompts", [])
self.db_config = config.get("db_config")
self.last_user = config.get("last_user", "")
self.saved_password_hash = config.get("saved_password_hash", "")
# Load history configuration
history_config = config.get("history_config", {})
if hasattr(self, 'history_manager'):
self.history_manager.max_history_count = history_config.get("max_history_count", 100)
self.logger.info(f"从当前目录配置文件加载成功: {current_config}")
config_loaded = True
except Exception as e:
self.logger.error(f"当前目录配置文件加载失败: {e}")
if not config_loaded:
self.logger.warning("未找到任何有效的配置文件")
print("警告:未找到配置文件,某些功能可能无法正常工作")
# 即使没有db_config也继续运行,让用户在UI中配置
if not self.db_config:
self.logger.info("未找到数据库配置,将使用UI配置模式")
self.db_config = None
if not self.api_key and getattr(sys, 'frozen', False):
try:
bundle_dir = Path(sys._MEIPASS)
......@@ -560,6 +1040,12 @@ class ImageGeneratorWindow(QMainWindow):
else:
config["saved_password_hash"] = ""
# Save history configuration
if hasattr(self, 'history_manager'):
config["history_config"] = {
"max_history_count": self.history_manager.max_history_count
}
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, 'w', encoding='utf-8') as f:
......@@ -569,7 +1055,7 @@ class ImageGeneratorWindow(QMainWindow):
def setup_ui(self):
"""Setup the user interface"""
self.setWindowTitle("AI 图像生成器")
self.setWindowTitle("珠宝壹佰图像生成器")
self.setGeometry(100, 100, 1200, 850)
self.setMinimumSize(1000, 700)
......@@ -578,7 +1064,30 @@ class ImageGeneratorWindow(QMainWindow):
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout()
main_layout.setContentsMargins(20, 20, 20, 20)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
# Create tab widget
self.tab_widget = QTabWidget()
# Create generation tab
generation_tab = self.setup_generation_tab()
self.tab_widget.addTab(generation_tab, "图片生成")
# Create history tab
history_tab = self.setup_history_tab()
self.tab_widget.addTab(history_tab, "历史记录")
main_layout.addWidget(self.tab_widget)
central_widget.setLayout(main_layout)
self.check_favorite_status()
def setup_generation_tab(self):
"""Setup the image generation tab"""
tab_widget = QWidget()
main_layout = QVBoxLayout()
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(15)
# Reference images section
......@@ -666,6 +1175,8 @@ class ImageGeneratorWindow(QMainWindow):
self.image_size.setCurrentIndex(1) # Default to 2K
settings_layout.addWidget(self.image_size)
settings_layout.addSpacing(10)
settings_layout.addStretch()
settings_group.setLayout(settings_layout)
content_row.addWidget(settings_group, 1)
......@@ -703,9 +1214,160 @@ class ImageGeneratorWindow(QMainWindow):
preview_group.setLayout(preview_layout)
main_layout.addWidget(preview_group, 1)
central_widget.setLayout(main_layout)
tab_widget.setLayout(main_layout)
return tab_widget
self.check_favorite_status()
def setup_history_tab(self):
"""Setup the history tab"""
tab_widget = QWidget()
main_layout = QVBoxLayout()
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
# History toolbar
toolbar_layout = QHBoxLayout()
refresh_btn = QPushButton("🔄 刷新")
refresh_btn.clicked.connect(self.refresh_history)
toolbar_layout.addWidget(refresh_btn)
clear_btn = QPushButton("🗑️ 清空历史")
clear_btn.clicked.connect(self.clear_history)
toolbar_layout.addWidget(clear_btn)
toolbar_layout.addStretch()
self.history_count_label = QLabel("共 0 条历史记录")
toolbar_layout.addWidget(self.history_count_label)
main_layout.addLayout(toolbar_layout)
# Create splitter for list and details
from PySide6.QtWidgets import QSplitter
splitter = QSplitter(Qt.Vertical)
# History list (upper part)
self.history_list = QListWidget()
self.history_list.setIconSize(QSize(120, 120))
self.history_list.setResizeMode(QListWidget.Adjust)
self.history_list.setViewMode(QListWidget.IconMode)
self.history_list.setSpacing(10)
self.history_list.setMinimumHeight(200) # Give more space for history list
self.history_list.itemClicked.connect(self.load_history_item)
self.history_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.history_list.customContextMenuRequested.connect(self.show_history_context_menu)
splitter.addWidget(self.history_list)
# Details panel (lower part)
self.details_panel = self.create_details_panel()
splitter.addWidget(self.details_panel)
# Set splitter proportions (40% for list, 60% for details)
splitter.setSizes([300, 450])
splitter.setChildrenCollapsible(False) # Prevent panels from being collapsed completely
main_layout.addWidget(splitter)
tab_widget.setLayout(main_layout)
# Load initial history
self.refresh_history()
return tab_widget
def create_details_panel(self):
"""Create the details panel for displaying selected history item"""
panel = QWidget()
layout = QVBoxLayout()
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# Prompt section
prompt_group = QGroupBox("提示词")
prompt_layout = QVBoxLayout()
# Prompt text area with copy button
prompt_header = QHBoxLayout()
prompt_header.addWidget(QLabel("完整提示词:"))
self.copy_prompt_btn = QPushButton("📋 复制")
self.copy_prompt_btn.clicked.connect(self.copy_prompt_text)
self.copy_prompt_btn.setEnabled(False)
prompt_header.addWidget(self.copy_prompt_btn)
prompt_header.addStretch()
prompt_layout.addLayout(prompt_header)
self.prompt_display = QLabel("请选择一个历史记录查看详情")
self.prompt_display.setWordWrap(True)
self.prompt_display.setStyleSheet("QLabel { padding: 8px; background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 4px; }")
prompt_layout.addWidget(self.prompt_display)
prompt_group.setLayout(prompt_layout)
layout.addWidget(prompt_group)
# Parameters section - compressed to one line
params_group = QGroupBox("生成参数")
params_layout = QHBoxLayout()
params_layout.addWidget(QLabel("生成时间:"))
self.time_label = QLabel("-")
params_layout.addWidget(self.time_label)
params_layout.addWidget(QLabel(" | 宽高比:"))
self.aspect_ratio_label = QLabel("-")
params_layout.addWidget(self.aspect_ratio_label)
params_layout.addWidget(QLabel(" | 图片尺寸:"))
self.image_size_label = QLabel("-")
params_layout.addWidget(self.image_size_label)
params_layout.addStretch()
params_group.setLayout(params_layout)
layout.addWidget(params_group)
# Images section - left (reference) and right (generated) layout
images_group = QGroupBox("图片预览")
images_layout = QHBoxLayout()
# Left side - Reference images
ref_group = QGroupBox("参考图片")
ref_layout = QVBoxLayout()
self.ref_images_scroll = QScrollArea()
self.ref_images_scroll.setWidgetResizable(True)
self.ref_images_scroll.setMinimumHeight(200)
self.ref_images_widget = QWidget()
self.ref_images_layout = QVBoxLayout() # Changed to vertical for better layout
self.ref_images_layout.setAlignment(Qt.AlignCenter)
self.ref_images_widget.setLayout(self.ref_images_layout)
self.ref_images_scroll.setWidget(self.ref_images_widget)
ref_layout.addWidget(self.ref_images_scroll)
ref_group.setLayout(ref_layout)
images_layout.addWidget(ref_group, 1) # 1:1 stretch
# Right side - Generated image (larger)
gen_group = QGroupBox("生成图片")
gen_layout = QVBoxLayout()
gen_layout.setAlignment(Qt.AlignCenter)
self.generated_image_label = QLabel("请选择一个历史记录查看生成图片")
self.generated_image_label.setAlignment(Qt.AlignCenter)
self.generated_image_label.setMinimumSize(200, 200) # Larger size for generated image
self.generated_image_label.setMaximumSize(300, 300)
self.generated_image_label.setStyleSheet("QLabel { background-color: #f5f5f5; border: 1px solid #ddd; color: #999; }")
self.generated_image_label.mouseDoubleClickEvent = self.open_generated_image_from_history
gen_layout.addWidget(self.generated_image_label)
gen_group.setLayout(gen_layout)
images_layout.addWidget(gen_group, 1) # 1:1 stretch
images_group.setLayout(images_layout)
layout.addWidget(images_group)
layout.addStretch()
panel.setLayout(layout)
return panel
def apply_styles(self):
"""Apply QSS stylesheet"""
......@@ -921,7 +1583,8 @@ class ImageGeneratorWindow(QMainWindow):
self.prompt_text.toPlainText().strip(),
self.uploaded_images,
self.aspect_ratio.currentText(),
self.image_size.currentText()
self.image_size.currentText(),
"gemini-3-pro-image-preview" # 锁死模型
)
self.worker.finished.connect(self.on_image_generated)
self.worker.error.connect(self.on_generation_error)
......@@ -934,7 +1597,7 @@ class ImageGeneratorWindow(QMainWindow):
self.worker.start()
def on_image_generated(self, image_bytes):
def on_image_generated(self, image_bytes, prompt, reference_images, aspect_ratio, image_size, model):
"""Handle successful image generation"""
self.generated_image_bytes = image_bytes
self.display_image()
......@@ -943,6 +1606,23 @@ class ImageGeneratorWindow(QMainWindow):
self.status_label.setText("● 图片生成成功")
self.status_label.setStyleSheet("QLabel { color: #34C759; }")
# 自动保存到历史记录
try:
self.history_manager.save_generation(
image_bytes=image_bytes,
prompt=prompt,
reference_images=reference_images,
aspect_ratio=aspect_ratio,
image_size=image_size,
model=model
)
self.status_label.setText("● 图片生成成功,已保存到历史记录")
# 刷新历史记录列表
self.refresh_history()
except Exception as e:
print(f"保存到历史记录失败: {e}")
# 不影响主要功能,静默处理错误
def on_generation_error(self, error_msg):
"""Handle image generation error"""
QMessageBox.critical(self, "错误", f"生成失败: {error_msg}")
......@@ -1028,32 +1708,358 @@ class ImageGeneratorWindow(QMainWindow):
QMessageBox.critical(self, "错误", f"保存失败: {str(e)}")
def refresh_history(self):
"""Refresh the history list"""
self.history_list.clear()
history_items = self.history_manager.load_history_index()
for item in history_items:
# Create list item with icon
list_item = QListWidgetItem()
# Set item data
list_item.setData(Qt.UserRole, item.timestamp)
# Create enhanced tooltip with details
tooltip = f"时间: {item.created_at.strftime('%Y-%m-%d %H:%M:%S')}\n"
tooltip += f"提示词: {item.prompt}\n"
tooltip += f"宽高比: {item.aspect_ratio}\n"
tooltip += f"尺寸: {item.image_size}"
list_item.setToolTip(tooltip)
# Try to load thumbnail
if item.generated_image_path.exists():
try:
pixmap = QPixmap(str(item.generated_image_path))
if not pixmap.isNull():
# Scale to thumbnail size
scaled_pixmap = pixmap.scaled(120, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation)
list_item.setIcon(QIcon(scaled_pixmap))
else:
# Create placeholder icon
list_item.setIcon(self.create_placeholder_icon("图片\n加载失败"))
except Exception as e:
print(f"Failed to load thumbnail for {item.timestamp}: {e}")
list_item.setIcon(self.create_placeholder_icon("图片\n错误"))
else:
list_item.setIcon(self.create_placeholder_icon("图片\n不存在"))
# Add text info below the icon
# Get prompt preview (first 20 characters)
prompt_preview = item.prompt[:20] + "..." if len(item.prompt) > 20 else item.prompt
list_item.setText(f"{item.timestamp}\n{prompt_preview}")
# Add to list
self.history_list.addItem(list_item)
# Update count label
self.history_count_label.setText(f"共 {len(history_items)} 条历史记录")
# Clear details panel if no items
if not history_items:
self.clear_details_panel()
def create_placeholder_icon(self, text):
"""Create a placeholder icon with text"""
# Create a 120x120 pixmap
pixmap = QPixmap(120, 120)
pixmap.fill(Qt.lightGray)
# Create a painter to draw text
from PySide6.QtGui import QPainter, QFont
painter = QPainter(pixmap)
painter.setPen(Qt.black)
painter.setFont(QFont("Arial", 10))
# Draw text in center
rect = pixmap.rect()
painter.drawText(rect, Qt.AlignCenter, text)
painter.end()
return QIcon(pixmap)
def load_history_item(self, item):
"""Display history item details when selected"""
timestamp = item.data(Qt.UserRole)
if not timestamp:
return
history_item = self.history_manager.get_history_item(timestamp)
if not history_item:
return
# Display details in the details panel
self.display_history_details(history_item)
def show_history_context_menu(self, position):
"""Show context menu for history items"""
item = self.history_list.itemAt(position)
if not item:
return
timestamp = item.data(Qt.UserRole)
if not timestamp:
return
# Create context menu
menu = QMenu(self)
# Delete action
delete_action = QAction("删除此项", self)
delete_action.triggered.connect(lambda: self.delete_history_item(timestamp))
menu.addAction(delete_action)
# Open in file manager action
open_action = QAction("在文件管理器中显示", self)
open_action.triggered.connect(lambda: self.open_in_file_manager(timestamp))
menu.addAction(open_action)
# Show menu
menu.exec_(self.history_list.mapToGlobal(position))
def delete_history_item(self, timestamp):
"""Delete a history item"""
reply = QMessageBox.question(
self, "确认删除", "确定要删除这条历史记录吗?\n这将删除相关的所有文件。",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
)
if reply == QMessageBox.Yes:
success = self.history_manager.delete_history_item(timestamp)
if success:
self.refresh_history()
self.status_label.setText("● 历史记录已删除")
self.status_label.setStyleSheet("QLabel { color: #34C759; }")
else:
QMessageBox.critical(self, "错误", "删除历史记录失败")
def open_in_file_manager(self, timestamp):
"""Open the history item directory in file manager"""
history_item = self.history_manager.get_history_item(timestamp)
if not history_item:
return
record_dir = history_item.generated_image_path.parent
if record_dir.exists():
import subprocess
import platform
try:
if platform.system() == "Windows":
subprocess.run(["explorer", str(record_dir)])
elif platform.system() == "Darwin": # macOS
subprocess.run(["open", str(record_dir)])
else: # Linux
subprocess.run(["xdg-open", str(record_dir)])
except Exception as e:
QMessageBox.critical(self, "错误", f"无法打开文件管理器: {str(e)}")
def clear_history(self):
"""Clear all history"""
reply = QMessageBox.question(
self, "确认清空", "确定要清空所有历史记录吗?\n这将删除所有历史图片文件,且无法恢复。",
QMessageBox.Yes | QMessageBox.No, QMessageBox.No
)
if reply == QMessageBox.Yes:
try:
# Remove entire history directory
import shutil
if self.history_manager.base_path.exists():
shutil.rmtree(self.history_manager.base_path)
# Recreate empty directory
self.history_manager.base_path.mkdir(parents=True, exist_ok=True)
self.refresh_history()
self.status_label.setText("● 历史记录已清空")
self.status_label.setStyleSheet("QLabel { color: #34C759; }")
except Exception as e:
QMessageBox.critical(self, "错误", f"清空历史记录失败: {str(e)}")
def display_history_details(self, history_item):
"""Display history item details in the details panel"""
try:
# Update prompt display
self.prompt_display.setText(history_item.prompt)
self.copy_prompt_btn.setEnabled(True)
self.current_history_prompt = history_item.prompt # Store for copying
# Update parameters
self.time_label.setText(history_item.created_at.strftime('%Y-%m-%d %H:%M:%S'))
self.aspect_ratio_label.setText(history_item.aspect_ratio)
self.image_size_label.setText(history_item.image_size)
# Display reference images
self.display_reference_images(history_item.reference_image_paths)
# Display generated image
self.display_generated_image(history_item.generated_image_path)
except Exception as e:
print(f"Error displaying history details: {e}")
def display_reference_images(self, reference_paths):
"""Display reference images in the details panel with adaptive sizing"""
# Clear existing images
for i in reversed(range(self.ref_images_layout.count())):
child = self.ref_images_layout.itemAt(i).widget()
if child:
child.setParent(None)
if not reference_paths:
no_images_label = QLabel("无参考图片")
no_images_label.setStyleSheet("color: #999;")
self.ref_images_layout.addWidget(no_images_label)
return
# Calculate adaptive size based on number of images
num_images = len(reference_paths)
if num_images == 1:
size = 180 # Single image gets more space
elif num_images == 2:
size = 140 # Two images get medium space
else:
size = 100 # Multiple images get smaller space
for ref_path in reference_paths:
if ref_path.exists():
try:
pixmap = QPixmap(str(ref_path))
if not pixmap.isNull():
# Create adaptive thumbnail
thumbnail = pixmap.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
image_label = QLabel()
image_label.setPixmap(thumbnail)
image_label.setFixedSize(size, size)
image_label.setAlignment(Qt.AlignCenter)
image_label.setStyleSheet("border: 1px solid #ddd; margin: 5px;")
image_label.mouseDoubleClickEvent = lambda e, path=ref_path: self.open_reference_image(path)
self.ref_images_layout.addWidget(image_label)
except Exception as e:
print(f"Failed to load reference image {ref_path}: {e}")
def display_generated_image(self, image_path):
"""Display the generated image in the details panel"""
if image_path.exists():
try:
pixmap = QPixmap(str(image_path))
if not pixmap.isNull():
# Scale to larger size while maintaining aspect ratio
available_size = self.generated_image_label.size()
scaled_pixmap = pixmap.scaled(
available_size.width(),
available_size.height(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation
)
self.generated_image_label.setPixmap(scaled_pixmap)
self.generated_image_label.setStyleSheet("QLabel { border: 1px solid #ddd; background-color: white; }")
self.current_generated_image_path = image_path
else:
self.generated_image_label.setText("图片加载失败")
self.generated_image_label.setStyleSheet("QLabel { background-color: #f5f5f5; border: 1px solid #ddd; color: #999; }")
except Exception as e:
print(f"Failed to load generated image {image_path}: {e}")
self.generated_image_label.setText("图片加载失败")
self.generated_image_label.setStyleSheet("QLabel { background-color: #f5f5f5; border: 1px solid #ddd; color: #999; }")
else:
self.generated_image_label.setText("图片文件不存在")
self.generated_image_label.setStyleSheet("QLabel { background-color: #f5f5f5; border: 1px solid #ddd; color: #999; }")
def clear_details_panel(self):
"""Clear the details panel"""
self.prompt_display.setText("请选择一个历史记录查看详情")
self.copy_prompt_btn.setEnabled(False)
self.time_label.setText("-")
self.aspect_ratio_label.setText("-")
self.image_size_label.setText("-")
# Clear reference images
for i in reversed(range(self.ref_images_layout.count())):
child = self.ref_images_layout.itemAt(i).widget()
if child:
child.setParent(None)
no_images_label = QLabel("无参考图片")
no_images_label.setStyleSheet("color: #999;")
self.ref_images_layout.addWidget(no_images_label)
self.generated_image_label.setText("请选择一个历史记录查看生成图片")
self.generated_image_label.setPixmap(QPixmap())
self.generated_image_label.setStyleSheet("QLabel { background-color: #f5f5f5; border: 1px solid #ddd; color: #999; }")
def copy_prompt_text(self):
"""Copy the prompt text to clipboard"""
if hasattr(self, 'current_history_prompt'):
from PySide6.QtWidgets import QApplication
clipboard = QApplication.clipboard()
clipboard.setText(self.current_history_prompt)
# Show success message briefly
original_text = self.copy_prompt_btn.text()
self.copy_prompt_btn.setText("✅ 已复制")
self.copy_prompt_btn.setStyleSheet("QPushButton { background-color: #34C759; color: white; }")
# Reset after 2 seconds
QTimer.singleShot(2000, lambda: self.reset_copy_button())
def reset_copy_button(self):
"""Reset the copy button appearance"""
self.copy_prompt_btn.setText("📋 复制")
self.copy_prompt_btn.setStyleSheet("")
def open_generated_image_from_history(self, event):
"""Open the generated image from history in system viewer"""
if hasattr(self, 'current_generated_image_path') and self.current_generated_image_path:
self.open_image_in_system_viewer(self.current_generated_image_path)
def open_reference_image(self, image_path):
"""Open a reference image in system viewer"""
self.open_image_in_system_viewer(image_path)
def open_image_in_system_viewer(self, image_path):
"""Open an image in the system default viewer"""
try:
QDesktopServices.openUrl(QUrl.fromLocalFile(str(image_path)))
except Exception as e:
QMessageBox.critical(self, "错误", f"无法打开图片: {str(e)}")
class ImageGenerationWorker(QThread):
"""Worker thread for image generation"""
finished = Signal(bytes)
finished = Signal(bytes, str, list, str, str, str) # image_bytes, prompt, reference_images, aspect_ratio, image_size, model
error = Signal(str)
progress = Signal(str)
def __init__(self, api_key, prompt, images, aspect_ratio, image_size):
def __init__(self, api_key, prompt, images, aspect_ratio, image_size, model="gemini-3-pro-image-preview"):
super().__init__()
self.logger = logging.getLogger(__name__)
self.api_key = api_key
self.prompt = prompt
self.images = images
self.aspect_ratio = aspect_ratio
self.image_size = image_size
self.model = model
self.logger.info(f"图片生成任务初始化 - 模型: {model}, 尺寸: {image_size}, 宽高比: {aspect_ratio}")
def run(self):
"""Execute image generation in background thread"""
try:
self.logger.info("开始图片生成任务")
if not self.prompt:
self.logger.error("图片描述为空")
self.error.emit("请输入图片描述!")
return
if not self.api_key:
self.logger.error("API密钥为空")
self.error.emit("未找到API密钥,请在config.json中配置!")
return
self.progress.emit("正在连接 Gemini API...")
self.logger.debug("正在连接 Gemini API")
client = genai.Client(api_key=self.api_key)
......@@ -1102,17 +2108,36 @@ class ImageGenerationWorker(QThread):
else:
image_bytes = base64.b64decode(part.inline_data.data)
self.finished.emit(image_bytes)
# Convert reference images to bytes for history saving
reference_images_bytes = []
for img_path in self.images:
if img_path and os.path.exists(img_path):
with open(img_path, 'rb') as f:
reference_images_bytes.append(f.read())
else:
reference_images_bytes.append(b'')
self.logger.info(f"图片生成成功 - 模型: {self.model}, 尺寸: {self.image_size}")
self.finished.emit(image_bytes, self.prompt, reference_images_bytes,
self.aspect_ratio, self.image_size, self.model)
return
self.error.emit("响应中没有图片数据")
error_msg = "响应中没有图片数据"
self.logger.error(error_msg)
self.error.emit(error_msg)
except Exception as e:
self.error.emit(str(e))
error_msg = f"图片生成异常: {e}"
self.logger.error(error_msg)
self.error.emit(error_msg)
def main():
"""Main application entry point"""
# 初始化日志系统
if not init_logging():
print("警告:日志系统初始化失败,将继续运行但不记录日志")
# Load config for database info
config_dir = Path('.')
if getattr(sys, 'frozen', False):
......@@ -1127,10 +2152,12 @@ def main():
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / 'config.json'
# If config doesn't exist in user directory, copy from bundled resources
# Always try to ensure user config exists - copy from bundled if needed
if not config_path.exists():
if getattr(sys, 'frozen', False):
# Running as bundled app - look in Resources folder (macOS .app bundle)
# Running as bundled app - look for bundled config
bundled_config = None
if platform.system() == 'Darwin':
# macOS: Contents/Resources/config.json
bundled_config = Path(sys.executable).parent.parent / 'Resources' / 'config.json'
......@@ -1138,14 +2165,31 @@ def main():
# Windows/Linux: same directory as executable
bundled_config = Path(sys.executable).parent / 'config.json'
if bundled_config.exists():
# Also try _MEIPASS directory (PyInstaller temp directory)
if not bundled_config.exists():
meipass_bundled = Path(sys._MEIPASS) / 'config.json'
if meipass_bundled.exists():
bundled_config = meipass_bundled
if bundled_config and bundled_config.exists():
try:
# Create config directory if it doesn't exist
config_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(bundled_config, config_path)
print(f"✓ Copied config from {bundled_config} to {config_path}")
except Exception as e:
print(f"✗ Failed to copy bundled config: {e}")
# If copy fails, try to use bundled config directly
config_path = bundled_config
else:
print(f"✗ Bundled config not found at {bundled_config}")
# Try to use current directory config as fallback
current_dir_config = Path('.') / 'config.json'
if current_dir_config.exists():
config_path = current_dir_config
print(f"✓ Using current directory config: {config_path}")
else:
print(f"✗ No config file found at all")
db_config = None
last_user = ""
......@@ -1186,12 +2230,13 @@ def main():
app_icon = QIcon(icon_path)
app.setWindowIcon(app_icon)
# Check database config
# Check database config - if missing, start app without database authentication
if not db_config:
QMessageBox.critical(None, "配置错误",
f"未找到数据库配置\n配置文件: {config_path}\n\n"
"请确保 config.json 包含 db_config 字段")
return
print("警告:未找到数据库配置,将跳过数据库认证")
# Create main window directly without login
main_window = ImageGeneratorWindow()
main_window.show()
sys.exit(app.exec())
# Show login dialog
login_dialog = LoginDialog(db_config, last_user, saved_password_hash)
......