1bbb3c47 by 柴进

:sparkles: 任务队列可取消运行中任务 + 回显修复 + Flash 独占宽高比支持

本次改动分三块,合并一个提交:

1. 运行中任务取消 (方案 A 软取消)
   - cancel_task 扩展支持 RUNNING: 标记 CANCELLED,脱钩 _current_worker,立刻 _process_next()
   - _on_task_completed/_on_task_failed 开头加 status == CANCELLED 自检,丢弃废 worker 回调
   - 右键菜单对 PENDING/RUNNING 都显示"取消任务"
   - _update_summary 新增"已取消"状态;_cleanup_old_tasks 纳入 CANCELLED 清理

2. 任务栏点击回显修复
   - 根因: TaskQueueWidget 创建时没传 parent,self.parent_window 永远 None,回显全部静默失败
   - 根因: 两套重名方法互相覆盖,生效版用了 prompt_input / add_reference_image 等不存在的属性
   - 修复: 删除重复定义;回填改用主窗口真实属性 (prompt_text / uploaded_images + update_image_preview / aspect_ratio / image_size / display_image)
   - 款式设计 tab 也完整支持 prompt / 宽高比 / 尺寸 / 结果图回显

3. Flash 独占宽高比 + 模式兼容校验
   - 新增 1:4 / 4:1 / 1:8 / 8:1 四个极速模式独占比例
   - 款式设计 tab 宽高比补齐到和图片生成 tab 一致
   - FLASH_ONLY_ASPECT_RATIOS 常量作为单一真相源
   - 双向实时校验:
     * 选 Flash-only 比例 + 慢速模式 → 问是否切到极速,拒绝则回滚比例
     * 极速 + Flash-only 比例 → 切慢速 → 问是否坚持切换,坚持则比例回落 1:1
   - 提交入口保留校验作为 defense in depth
1 parent 8f841eac
......@@ -41,6 +41,19 @@ from dataclasses import dataclass, asdict
from typing import List, Optional, Dict, Any
# 生成模式 -> Gemini 模型 ID 映射(单一真相源,消除原先两处 get_selected_model 复制粘贴)
# 极速模式:Nano Banana 2 (Gemini 3.1 Flash Image), 指令遵循强于 2.5-flash-image
# 慢速模式:Nano Banana Pro (Gemini 3 Pro Image Preview)
MODEL_BY_MODE = {
"极速模式": "gemini-3.1-flash-image-preview",
"慢速模式": "gemini-3-pro-image-preview",
}
MODEL_PRO = MODEL_BY_MODE["慢速模式"] # 用于 Worker 中判断是否支持 image_size 参数
# Nano Banana 2 (Flash) 独占的宽高比 —— Pro 不支持,选中这些时需提示切换到极速模式
FLASH_ONLY_ASPECT_RATIOS = {"1:4", "4:1", "1:8", "8:1"}
def _flush_logs() -> None:
"""强制刷盘所有日志 handlers.
......@@ -109,18 +122,35 @@ def _enable_crash_diagnostics():
crash_log = _get_crash_log_path()
# 1. faulthandler: segfault 时自动输出 Python 调用栈到文件
# PyInstaller windowed 模式 (runw.exe / console=False) 下 sys.stderr / sys.stdout
# 都会是 None,直接传给 faulthandler 会 RuntimeError: sys.stderr is None。
try:
crash_fh = open(crash_log, "a", encoding="utf-8")
crash_fh.write(f"\n{'='*60}\n")
crash_fh.write(f"[STARTUP] {datetime.now().isoformat()} - faulthandler 已启用\n")
crash_fh.flush()
faulthandler.enable(file=crash_fh, all_threads=True)
# 同时输出到 stderr
# 同时输出到 stderr (仅在 stderr 真实存在时;windowed build 下跳过)
if sys.stderr is not None:
try:
faulthandler.enable(file=sys.stderr, all_threads=True)
except (RuntimeError, ValueError):
pass
try:
print(f"崩溃诊断日志路径: {crash_log}")
except Exception:
pass
except Exception as e:
try:
print(f"faulthandler 启用失败: {e}")
faulthandler.enable() # 至少启用 stderr 输出
except Exception:
pass
# 兜底:仅在 stderr 可用时才默认 enable(它内部默认写 stderr)
if sys.stderr is not None:
try:
faulthandler.enable()
except (RuntimeError, ValueError):
pass
# 2. 全局 Python 异常钩子
def _global_excepthook(exc_type, exc_value, exc_tb):
......@@ -1217,34 +1247,31 @@ class LoginDialog(QDialog):
return "Unknown"
def log_user_login(self, username):
"""静默记录用户登录日志,支持双IP"""
"""记录用户登录日志 -> 审计队列(本地落盘 + 后台异步上传)。"""
try:
from audit_logger import get_audit_logger
auditor = get_audit_logger()
if auditor is None:
# 理论上 preflight 通过后单例必然已 init;防御性日志
logging.getLogger(__name__).error(
"audit logger 未初始化,登录日志无法记录 (user=%s)", username
)
return
local_ip = self.get_local_ip()
public_ip = self.get_public_ip()
device_name = self.get_device_name()
conn = pymysql.connect(
host=self.db_config['host'],
port=self.db_config.get('port', 3306),
user=self.db_config['user'],
password=self.db_config['password'],
database=self.db_config['database'],
connect_timeout=5
auditor.log_login(
user_name=username,
local_ip=local_ip,
public_ip=public_ip,
device_name=device_name,
)
except Exception as e:
# 不再裸吞:至少 error 日志里有痕迹
logging.getLogger(__name__).error(
"登录日志入队失败 (user=%s): %s", username, e
)
try:
with conn.cursor() as cursor:
sql = """INSERT INTO nano_banana_user_log
(user_name, local_ip, public_ip, device_name, login_time)
VALUES (%s, %s, %s, %s, %s)"""
cursor.execute(sql, (username, local_ip, public_ip, device_name, datetime.now()))
conn.commit()
finally:
conn.close()
except Exception:
# 静默处理,不影响登录流程
pass
def show_error(self, message):
"""显示错误弹窗和标签"""
......@@ -1776,7 +1803,7 @@ class ImageGeneratorWindow(QMainWindow):
# Right side: Task queue sidebar
self.logger.info("[INIT-UI] 创建任务队列侧边栏...")
from task_queue import TaskQueueWidget
self.task_queue_widget = TaskQueueWidget(self.task_manager)
self.task_queue_widget = TaskQueueWidget(self.task_manager, parent=self)
main_layout.addWidget(self.task_queue_widget, 3) # 30% width
self.logger.info("[INIT-UI] 任务队列侧边栏创建完成")
......@@ -1906,7 +1933,14 @@ class ImageGeneratorWindow(QMainWindow):
# 宽高比
settings_layout.addWidget(QLabel("宽高比"))
self.aspect_ratio = QComboBox()
self.aspect_ratio.addItems(["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"])
self.aspect_ratio.addItems([
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9",
# 以下为 Nano Banana 2 (极速模式) 独占,Pro 不支持
"1:4", "4:1", "1:8", "8:1",
])
# 记录上一次值用于用户拒绝切换模式时回滚,避免留在一个无法提交的状态
self._prev_aspect_ratio = self.aspect_ratio.currentText()
self.aspect_ratio.currentTextChanged.connect(self._on_aspect_ratio_changed)
settings_layout.addWidget(self.aspect_ratio)
settings_layout.addSpacing(10)
......@@ -2649,36 +2683,77 @@ class ImageGeneratorWindow(QMainWindow):
def get_selected_model(self):
"""根据生成模式返回对应的模型名称"""
if self.generation_mode.currentText() == "慢速模式":
return "gemini-3-pro-image-preview"
else:
return "gemini-2.5-flash-image"
return MODEL_BY_MODE.get(self.generation_mode.currentText(), MODEL_BY_MODE["极速模式"])
def on_generation_mode_changed(self, index):
"""生成模式切换时的处理"""
if self.generation_mode.currentText() == "极速模式":
# 极速模式强制使用1K
self.image_size.setCurrentIndex(0) # 1K
"""
模式切换校验:
若当前宽高比是 Flash-only 且用户切到慢速模式 → 提示不支持,询问是否坚持切换。
坚持 → 宽高比自动重置为 1:1;拒绝 → 回滚模式回到极速模式。
"""
new_mode = self.generation_mode.currentText()
current_aspect = self.aspect_ratio.currentText()
if new_mode == "慢速模式" and current_aspect in FLASH_ONLY_ASPECT_RATIOS:
reply = QMessageBox.question(
self,
"模式与宽高比不匹配",
f"当前宽高比 {current_aspect} 仅【极速模式】支持,\n"
f"【慢速模式】不支持该比例。\n\n"
f"是否仍要切换到慢速模式?\n(切换后宽高比将自动改为 1:1)",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
# 把宽高比回落到 1:1,避免留在无法提交的状态
self.aspect_ratio.blockSignals(True)
idx = self.aspect_ratio.findText("1:1")
if idx >= 0:
self.aspect_ratio.setCurrentIndex(idx)
self._prev_aspect_ratio = "1:1"
self.aspect_ratio.blockSignals(False)
else:
# 回滚模式回到极速,blockSignals 避免回滚再触发本函数
self.generation_mode.blockSignals(True)
fast_idx = self.generation_mode.findText("极速模式")
if fast_idx >= 0:
self.generation_mode.setCurrentIndex(fast_idx)
self.generation_mode.blockSignals(False)
def on_image_size_changed(self, index):
"""图片尺寸切换时的处理"""
selected_size = self.image_size.currentText()
current_mode = self.generation_mode.currentText()
"""图片尺寸切换时的处理。
Nano Banana 2 与 Nano Banana Pro 两个模型均支持 1K/2K/4K 全分辨率,
此槽保留作为信号连接锚点,不再做任何限制。"""
pass
# 如果选择2K或4K,且当前是极速模式,提示切换
if selected_size in ["2K", "4K"] and current_mode == "极速模式":
def _on_aspect_ratio_changed(self, new_ratio: str):
"""
用户选择宽高比即时校验:
如果选了 Flash-only 比例且当前是慢速模式,弹窗提示是否切到极速模式。
用户拒绝则回滚到上一次选择,避免留在一个无法提交的状态。
"""
if new_ratio in FLASH_ONLY_ASPECT_RATIOS and self.generation_mode.currentText() != "极速模式":
reply = QMessageBox.question(
self,
"模式切换确认",
f"{selected_size} 只有慢速模式支持,是否确认切换到慢速模式?",
"模式与宽高比不匹配",
f"宽高比 {new_ratio} 仅【极速模式】支持,\n"
f"当前【慢速模式】不支持该比例。\n\n"
f"是否切换到极速模式?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
self.generation_mode.setCurrentIndex(1) # 切换到慢速模式
self.generation_mode.setCurrentIndex(0) # 切到极速模式
self._prev_aspect_ratio = new_ratio
else:
# 用户拒绝,恢复到1K
self.image_size.setCurrentIndex(0)
# 回滚,blockSignals 避免回滚动作再次触发本函数
self.aspect_ratio.blockSignals(True)
idx = self.aspect_ratio.findText(self._prev_aspect_ratio)
if idx >= 0:
self.aspect_ratio.setCurrentIndex(idx)
self.aspect_ratio.blockSignals(False)
else:
self._prev_aspect_ratio = new_ratio
#
# def check_multi_image_mode_conflict(self):
# """检查极速模式下的多图限制"""
......@@ -2713,6 +2788,21 @@ class ImageGeneratorWindow(QMainWindow):
QMessageBox.warning(self, "提示", "请输入图片描述!")
return
# 兼容性校验: 1:4 / 4:1 / 1:8 / 8:1 仅 Nano Banana 2 支持,慢速模式要先切过来
aspect_ratio = self.aspect_ratio.currentText()
if aspect_ratio in FLASH_ONLY_ASPECT_RATIOS and self.generation_mode.currentText() != "极速模式":
reply = QMessageBox.question(
self,
"模式与宽高比不匹配",
f"宽高比 {aspect_ratio} 仅【极速模式】支持,\n"
f"当前【慢速模式】不支持该比例。\n\n"
f"是否切换到极速模式继续生成?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply != QMessageBox.Yes:
return
self.generation_mode.setCurrentIndex(0) # 切到极速模式
try:
# Submit task to queue
......@@ -3300,7 +3390,7 @@ class ImageGenerationWorker(QThread):
error = Signal(str)
progress = Signal(str)
def __init__(self, api_key, prompt, images, aspect_ratio, image_size, model="gemini-3-pro-image-preview"):
def __init__(self, api_key, prompt, images, aspect_ratio, image_size, model=MODEL_PRO):
super().__init__()
self.logger = logging.getLogger(__name__)
self.api_key = api_key
......@@ -3310,8 +3400,23 @@ class ImageGenerationWorker(QThread):
self.image_size = image_size
self.model = model
# 审计元信息:供 TaskQueueManager 在信号回调中读取
self.finish_reason: Optional[str] = None
self.logger.info(f"图片生成任务初始化 - 模型: {model}, 尺寸: {image_size}, 宽高比: {aspect_ratio}")
def _extract_finish_reason(self, response) -> Optional[str]:
"""从 Gemini 响应提取 finish_reason,失败返回 None(不抛异常)。"""
try:
fr = response.candidates[0].finish_reason
if fr is None:
return None
# finish_reason 可能是 enum,转成字符串
name = getattr(fr, "name", None)
return name if name else str(fr)
except Exception:
return None
def run(self):
"""Execute image generation in background thread"""
try:
......@@ -3354,9 +3459,9 @@ class ImageGenerationWorker(QThread):
self.progress.emit("正在生成图片...")
# Generation config
# Note: gemini-2.5-flash-image-preview does not support image_size parameter
if "gemini-3-pro-image-preview" in self.model:
# Gemini 3 Pro supports both aspect_ratio and image_size
# 当前使用的两个模型都支持 aspect_ratio + image_size:
# - gemini-3.1-flash-image-preview (Nano Banana 2): 512/1K/2K/4K + 14 种 ratio
# - gemini-3-pro-image-preview (Nano Banana Pro): 1K/2K/4K
config = types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
......@@ -3364,14 +3469,6 @@ class ImageGenerationWorker(QThread):
image_size=self.image_size
)
)
else:
# Gemini 2.5 Flash only supports aspect_ratio (fixed 1024px output)
config = types.GenerateContentConfig(
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=self.aspect_ratio
)
)
# Generate
response = client.models.generate_content(
......@@ -3379,6 +3476,7 @@ class ImageGenerationWorker(QThread):
contents=content_parts,
config=config
)
self.finish_reason = self._extract_finish_reason(response)
# Extract image
text_fragments = []
......@@ -3399,20 +3497,15 @@ class ImageGenerationWorker(QThread):
else:
reference_images_bytes.append(b'')
self.logger.info(f"图片生成成功 - 模型: {self.model}, 尺寸: {self.image_size}")
self.logger.info(f"图片生成成功 - 模型: {self.model}, 尺寸: {self.image_size}, finish_reason={self.finish_reason}")
self.finished.emit(image_bytes, self.prompt, reference_images_bytes,
self.aspect_ratio, self.image_size, self.model)
return
if getattr(part, 'text', None):
text_fragments.append(part.text)
finish_reason = ""
try:
finish_reason = str(response.candidates[0].finish_reason)
except Exception:
pass
detail = " | ".join(t for t in text_fragments if t).strip()
error_msg = f"响应中没有图片数据 (finish_reason={finish_reason})"
error_msg = f"响应中没有图片数据 (finish_reason={self.finish_reason})"
if detail:
error_msg += f"\n模型说明: {detail}"
self.logger.error(error_msg)
......@@ -3420,7 +3513,7 @@ class ImageGenerationWorker(QThread):
except Exception as e:
error_msg = f"图片生成异常: {e}"
self.logger.error(error_msg)
self.logger.error(error_msg, exc_info=True)
self.error.emit(error_msg)
......@@ -3903,7 +3996,14 @@ class StyleDesignerTab(QWidget):
aspect_label.setStyleSheet("QLabel { font-size: 14px; line-height: 18px; }")
settings_layout.addWidget(aspect_label)
self.aspect_ratio = QComboBox()
self.aspect_ratio.addItems(["1:1", "2:3", "3:2", "3:4", "4:3", "16:9"])
self.aspect_ratio.addItems([
"1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9",
# 以下为 Nano Banana 2 (极速模式) 独占,Pro 不支持
"1:4", "4:1", "1:8", "8:1",
])
# 记录上一次值用于用户拒绝切换模式时回滚
self._prev_aspect_ratio = self.aspect_ratio.currentText()
self.aspect_ratio.currentTextChanged.connect(self._on_aspect_ratio_changed)
settings_layout.addWidget(self.aspect_ratio)
settings_layout.addSpacing(10)
......@@ -4195,36 +4295,75 @@ class StyleDesignerTab(QWidget):
def get_selected_model(self):
"""根据生成模式返回对应的模型名称"""
if self.generation_mode.currentText() == "慢速模式":
return "gemini-3-pro-image-preview"
else:
return "gemini-2.5-flash-image"
return MODEL_BY_MODE.get(self.generation_mode.currentText(), MODEL_BY_MODE["极速模式"])
def on_generation_mode_changed(self, index):
"""生成模式切换时的处理"""
if self.generation_mode.currentText() == "极速模式":
# 极速模式强制使用1K
self.image_size.setCurrentIndex(0) # 1K
"""
模式切换校验:
若当前宽高比是 Flash-only 且用户切到慢速模式 → 提示不支持,询问是否坚持切换。
坚持 → 宽高比自动重置为 1:1;拒绝 → 回滚模式回到极速模式。
"""
new_mode = self.generation_mode.currentText()
current_aspect = self.aspect_ratio.currentText()
if new_mode == "慢速模式" and current_aspect in FLASH_ONLY_ASPECT_RATIOS:
reply = QMessageBox.question(
self,
"模式与宽高比不匹配",
f"当前宽高比 {current_aspect} 仅【极速模式】支持,\n"
f"【慢速模式】不支持该比例。\n\n"
f"是否仍要切换到慢速模式?\n(切换后宽高比将自动改为 1:1)",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
# 把宽高比回落到 1:1,避免留在无法提交的状态
self.aspect_ratio.blockSignals(True)
idx = self.aspect_ratio.findText("1:1")
if idx >= 0:
self.aspect_ratio.setCurrentIndex(idx)
self._prev_aspect_ratio = "1:1"
self.aspect_ratio.blockSignals(False)
else:
# 回滚模式回到极速,blockSignals 避免回滚再触发本函数
self.generation_mode.blockSignals(True)
fast_idx = self.generation_mode.findText("极速模式")
if fast_idx >= 0:
self.generation_mode.setCurrentIndex(fast_idx)
self.generation_mode.blockSignals(False)
def on_image_size_changed(self, index):
"""图片尺寸切换时的处理"""
selected_size = self.image_size.currentText()
current_mode = self.generation_mode.currentText()
"""图片尺寸切换时的处理。
Nano Banana 2 与 Nano Banana Pro 两个模型均支持 1K/2K/4K 全分辨率,
此槽保留作为信号连接锚点,不再做任何限制。"""
pass
# 如果选择2K或4K,且当前是极速模式,提示切换
if selected_size in ["2K", "4K"] and current_mode == "极速模式":
def _on_aspect_ratio_changed(self, new_ratio: str):
"""
用户选择宽高比即时校验:
选了 Flash-only 比例且当前是慢速模式 → 弹窗引导切换;拒绝则回滚。
"""
if new_ratio in FLASH_ONLY_ASPECT_RATIOS and self.generation_mode.currentText() != "极速模式":
reply = QMessageBox.question(
self,
"模式切换确认",
f"{selected_size} 只有慢速模式支持,是否确认切换到慢速模式?",
"模式与宽高比不匹配",
f"宽高比 {new_ratio} 仅【极速模式】支持,\n"
f"当前【慢速模式】不支持该比例。\n\n"
f"是否切换到极速模式?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
self.generation_mode.setCurrentIndex(1) # 切换到慢速模式
self.generation_mode.setCurrentIndex(0)
self._prev_aspect_ratio = new_ratio
else:
self.aspect_ratio.blockSignals(True)
idx = self.aspect_ratio.findText(self._prev_aspect_ratio)
if idx >= 0:
self.aspect_ratio.setCurrentIndex(idx)
self.aspect_ratio.blockSignals(False)
else:
# 用户拒绝,恢复到1K
self.image_size.setCurrentIndex(0)
self._prev_aspect_ratio = new_ratio
def generate_image(self):
"""Submit image generation task to queue"""
......@@ -4239,6 +4378,22 @@ class StyleDesignerTab(QWidget):
# 获取设置
aspect_ratio = self.aspect_ratio.currentText()
image_size = self.image_size.currentText()
# 兼容性校验: 1:4 / 4:1 / 1:8 / 8:1 仅 Nano Banana 2 支持,慢速模式要先切过来
if aspect_ratio in FLASH_ONLY_ASPECT_RATIOS and self.generation_mode.currentText() != "极速模式":
reply = QMessageBox.question(
self,
"模式与宽高比不匹配",
f"宽高比 {aspect_ratio} 仅【极速模式】支持,\n"
f"当前【慢速模式】不支持该比例。\n\n"
f"是否切换到极速模式继续生成?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply != QMessageBox.Yes:
return
self.generation_mode.setCurrentIndex(0) # 切到极速模式
model = self.get_selected_model()
# 获取父窗口的 API key
......@@ -4479,85 +4634,83 @@ def main():
except Exception as e:
logger.warning(f"[BOOT] 清理剪贴板临时文件失败: {e}")
# 第3步:加载配置
logger.info("[BOOT] Phase 3: 加载配置文件...")
# Load config for database info
config_dir = Path('.')
if getattr(sys, 'frozen', False):
system = platform.system()
if system == 'Darwin':
config_dir = Path.home() / 'Library' / 'Application Support' / 'ZB100ImageGenerator'
elif system == 'Windows':
config_dir = Path(os.getenv('APPDATA', Path.home())) / 'ZB100ImageGenerator'
else:
config_dir = Path.home() / '.config' / 'zb100imagegenerator'
# 第3步:定位 config 路径 + 从 bundled 拷贝(若用户目录下缺失)
logger.info("[BOOT] Phase 3: 定位配置文件...")
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / 'config.json'
from config_util import get_config_dir, get_config_path
config_dir = get_config_dir()
config_path = get_config_path()
# 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 for bundled config
# 如果用户配置不存在,从打包资源里拷一份出来(不抛异常就行,不存在 preflight 会拦)
if not config_path.exists() and getattr(sys, 'frozen', False):
bundled_config = None
if platform.system() == 'Darwin':
# macOS: Contents/Resources/config.json
bundled_config = Path(sys.executable).parent.parent / 'Resources' / 'config.json'
else:
# Windows/Linux: same directory as executable
bundled_config = Path(sys.executable).parent / 'config.json'
# Also try _MEIPASS directory (PyInstaller temp directory)
if not bundled_config.exists():
if not bundled_config.exists() and hasattr(sys, '_MEIPASS'):
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 = ""
saved_password_hash = ""
if config_path.exists():
try:
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
db_config = config.get("db_config")
last_user = config.get("last_user", "")
saved_password_hash = config.get("saved_password_hash", "")
logger.info(f"[BOOT] Phase 3 完成: 配置已加载, db_config={'有' if db_config else '无'}")
logger.info(f"[BOOT] 已从 bundled 拷贝 config 到 {config_path}")
except Exception as e:
logger.error(f"[BOOT] Phase 3 失败: 配置加载异常: {e}")
print(f"Failed to load config: {e}")
else:
logger.warning(f"[BOOT] Phase 3: 配置文件不存在: {config_path}")
logger.error(f"[BOOT] 从 bundled 拷贝 config 失败: {e}")
# 第4步:创建 QApplication
# 第4步:创建 QApplication(preflight 对话框需要)
logger.info("[BOOT] Phase 4: 创建 QApplication...")
app = QApplication(sys.argv)
logger.info("[BOOT] Phase 4 完成: QApplication 已创建")
# 第 4.5 步:启动门禁 preflight
# 任一检查失败 → 弹"应用启动失败,请联系 @柴进" → sys.exit(1)
logger.info("[BOOT] Phase 4.5: 启动门禁 preflight...")
from preflight import preflight_check, handle_preflight_failure
from audit_logger import init_audit_logger
audit_queue_path = config_dir / 'audit_queue.ndjson'
# 复用 init_logging 里决定的 logs_dir:
# 以 logger 的第一个 RotatingFileHandler 的 baseFilename 的父目录为准
logs_dir = config_dir # 兜底
try:
for h in logging.getLogger().handlers:
base = getattr(h, 'baseFilename', None)
if base:
logs_dir = Path(base).parent
break
except Exception:
pass
preflight_ok, preflight_err, loaded_config = preflight_check(config_path, audit_queue_path)
if not preflight_ok:
logger.error(f"[BOOT] preflight 失败: {preflight_err}")
handle_preflight_failure(preflight_err, logs_dir)
return # handle_preflight_failure 内部会 sys.exit(1)
# preflight 通过后,db_config 必定存在且可用
db_config = loaded_config["db_config"]
last_user = loaded_config.get("last_user", "")
saved_password_hash = loaded_config.get("saved_password_hash", "")
logger.info("[BOOT] Phase 4.5 完成: preflight 通过,启动审计 logger...")
# 启动审计 logger 单例(后台 UploadWorker 开始跑)
init_audit_logger(db_config, audit_queue_path, logs_dir)
logger.info("[BOOT] audit logger 已初始化")
# 应用退出时 flush 审计队列
def _flush_audit_on_quit():
try:
from audit_logger import get_audit_logger
auditor = get_audit_logger()
if auditor is not None:
auditor.shutdown(timeout=5.0)
except Exception as e:
logger.error(f"退出时 flush 审计队列失败: {e}")
app.aboutToQuit.connect(_flush_audit_on_quit)
# 第5步:设置应用图标
logger.info("[BOOT] Phase 5: 设置应用图标...")
# Set application icon
......@@ -4585,17 +4738,7 @@ def main():
else:
logger.info(f"[BOOT] Phase 5: 跳过图标设置 (icon_path={icon_path})")
# Check database config - if missing, start app without database authentication
if not db_config:
logger.warning("[BOOT] 无数据库配置,跳过登录直接进入主窗口")
print("警告:未找到数据库配置,将跳过数据库认证")
# Create main window directly without login
logger.info("[BOOT] Phase 6: 创建主窗口(无登录模式)...")
main_window = ImageGeneratorWindow()
logger.info("[BOOT] Phase 6 完成: 主窗口已创建")
main_window.show()
logger.info("[BOOT] Phase 7: 主窗口已显示,进入事件循环")
sys.exit(app.exec())
# preflight 保证 db_config 必定非空,无需再分支判断
# 第6步:显示登录对话框
logger.info("[BOOT] Phase 6: 创建登录对话框...")
......
......@@ -66,6 +66,9 @@ class Task:
result_bytes: Optional[bytes] = None
error_message: Optional[str] = None
# 审计元信息
finish_reason: Optional[str] = None
# UI 相关
thumbnail: Optional[bytes] = None
progress: float = 0.0
......@@ -106,59 +109,11 @@ class TaskQueueManager(QObject):
self._max_queue_size = 10
self._max_history_size = 10 # 只保留最近10条完成任务
# 加载数据库配置用于日志记录
self._db_config = None
self._load_db_config()
# 审计日志走 audit_logger 单例,不再在此处加载 db_config
self._initialized = True
self.logger.info("TaskQueueManager 初始化完成")
def _load_db_config(self):
"""加载数据库配置(与 image_generator.py 相同的多路径逻辑)"""
try:
import json
import sys
import os
import platform
from pathlib import Path
config_paths = []
# 1. 用户配置目录(打包后优先)
if getattr(sys, 'frozen', False):
system = platform.system()
if system == 'Darwin':
user_config = Path.home() / 'Library' / 'Application Support' / 'ZB100ImageGenerator' / 'config.json'
elif system == 'Windows':
user_config = Path(os.getenv('APPDATA', Path.home())) / 'ZB100ImageGenerator' / 'config.json'
else:
user_config = Path.home() / '.config' / 'zb100imagegenerator' / 'config.json'
config_paths.append(user_config)
# 2. 可执行文件所在目录
config_paths.append(Path(sys.executable).parent / 'config.json')
# 3. PyInstaller _MEIPASS 目录
if hasattr(sys, '_MEIPASS'):
config_paths.append(Path(sys._MEIPASS) / 'config.json')
# 4. 当前工作目录(开发模式)
config_paths.append(Path('config.json'))
# 尝试所有路径
for config_file in config_paths:
if config_file.exists():
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
self._db_config = config.get('db_config')
if self._db_config:
self.logger.info(f"数据库配置已加载: {config_file}")
return
self.logger.warning("未找到 config.json,日志记录将被禁用")
except Exception as e:
self.logger.warning(f"加载数据库配置失败: {e}")
def submit_task(
self,
task_type: TaskType,
......@@ -249,15 +204,18 @@ class TaskQueueManager(QObject):
task.model
)
# 绑定信号
self._current_worker.finished.connect(
# 绑定信号;用 worker 局部引用捕获 finish_reason,避免后续 _current_worker 被替换
worker_ref = self._current_worker
worker_ref.finished.connect(
lambda img_bytes, prompt, ref_imgs, ar, size, model:
self._on_task_completed(task_id, img_bytes, prompt, ref_imgs, ar, size, model)
self._on_task_completed(task_id, img_bytes, prompt, ref_imgs, ar, size, model,
getattr(worker_ref, 'finish_reason', None))
)
self._current_worker.error.connect(
lambda error: self._on_task_failed(task_id, error)
worker_ref.error.connect(
lambda error: self._on_task_failed(task_id, error,
getattr(worker_ref, 'finish_reason', None))
)
self._current_worker.progress.connect(
worker_ref.progress.connect(
lambda status: self.task_progress.emit(task_id, 0.5, status)
)
......@@ -265,16 +223,23 @@ class TaskQueueManager(QObject):
self._current_worker.start()
def _on_task_completed(self, task_id: str, image_bytes: bytes, prompt: str,
reference_images: list, aspect_ratio: str, image_size: str, model: str):
reference_images: list, aspect_ratio: str, image_size: str, model: str,
finish_reason: Optional[str] = None):
"""任务完成回调"""
task = self._tasks.get(task_id)
if not task:
self.logger.error(f"任务 {task_id[:8]} 不存在")
return
# 软取消: 用户在运行期间取消了该任务,丢弃这次回调结果(worker 已在别处被替换,下一个任务已启动)
if task.status == TaskStatus.CANCELLED:
self.logger.info(f"任务 {task_id[:8]} 已被取消,丢弃完成回调")
return
task.status = TaskStatus.COMPLETED
task.completed_at = datetime.now()
task.result_bytes = image_bytes
task.finish_reason = finish_reason
# 生成缩略图
try:
......@@ -283,7 +248,7 @@ class TaskQueueManager(QObject):
self.logger.warning(f"生成缩略图失败: {e}")
elapsed = (task.completed_at - task.started_at).total_seconds()
self.logger.info(f"任务完成: {task_id[:8]} - 耗时 {elapsed:.1f}s")
self.logger.info(f"任务完成: {task_id[:8]} - 耗时 {elapsed:.1f}s, finish_reason={finish_reason}")
# 记录使用日志
self._log_usage(task_id, 'success', 'memory', None)
......@@ -297,18 +262,24 @@ class TaskQueueManager(QObject):
# 处理下一个任务
self._process_next()
def _on_task_failed(self, task_id: str, error: str):
def _on_task_failed(self, task_id: str, error: str, finish_reason: Optional[str] = None):
"""任务失败回调"""
task = self._tasks.get(task_id)
if not task:
self.logger.error(f"任务 {task_id[:8]} 不存在")
return
# 软取消: 用户取消后任务实际报错返回也走这里,直接丢弃
if task.status == TaskStatus.CANCELLED:
self.logger.info(f"任务 {task_id[:8]} 已被取消,丢弃失败回调")
return
task.status = TaskStatus.FAILED
task.completed_at = datetime.now()
task.error_message = error
task.finish_reason = finish_reason
self.logger.error(f"任务失败: {task_id[:8]} - {error}")
self.logger.error(f"任务失败: {task_id[:8]} - {error}, finish_reason={finish_reason}")
# 记录使用日志
self._log_usage(task_id, 'failure', None, error)
......@@ -326,7 +297,7 @@ class TaskQueueManager(QObject):
# 获取所有已完成和失败的任务,按完成时间排序
finished_tasks = [
t for t in self._tasks.values()
if t.status in [TaskStatus.COMPLETED, TaskStatus.FAILED] and t.completed_at
if t.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED] and t.completed_at
]
finished_tasks.sort(key=lambda t: t.completed_at, reverse=True)
......@@ -372,11 +343,20 @@ class TaskQueueManager(QObject):
def cancel_task(self, task_id: str):
"""
取消任务 (仅等待中任务)
将任务状态设为 CANCELLED 并从队列中移除
取消任务 - 支持 PENDING 和 RUNNING 两种状态
- PENDING: 从队列里移除,状态置 CANCELLED
- RUNNING: 软取消。标记 CANCELLED,脱钩 _current_worker,立刻启动下一个任务。
被丢弃的 worker 线程在后台跑完后,回调在 _on_task_completed / _on_task_failed
入口处通过 status == CANCELLED 自检被丢弃(不显示结果、不记审计日志)。
API 调用费已经发出,无法退回。
"""
task = self._tasks.get(task_id)
if task and task.status == TaskStatus.PENDING:
if not task:
self.logger.warning(f"取消任务失败: {task_id[:8]} 不存在")
return
if task.status == TaskStatus.PENDING:
task.status = TaskStatus.CANCELLED
task.completed_at = datetime.now()
......@@ -386,80 +366,68 @@ class TaskQueueManager(QObject):
tid = self._queue.get()
if tid != task_id:
temp_queue.put(tid)
# 替换原队列
self._queue = temp_queue
self.logger.info(f"任务已取消: {task_id[:8]}")
self.task_failed.emit(task_id, "用户取消") # 发送取消信号以更新 UI
self.logger.info(f"任务已取消(等待中): {task_id[:8]}")
self.task_failed.emit(task_id, "用户取消")
elif task.status == TaskStatus.RUNNING:
task.status = TaskStatus.CANCELLED
task.completed_at = datetime.now()
# 脱钩当前 worker。worker 线程仍在后台跑(Gemini API 同步阻塞无法中止),
# 但它的 finished/error 信号回调会在入口处因为 status == CANCELLED 而丢弃。
self._current_worker = None
self.logger.info(f"任务已取消(运行中): {task_id[:8]} - worker 后台继续,结果将丢弃")
self.task_failed.emit(task_id, "用户取消")
# 立刻启动下一个任务,不等后台那个废 worker
self._process_next()
else:
self.logger.info(f"任务 {task_id[:8]} 状态为 {task.status.value},无需取消")
def _log_usage(self, task_id: str, status: str, result_path: Optional[str], error_message: Optional[str]):
"""
记录用户使用日志到数据库
记录用户使用日志 -> 审计队列(本地落盘 + 后台异步上传到 MySQL)。
Args:
task_id: 任务ID
status: 'success' 或 'failure'
result_path: 成功时的图片路径,失败时为 None
error_message: 失败时的错误信息,成功时为 None
关键变化:原先直接 INSERT,有多条静默失败路径;现在统一走 audit_logger 的
本地 NDJSON 队列,fsync 后返回。数据库层面的故障在后台 worker 重试,
不再影响主流程、也不会让事件丢失。
"""
if not self._db_config:
self.logger.debug("数据库配置未加载,跳过日志记录")
return
task = self._tasks.get(task_id)
if not task:
self.logger.warning(f"任务 {task_id[:8]} 不存在,无法记录日志")
return
# duration_ms: 从任务开始到现在
duration_ms: Optional[int] = None
if task.started_at and task.completed_at:
duration_ms = int((task.completed_at - task.started_at).total_seconds() * 1000)
try:
import pymysql
# 处理未登录用户
user_name = task.user_name if task.user_name else "未知用户"
device_name = task.device_name if task.device_name else "未知设备"
# 连接数据库
connection = pymysql.connect(
host=self._db_config['host'],
port=self._db_config['port'],
user=self._db_config['user'],
password=self._db_config['password'],
database=self._db_config['database']
from audit_logger import get_audit_logger
auditor = get_audit_logger()
if auditor is None:
# preflight 通过后单例应当已初始化;防御性日志
self.logger.error(
"audit logger 未初始化,任务日志无法记录 (task=%s)", task_id[:8]
)
with connection.cursor() as cursor:
sql = """
INSERT INTO `nano_banana_user_use_log`
(`user_name`, `device_name`, `prompt`, `result_path`, `status`, `error_message`)
VALUES (%s, %s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
user_name,
device_name,
task.prompt,
result_path,
status,
error_message
))
connection.commit()
connection.close()
self.logger.info(f"使用日志已记录: {task_id[:8]} - {status}")
return
auditor.log_use(
user_name=task.user_name or "未知用户",
device_name=task.device_name or "未知设备",
prompt=task.prompt,
result_path=result_path,
status=status,
error_message=error_message,
model=task.model,
duration_ms=duration_ms,
finish_reason=task.finish_reason,
)
self.logger.info(f"使用日志已入队: {task_id[:8]} - {status}")
except Exception as e:
# 日志记录失败不应影响主流程
self.logger.error(f"记录使用日志失败: {e}", exc_info=True)
# 在Mac打包版本中输出到控制台进行调试
import sys
import platform
if getattr(sys, 'frozen', False) and platform.system() == 'Darwin':
print(f"DEBUG - 数据库记录失败: {e}")
print(f"DEBUG - 用户名: {user_name}")
print(f"DEBUG - 设备名: {device_name}")
print(f"DEBUG - 提示词: {task.prompt[:50]}...")
self.logger.error(f"使用日志入队失败: {e}", exc_info=True)
class TaskQueueWidget(QWidget):
......@@ -488,7 +456,7 @@ class TaskQueueWidget(QWidget):
title = QLabel("任务队列")
title.setStyleSheet("QLabel { font-weight: bold; font-size: 10px; color: #666; }")
title.setAlignment(Qt.AlignCenter)
title.setToolTip("鼠标悬停查看详情\n右键等待中的任务可取消")
title.setToolTip("鼠标悬停查看详情\n右键等待中或运行中的任务可取消")
layout.addWidget(title)
# 分隔线
......@@ -569,6 +537,9 @@ class TaskQueueWidget(QWidget):
elif task.status == TaskStatus.FAILED:
status_text = "失败"
color = "#FF3B30" # 红色
elif task.status == TaskStatus.CANCELLED:
status_text = "已取消"
color = "#8E8E93" # 中性灰
else:
status_text = "未知"
color = "#666666" # 灰色
......@@ -616,10 +587,11 @@ class TaskQueueWidget(QWidget):
if not task:
return
# 仅为 PENDING 状态的任务显示取消选项
if task.status == TaskStatus.PENDING:
# PENDING 和 RUNNING 均可取消
if task.status in (TaskStatus.PENDING, TaskStatus.RUNNING):
menu = QMenu()
cancel_action = menu.addAction("取消任务")
label = "取消任务" if task.status == TaskStatus.PENDING else "取消任务(运行中)"
cancel_action = menu.addAction(label)
action = menu.exec_(self.task_list.mapToGlobal(position))
......@@ -632,7 +604,7 @@ class TaskQueueWidget(QWidget):
self._update_summary()
def _on_task_item_clicked(self, item: QListWidgetItem):
"""点击任务项 - 回填数据到主窗口"""
"""点击任务项 - 回填数据到主窗口 (prompt/参考图/设置 + 如果完成则回显结果图)"""
task_id = item.data(Qt.UserRole)
if not task_id:
return
......@@ -641,76 +613,83 @@ class TaskQueueWidget(QWidget):
if not task or not self.parent_window:
return
# 切换到对应的标签页
if task.type == TaskType.STYLE_DESIGN:
self.parent_window.tab_widget.setCurrentIndex(1) # 款式设计标签
style_tab = self.parent_window.tab_widget.currentWidget()
else:
self.parent_window.tab_widget.setCurrentIndex(0) # 图片生成标签
gen_tab = self.parent_window.tab_widget.currentWidget()
# 如果是已完成任务,直接在主窗口显示结果
if task.status == TaskStatus.COMPLETED and task.result_bytes:
if task.type == TaskType.STYLE_DESIGN and hasattr(style_tab, '_display_generated_image_from_bytes'):
# 款式设计:将图片数据存储到样式标签并显示
style_tab.generated_image_bytes = task.result_bytes
style_tab._display_generated_image_from_bytes()
elif hasattr(gen_tab, '_display_generated_image_from_bytes'):
# 图片生成:将图片数据存储到主窗口并显示
self.parent_window.generated_image_bytes = task.result_bytes
gen_tab._display_generated_image_from_bytes()
# 回填参数到主窗口
# 先切 tab + 回填参数 + 回填结果
self._load_task_to_main_window(task)
def _load_task_to_main_window(self, task: Task):
"""将任务数据回填到主窗口"""
"""
回填任务到左侧执行区。
数据来源: Task 对象本身 (prompt、reference_images、aspect_ratio、image_size)。
目标:
- 款式设计 Tab: StyleDesignerTab 实例 (prompt_preview / aspect_ratio / image_size / _display_generated_image_from_bytes)
- 图片生成 Tab: ImageGeneratorWindow 主窗口本身
(prompt_text / uploaded_images + update_image_preview / aspect_ratio / image_size / display_image)
"""
main_window = self.parent_window
if main_window is None:
return
try:
if task.type == TaskType.STYLE_DESIGN:
# 款式设计标签页 - 回填prompt到预览框
self.parent_window.tab_widget.setCurrentIndex(1) # 款式设计标签
style_tab = self.parent_window.tab_widget.currentWidget()
main_window.tab_widget.setCurrentIndex(1)
style_tab = main_window.tab_widget.currentWidget()
# 回填prompt到预览框
if hasattr(style_tab, 'prompt_preview'):
# 款式设计: prompt 预览 + 设置 + 结果图
if hasattr(style_tab, 'prompt_preview') and task.prompt:
style_tab.prompt_preview.setPlainText(task.prompt)
# 回填设置
if hasattr(style_tab, 'aspect_ratio') and task.aspect_ratio:
index = style_tab.aspect_ratio.findText(task.aspect_ratio)
if index >= 0:
style_tab.aspect_ratio.setCurrentIndex(index)
idx = style_tab.aspect_ratio.findText(task.aspect_ratio)
if idx >= 0:
style_tab.aspect_ratio.setCurrentIndex(idx)
if hasattr(style_tab, 'image_size') and task.image_size:
index = style_tab.image_size.findText(task.image_size)
if index >= 0:
style_tab.image_size.setCurrentIndex(index)
idx = style_tab.image_size.findText(task.image_size)
if idx >= 0:
style_tab.image_size.setCurrentIndex(idx)
# 已完成任务: 回显生成结果图
if (task.status == TaskStatus.COMPLETED and task.result_bytes
and hasattr(style_tab, '_display_generated_image_from_bytes')):
style_tab.generated_image_bytes = task.result_bytes
style_tab._display_generated_image_from_bytes()
else:
# 图片生成标签页
self.parent_window.tab_widget.setCurrentIndex(0) # 图片生成标签
gen_tab = self.parent_window.tab_widget.currentWidget()
# 回填prompt
if hasattr(gen_tab, 'prompt_text'):
gen_tab.prompt_text.setPlainText(task.prompt)
# 回填参考图片
if task.reference_images and hasattr(gen_tab, 'add_reference_image'):
for ref_path in task.reference_images:
gen_tab.add_reference_image(ref_path)
# 回填设置
if hasattr(gen_tab, 'aspect_ratio') and task.aspect_ratio:
index = gen_tab.aspect_ratio.findText(task.aspect_ratio)
if index >= 0:
gen_tab.aspect_ratio.setCurrentIndex(index)
if hasattr(gen_tab, 'image_size') and task.image_size:
index = gen_tab.image_size.findText(task.image_size)
if index >= 0:
gen_tab.image_size.setCurrentIndex(index)
# 图片生成: 所有控件都挂在主窗口上 (generation_tab 只是容器)
main_window.tab_widget.setCurrentIndex(0)
if hasattr(main_window, 'prompt_text') and task.prompt:
main_window.prompt_text.setPlainText(task.prompt)
# 参考图: 直接覆盖 uploaded_images + 刷缩略图 + 刷计数
if hasattr(main_window, 'uploaded_images'):
# 只保留仍存在于磁盘的路径,避免旧任务引用已删除文件
import os
valid_paths = [p for p in (task.reference_images or []) if p and os.path.exists(p)]
main_window.uploaded_images = list(valid_paths)
if hasattr(main_window, 'update_image_preview'):
main_window.update_image_preview()
if hasattr(main_window, 'image_count_label'):
main_window.image_count_label.setText(f"已选择 {len(valid_paths)} 张")
if hasattr(main_window, 'aspect_ratio') and task.aspect_ratio:
idx = main_window.aspect_ratio.findText(task.aspect_ratio)
if idx >= 0:
main_window.aspect_ratio.setCurrentIndex(idx)
if hasattr(main_window, 'image_size') and task.image_size:
idx = main_window.image_size.findText(task.image_size)
if idx >= 0:
main_window.image_size.setCurrentIndex(idx)
# 已完成任务: 回显生成结果图
if (task.status == TaskStatus.COMPLETED and task.result_bytes
and hasattr(main_window, 'display_image')):
main_window.generated_image_bytes = task.result_bytes
main_window.display_image()
except Exception as e:
self.logger.error(f"回填数据到主窗口失败: {e}")
self.logger.error(f"回填数据到主窗口失败: {e}", exc_info=True)
def _on_task_started(self, task_id: str):
"""任务开始回调"""
......@@ -789,69 +768,3 @@ class TaskQueueWidget(QWidget):
# 侧边栏模式下进度信息通过状态标签显示
# 不需要额外更新,由_update_summary统一处理
pass
def _on_task_item_clicked(self, item: QListWidgetItem):
"""单击任务 - 回填数据到主窗口或显示结果"""
task_id = item.data(Qt.UserRole)
task = self.manager.get_task(task_id)
if not task or not self.parent_window:
return
# 切换到对应的标签页
if task.type == TaskType.STYLE_DESIGN:
self.parent_window.tab_widget.setCurrentIndex(1) # 款式设计标签
style_tab = self.parent_window.tab_widget.currentWidget()
else:
self.parent_window.tab_widget.setCurrentIndex(0) # 图片生成标签
gen_tab = self.parent_window.tab_widget.currentWidget()
# 如果是已完成任务,直接在主窗口显示结果
if task.status == TaskStatus.COMPLETED and task.result_bytes:
self.parent_window.generated_image_bytes = task.result_bytes
# 显示生成的图片
if hasattr(self.parent_window, 'display_generated_image'):
self.parent_window.display_generated_image()
elif task.type == TaskType.STYLE_DESIGN and hasattr(style_tab, '_display_generated_image_from_bytes'):
style_tab._display_generated_image_from_bytes()
elif hasattr(gen_tab, '_display_generated_image_from_bytes'):
gen_tab._display_generated_image_from_bytes()
# 回填参数
self._load_task_to_main_window(task)
def _load_task_to_main_window(self, task: Task):
"""将任务数据回填到主窗口"""
try:
if task.type == TaskType.STYLE_DESIGN:
# 切换到款式设计标签
self.parent_window.tab_widget.setCurrentIndex(1)
style_tab = self.parent_window.tab_widget.currentWidget()
if hasattr(style_tab, 'library_manager'):
# 款式设计不需要回填prompt,因为是参数组合
pass
else:
# 切换到图片生成标签
self.parent_window.tab_widget.setCurrentIndex(0)
gen_tab = self.parent_window.tab_widget.currentWidget()
if hasattr(gen_tab, 'prompt_input'):
# 回填prompt
gen_tab.prompt_input.setPlainText(task.prompt)
# 回填参考图片
if task.reference_images:
for ref_path in task.reference_images:
if hasattr(gen_tab, 'add_reference_image'):
gen_tab.add_reference_image(ref_path)
# 回填设置
if hasattr(gen_tab, 'aspect_ratio') and task.aspect_ratio:
index = gen_tab.aspect_ratio.findText(task.aspect_ratio)
if index >= 0:
gen_tab.aspect_ratio.setCurrentIndex(index)
if hasattr(gen_tab, 'image_size') and task.image_size:
index = gen_tab.image_size.findText(task.image_size)
if index >= 0:
gen_tab.image_size.setCurrentIndex(index)
except Exception as e:
self.logger.error(f"回填数据到主窗口失败: {e}")
......