任务队列可取消运行中任务 + 回显修复 + 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
Showing
2 changed files
with
461 additions
and
405 deletions
| ... | @@ -41,6 +41,19 @@ from dataclasses import dataclass, asdict | ... | @@ -41,6 +41,19 @@ from dataclasses import dataclass, asdict |
| 41 | from typing import List, Optional, Dict, Any | 41 | from typing import List, Optional, Dict, Any |
| 42 | 42 | ||
| 43 | 43 | ||
| 44 | # 生成模式 -> Gemini 模型 ID 映射(单一真相源,消除原先两处 get_selected_model 复制粘贴) | ||
| 45 | # 极速模式:Nano Banana 2 (Gemini 3.1 Flash Image), 指令遵循强于 2.5-flash-image | ||
| 46 | # 慢速模式:Nano Banana Pro (Gemini 3 Pro Image Preview) | ||
| 47 | MODEL_BY_MODE = { | ||
| 48 | "极速模式": "gemini-3.1-flash-image-preview", | ||
| 49 | "慢速模式": "gemini-3-pro-image-preview", | ||
| 50 | } | ||
| 51 | MODEL_PRO = MODEL_BY_MODE["慢速模式"] # 用于 Worker 中判断是否支持 image_size 参数 | ||
| 52 | |||
| 53 | # Nano Banana 2 (Flash) 独占的宽高比 —— Pro 不支持,选中这些时需提示切换到极速模式 | ||
| 54 | FLASH_ONLY_ASPECT_RATIOS = {"1:4", "4:1", "1:8", "8:1"} | ||
| 55 | |||
| 56 | |||
| 44 | def _flush_logs() -> None: | 57 | def _flush_logs() -> None: |
| 45 | """强制刷盘所有日志 handlers. | 58 | """强制刷盘所有日志 handlers. |
| 46 | 59 | ||
| ... | @@ -109,18 +122,35 @@ def _enable_crash_diagnostics(): | ... | @@ -109,18 +122,35 @@ def _enable_crash_diagnostics(): |
| 109 | crash_log = _get_crash_log_path() | 122 | crash_log = _get_crash_log_path() |
| 110 | 123 | ||
| 111 | # 1. faulthandler: segfault 时自动输出 Python 调用栈到文件 | 124 | # 1. faulthandler: segfault 时自动输出 Python 调用栈到文件 |
| 125 | # PyInstaller windowed 模式 (runw.exe / console=False) 下 sys.stderr / sys.stdout | ||
| 126 | # 都会是 None,直接传给 faulthandler 会 RuntimeError: sys.stderr is None。 | ||
| 112 | try: | 127 | try: |
| 113 | crash_fh = open(crash_log, "a", encoding="utf-8") | 128 | crash_fh = open(crash_log, "a", encoding="utf-8") |
| 114 | crash_fh.write(f"\n{'='*60}\n") | 129 | crash_fh.write(f"\n{'='*60}\n") |
| 115 | crash_fh.write(f"[STARTUP] {datetime.now().isoformat()} - faulthandler 已启用\n") | 130 | crash_fh.write(f"[STARTUP] {datetime.now().isoformat()} - faulthandler 已启用\n") |
| 116 | crash_fh.flush() | 131 | crash_fh.flush() |
| 117 | faulthandler.enable(file=crash_fh, all_threads=True) | 132 | faulthandler.enable(file=crash_fh, all_threads=True) |
| 118 | # 同时输出到 stderr | 133 | # 同时输出到 stderr (仅在 stderr 真实存在时;windowed build 下跳过) |
| 134 | if sys.stderr is not None: | ||
| 135 | try: | ||
| 119 | faulthandler.enable(file=sys.stderr, all_threads=True) | 136 | faulthandler.enable(file=sys.stderr, all_threads=True) |
| 137 | except (RuntimeError, ValueError): | ||
| 138 | pass | ||
| 139 | try: | ||
| 120 | print(f"崩溃诊断日志路径: {crash_log}") | 140 | print(f"崩溃诊断日志路径: {crash_log}") |
| 141 | except Exception: | ||
| 142 | pass | ||
| 121 | except Exception as e: | 143 | except Exception as e: |
| 144 | try: | ||
| 122 | print(f"faulthandler 启用失败: {e}") | 145 | print(f"faulthandler 启用失败: {e}") |
| 123 | faulthandler.enable() # 至少启用 stderr 输出 | 146 | except Exception: |
| 147 | pass | ||
| 148 | # 兜底:仅在 stderr 可用时才默认 enable(它内部默认写 stderr) | ||
| 149 | if sys.stderr is not None: | ||
| 150 | try: | ||
| 151 | faulthandler.enable() | ||
| 152 | except (RuntimeError, ValueError): | ||
| 153 | pass | ||
| 124 | 154 | ||
| 125 | # 2. 全局 Python 异常钩子 | 155 | # 2. 全局 Python 异常钩子 |
| 126 | def _global_excepthook(exc_type, exc_value, exc_tb): | 156 | def _global_excepthook(exc_type, exc_value, exc_tb): |
| ... | @@ -1217,34 +1247,31 @@ class LoginDialog(QDialog): | ... | @@ -1217,34 +1247,31 @@ class LoginDialog(QDialog): |
| 1217 | return "Unknown" | 1247 | return "Unknown" |
| 1218 | 1248 | ||
| 1219 | def log_user_login(self, username): | 1249 | def log_user_login(self, username): |
| 1220 | """静默记录用户登录日志,支持双IP""" | 1250 | """记录用户登录日志 -> 审计队列(本地落盘 + 后台异步上传)。""" |
| 1221 | try: | 1251 | try: |
| 1252 | from audit_logger import get_audit_logger | ||
| 1253 | auditor = get_audit_logger() | ||
| 1254 | if auditor is None: | ||
| 1255 | # 理论上 preflight 通过后单例必然已 init;防御性日志 | ||
| 1256 | logging.getLogger(__name__).error( | ||
| 1257 | "audit logger 未初始化,登录日志无法记录 (user=%s)", username | ||
| 1258 | ) | ||
| 1259 | return | ||
| 1260 | |||
| 1222 | local_ip = self.get_local_ip() | 1261 | local_ip = self.get_local_ip() |
| 1223 | public_ip = self.get_public_ip() | 1262 | public_ip = self.get_public_ip() |
| 1224 | device_name = self.get_device_name() | 1263 | device_name = self.get_device_name() |
| 1225 | 1264 | auditor.log_login( | |
| 1226 | conn = pymysql.connect( | 1265 | user_name=username, |
| 1227 | host=self.db_config['host'], | 1266 | local_ip=local_ip, |
| 1228 | port=self.db_config.get('port', 3306), | 1267 | public_ip=public_ip, |
| 1229 | user=self.db_config['user'], | 1268 | device_name=device_name, |
| 1230 | password=self.db_config['password'], | 1269 | ) |
| 1231 | database=self.db_config['database'], | 1270 | except Exception as e: |
| 1232 | connect_timeout=5 | 1271 | # 不再裸吞:至少 error 日志里有痕迹 |
| 1272 | logging.getLogger(__name__).error( | ||
| 1273 | "登录日志入队失败 (user=%s): %s", username, e | ||
| 1233 | ) | 1274 | ) |
| 1234 | |||
| 1235 | try: | ||
| 1236 | with conn.cursor() as cursor: | ||
| 1237 | sql = """INSERT INTO nano_banana_user_log | ||
| 1238 | (user_name, local_ip, public_ip, device_name, login_time) | ||
| 1239 | VALUES (%s, %s, %s, %s, %s)""" | ||
| 1240 | cursor.execute(sql, (username, local_ip, public_ip, device_name, datetime.now())) | ||
| 1241 | conn.commit() | ||
| 1242 | finally: | ||
| 1243 | conn.close() | ||
| 1244 | |||
| 1245 | except Exception: | ||
| 1246 | # 静默处理,不影响登录流程 | ||
| 1247 | pass | ||
| 1248 | 1275 | ||
| 1249 | def show_error(self, message): | 1276 | def show_error(self, message): |
| 1250 | """显示错误弹窗和标签""" | 1277 | """显示错误弹窗和标签""" |
| ... | @@ -1776,7 +1803,7 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -1776,7 +1803,7 @@ class ImageGeneratorWindow(QMainWindow): |
| 1776 | # Right side: Task queue sidebar | 1803 | # Right side: Task queue sidebar |
| 1777 | self.logger.info("[INIT-UI] 创建任务队列侧边栏...") | 1804 | self.logger.info("[INIT-UI] 创建任务队列侧边栏...") |
| 1778 | from task_queue import TaskQueueWidget | 1805 | from task_queue import TaskQueueWidget |
| 1779 | self.task_queue_widget = TaskQueueWidget(self.task_manager) | 1806 | self.task_queue_widget = TaskQueueWidget(self.task_manager, parent=self) |
| 1780 | main_layout.addWidget(self.task_queue_widget, 3) # 30% width | 1807 | main_layout.addWidget(self.task_queue_widget, 3) # 30% width |
| 1781 | self.logger.info("[INIT-UI] 任务队列侧边栏创建完成") | 1808 | self.logger.info("[INIT-UI] 任务队列侧边栏创建完成") |
| 1782 | 1809 | ||
| ... | @@ -1906,7 +1933,14 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -1906,7 +1933,14 @@ class ImageGeneratorWindow(QMainWindow): |
| 1906 | # 宽高比 | 1933 | # 宽高比 |
| 1907 | settings_layout.addWidget(QLabel("宽高比")) | 1934 | settings_layout.addWidget(QLabel("宽高比")) |
| 1908 | self.aspect_ratio = QComboBox() | 1935 | self.aspect_ratio = QComboBox() |
| 1909 | self.aspect_ratio.addItems(["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9"]) | 1936 | self.aspect_ratio.addItems([ |
| 1937 | "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", | ||
| 1938 | # 以下为 Nano Banana 2 (极速模式) 独占,Pro 不支持 | ||
| 1939 | "1:4", "4:1", "1:8", "8:1", | ||
| 1940 | ]) | ||
| 1941 | # 记录上一次值用于用户拒绝切换模式时回滚,避免留在一个无法提交的状态 | ||
| 1942 | self._prev_aspect_ratio = self.aspect_ratio.currentText() | ||
| 1943 | self.aspect_ratio.currentTextChanged.connect(self._on_aspect_ratio_changed) | ||
| 1910 | settings_layout.addWidget(self.aspect_ratio) | 1944 | settings_layout.addWidget(self.aspect_ratio) |
| 1911 | 1945 | ||
| 1912 | settings_layout.addSpacing(10) | 1946 | settings_layout.addSpacing(10) |
| ... | @@ -2649,36 +2683,77 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -2649,36 +2683,77 @@ class ImageGeneratorWindow(QMainWindow): |
| 2649 | 2683 | ||
| 2650 | def get_selected_model(self): | 2684 | def get_selected_model(self): |
| 2651 | """根据生成模式返回对应的模型名称""" | 2685 | """根据生成模式返回对应的模型名称""" |
| 2652 | if self.generation_mode.currentText() == "慢速模式": | 2686 | return MODEL_BY_MODE.get(self.generation_mode.currentText(), MODEL_BY_MODE["极速模式"]) |
| 2653 | return "gemini-3-pro-image-preview" | ||
| 2654 | else: | ||
| 2655 | return "gemini-2.5-flash-image" | ||
| 2656 | 2687 | ||
| 2657 | def on_generation_mode_changed(self, index): | 2688 | def on_generation_mode_changed(self, index): |
| 2658 | """生成模式切换时的处理""" | 2689 | """ |
| 2659 | if self.generation_mode.currentText() == "极速模式": | 2690 | 模式切换校验: |
| 2660 | # 极速模式强制使用1K | 2691 | 若当前宽高比是 Flash-only 且用户切到慢速模式 → 提示不支持,询问是否坚持切换。 |
| 2661 | self.image_size.setCurrentIndex(0) # 1K | 2692 | 坚持 → 宽高比自动重置为 1:1;拒绝 → 回滚模式回到极速模式。 |
| 2693 | """ | ||
| 2694 | new_mode = self.generation_mode.currentText() | ||
| 2695 | current_aspect = self.aspect_ratio.currentText() | ||
| 2696 | |||
| 2697 | if new_mode == "慢速模式" and current_aspect in FLASH_ONLY_ASPECT_RATIOS: | ||
| 2698 | reply = QMessageBox.question( | ||
| 2699 | self, | ||
| 2700 | "模式与宽高比不匹配", | ||
| 2701 | f"当前宽高比 {current_aspect} 仅【极速模式】支持,\n" | ||
| 2702 | f"【慢速模式】不支持该比例。\n\n" | ||
| 2703 | f"是否仍要切换到慢速模式?\n(切换后宽高比将自动改为 1:1)", | ||
| 2704 | QMessageBox.Yes | QMessageBox.No, | ||
| 2705 | QMessageBox.No | ||
| 2706 | ) | ||
| 2707 | if reply == QMessageBox.Yes: | ||
| 2708 | # 把宽高比回落到 1:1,避免留在无法提交的状态 | ||
| 2709 | self.aspect_ratio.blockSignals(True) | ||
| 2710 | idx = self.aspect_ratio.findText("1:1") | ||
| 2711 | if idx >= 0: | ||
| 2712 | self.aspect_ratio.setCurrentIndex(idx) | ||
| 2713 | self._prev_aspect_ratio = "1:1" | ||
| 2714 | self.aspect_ratio.blockSignals(False) | ||
| 2715 | else: | ||
| 2716 | # 回滚模式回到极速,blockSignals 避免回滚再触发本函数 | ||
| 2717 | self.generation_mode.blockSignals(True) | ||
| 2718 | fast_idx = self.generation_mode.findText("极速模式") | ||
| 2719 | if fast_idx >= 0: | ||
| 2720 | self.generation_mode.setCurrentIndex(fast_idx) | ||
| 2721 | self.generation_mode.blockSignals(False) | ||
| 2662 | 2722 | ||
| 2663 | def on_image_size_changed(self, index): | 2723 | def on_image_size_changed(self, index): |
| 2664 | """图片尺寸切换时的处理""" | 2724 | """图片尺寸切换时的处理。 |
| 2665 | selected_size = self.image_size.currentText() | 2725 | Nano Banana 2 与 Nano Banana Pro 两个模型均支持 1K/2K/4K 全分辨率, |
| 2666 | current_mode = self.generation_mode.currentText() | 2726 | 此槽保留作为信号连接锚点,不再做任何限制。""" |
| 2727 | pass | ||
| 2667 | 2728 | ||
| 2668 | # 如果选择2K或4K,且当前是极速模式,提示切换 | 2729 | def _on_aspect_ratio_changed(self, new_ratio: str): |
| 2669 | if selected_size in ["2K", "4K"] and current_mode == "极速模式": | 2730 | """ |
| 2731 | 用户选择宽高比即时校验: | ||
| 2732 | 如果选了 Flash-only 比例且当前是慢速模式,弹窗提示是否切到极速模式。 | ||
| 2733 | 用户拒绝则回滚到上一次选择,避免留在一个无法提交的状态。 | ||
| 2734 | """ | ||
| 2735 | if new_ratio in FLASH_ONLY_ASPECT_RATIOS and self.generation_mode.currentText() != "极速模式": | ||
| 2670 | reply = QMessageBox.question( | 2736 | reply = QMessageBox.question( |
| 2671 | self, | 2737 | self, |
| 2672 | "模式切换确认", | 2738 | "模式与宽高比不匹配", |
| 2673 | f"{selected_size} 只有慢速模式支持,是否确认切换到慢速模式?", | 2739 | f"宽高比 {new_ratio} 仅【极速模式】支持,\n" |
| 2740 | f"当前【慢速模式】不支持该比例。\n\n" | ||
| 2741 | f"是否切换到极速模式?", | ||
| 2674 | QMessageBox.Yes | QMessageBox.No, | 2742 | QMessageBox.Yes | QMessageBox.No, |
| 2675 | QMessageBox.No | 2743 | QMessageBox.Yes |
| 2676 | ) | 2744 | ) |
| 2677 | if reply == QMessageBox.Yes: | 2745 | if reply == QMessageBox.Yes: |
| 2678 | self.generation_mode.setCurrentIndex(1) # 切换到慢速模式 | 2746 | self.generation_mode.setCurrentIndex(0) # 切到极速模式 |
| 2747 | self._prev_aspect_ratio = new_ratio | ||
| 2679 | else: | 2748 | else: |
| 2680 | # 用户拒绝,恢复到1K | 2749 | # 回滚,blockSignals 避免回滚动作再次触发本函数 |
| 2681 | self.image_size.setCurrentIndex(0) | 2750 | self.aspect_ratio.blockSignals(True) |
| 2751 | idx = self.aspect_ratio.findText(self._prev_aspect_ratio) | ||
| 2752 | if idx >= 0: | ||
| 2753 | self.aspect_ratio.setCurrentIndex(idx) | ||
| 2754 | self.aspect_ratio.blockSignals(False) | ||
| 2755 | else: | ||
| 2756 | self._prev_aspect_ratio = new_ratio | ||
| 2682 | # | 2757 | # |
| 2683 | # def check_multi_image_mode_conflict(self): | 2758 | # def check_multi_image_mode_conflict(self): |
| 2684 | # """检查极速模式下的多图限制""" | 2759 | # """检查极速模式下的多图限制""" |
| ... | @@ -2713,6 +2788,21 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -2713,6 +2788,21 @@ class ImageGeneratorWindow(QMainWindow): |
| 2713 | QMessageBox.warning(self, "提示", "请输入图片描述!") | 2788 | QMessageBox.warning(self, "提示", "请输入图片描述!") |
| 2714 | return | 2789 | return |
| 2715 | 2790 | ||
| 2791 | # 兼容性校验: 1:4 / 4:1 / 1:8 / 8:1 仅 Nano Banana 2 支持,慢速模式要先切过来 | ||
| 2792 | aspect_ratio = self.aspect_ratio.currentText() | ||
| 2793 | if aspect_ratio in FLASH_ONLY_ASPECT_RATIOS and self.generation_mode.currentText() != "极速模式": | ||
| 2794 | reply = QMessageBox.question( | ||
| 2795 | self, | ||
| 2796 | "模式与宽高比不匹配", | ||
| 2797 | f"宽高比 {aspect_ratio} 仅【极速模式】支持,\n" | ||
| 2798 | f"当前【慢速模式】不支持该比例。\n\n" | ||
| 2799 | f"是否切换到极速模式继续生成?", | ||
| 2800 | QMessageBox.Yes | QMessageBox.No, | ||
| 2801 | QMessageBox.Yes | ||
| 2802 | ) | ||
| 2803 | if reply != QMessageBox.Yes: | ||
| 2804 | return | ||
| 2805 | self.generation_mode.setCurrentIndex(0) # 切到极速模式 | ||
| 2716 | 2806 | ||
| 2717 | try: | 2807 | try: |
| 2718 | # Submit task to queue | 2808 | # Submit task to queue |
| ... | @@ -3300,7 +3390,7 @@ class ImageGenerationWorker(QThread): | ... | @@ -3300,7 +3390,7 @@ class ImageGenerationWorker(QThread): |
| 3300 | error = Signal(str) | 3390 | error = Signal(str) |
| 3301 | progress = Signal(str) | 3391 | progress = Signal(str) |
| 3302 | 3392 | ||
| 3303 | def __init__(self, api_key, prompt, images, aspect_ratio, image_size, model="gemini-3-pro-image-preview"): | 3393 | def __init__(self, api_key, prompt, images, aspect_ratio, image_size, model=MODEL_PRO): |
| 3304 | super().__init__() | 3394 | super().__init__() |
| 3305 | self.logger = logging.getLogger(__name__) | 3395 | self.logger = logging.getLogger(__name__) |
| 3306 | self.api_key = api_key | 3396 | self.api_key = api_key |
| ... | @@ -3310,8 +3400,23 @@ class ImageGenerationWorker(QThread): | ... | @@ -3310,8 +3400,23 @@ class ImageGenerationWorker(QThread): |
| 3310 | self.image_size = image_size | 3400 | self.image_size = image_size |
| 3311 | self.model = model | 3401 | self.model = model |
| 3312 | 3402 | ||
| 3403 | # 审计元信息:供 TaskQueueManager 在信号回调中读取 | ||
| 3404 | self.finish_reason: Optional[str] = None | ||
| 3405 | |||
| 3313 | self.logger.info(f"图片生成任务初始化 - 模型: {model}, 尺寸: {image_size}, 宽高比: {aspect_ratio}") | 3406 | self.logger.info(f"图片生成任务初始化 - 模型: {model}, 尺寸: {image_size}, 宽高比: {aspect_ratio}") |
| 3314 | 3407 | ||
| 3408 | def _extract_finish_reason(self, response) -> Optional[str]: | ||
| 3409 | """从 Gemini 响应提取 finish_reason,失败返回 None(不抛异常)。""" | ||
| 3410 | try: | ||
| 3411 | fr = response.candidates[0].finish_reason | ||
| 3412 | if fr is None: | ||
| 3413 | return None | ||
| 3414 | # finish_reason 可能是 enum,转成字符串 | ||
| 3415 | name = getattr(fr, "name", None) | ||
| 3416 | return name if name else str(fr) | ||
| 3417 | except Exception: | ||
| 3418 | return None | ||
| 3419 | |||
| 3315 | def run(self): | 3420 | def run(self): |
| 3316 | """Execute image generation in background thread""" | 3421 | """Execute image generation in background thread""" |
| 3317 | try: | 3422 | try: |
| ... | @@ -3354,9 +3459,9 @@ class ImageGenerationWorker(QThread): | ... | @@ -3354,9 +3459,9 @@ class ImageGenerationWorker(QThread): |
| 3354 | self.progress.emit("正在生成图片...") | 3459 | self.progress.emit("正在生成图片...") |
| 3355 | 3460 | ||
| 3356 | # Generation config | 3461 | # Generation config |
| 3357 | # Note: gemini-2.5-flash-image-preview does not support image_size parameter | 3462 | # 当前使用的两个模型都支持 aspect_ratio + image_size: |
| 3358 | if "gemini-3-pro-image-preview" in self.model: | 3463 | # - gemini-3.1-flash-image-preview (Nano Banana 2): 512/1K/2K/4K + 14 种 ratio |
| 3359 | # Gemini 3 Pro supports both aspect_ratio and image_size | 3464 | # - gemini-3-pro-image-preview (Nano Banana Pro): 1K/2K/4K |
| 3360 | config = types.GenerateContentConfig( | 3465 | config = types.GenerateContentConfig( |
| 3361 | response_modalities=["TEXT", "IMAGE"], | 3466 | response_modalities=["TEXT", "IMAGE"], |
| 3362 | image_config=types.ImageConfig( | 3467 | image_config=types.ImageConfig( |
| ... | @@ -3364,14 +3469,6 @@ class ImageGenerationWorker(QThread): | ... | @@ -3364,14 +3469,6 @@ class ImageGenerationWorker(QThread): |
| 3364 | image_size=self.image_size | 3469 | image_size=self.image_size |
| 3365 | ) | 3470 | ) |
| 3366 | ) | 3471 | ) |
| 3367 | else: | ||
| 3368 | # Gemini 2.5 Flash only supports aspect_ratio (fixed 1024px output) | ||
| 3369 | config = types.GenerateContentConfig( | ||
| 3370 | response_modalities=["TEXT", "IMAGE"], | ||
| 3371 | image_config=types.ImageConfig( | ||
| 3372 | aspect_ratio=self.aspect_ratio | ||
| 3373 | ) | ||
| 3374 | ) | ||
| 3375 | 3472 | ||
| 3376 | # Generate | 3473 | # Generate |
| 3377 | response = client.models.generate_content( | 3474 | response = client.models.generate_content( |
| ... | @@ -3379,6 +3476,7 @@ class ImageGenerationWorker(QThread): | ... | @@ -3379,6 +3476,7 @@ class ImageGenerationWorker(QThread): |
| 3379 | contents=content_parts, | 3476 | contents=content_parts, |
| 3380 | config=config | 3477 | config=config |
| 3381 | ) | 3478 | ) |
| 3479 | self.finish_reason = self._extract_finish_reason(response) | ||
| 3382 | 3480 | ||
| 3383 | # Extract image | 3481 | # Extract image |
| 3384 | text_fragments = [] | 3482 | text_fragments = [] |
| ... | @@ -3399,20 +3497,15 @@ class ImageGenerationWorker(QThread): | ... | @@ -3399,20 +3497,15 @@ class ImageGenerationWorker(QThread): |
| 3399 | else: | 3497 | else: |
| 3400 | reference_images_bytes.append(b'') | 3498 | reference_images_bytes.append(b'') |
| 3401 | 3499 | ||
| 3402 | self.logger.info(f"图片生成成功 - 模型: {self.model}, 尺寸: {self.image_size}") | 3500 | self.logger.info(f"图片生成成功 - 模型: {self.model}, 尺寸: {self.image_size}, finish_reason={self.finish_reason}") |
| 3403 | self.finished.emit(image_bytes, self.prompt, reference_images_bytes, | 3501 | self.finished.emit(image_bytes, self.prompt, reference_images_bytes, |
| 3404 | self.aspect_ratio, self.image_size, self.model) | 3502 | self.aspect_ratio, self.image_size, self.model) |
| 3405 | return | 3503 | return |
| 3406 | if getattr(part, 'text', None): | 3504 | if getattr(part, 'text', None): |
| 3407 | text_fragments.append(part.text) | 3505 | text_fragments.append(part.text) |
| 3408 | 3506 | ||
| 3409 | finish_reason = "" | ||
| 3410 | try: | ||
| 3411 | finish_reason = str(response.candidates[0].finish_reason) | ||
| 3412 | except Exception: | ||
| 3413 | pass | ||
| 3414 | detail = " | ".join(t for t in text_fragments if t).strip() | 3507 | detail = " | ".join(t for t in text_fragments if t).strip() |
| 3415 | error_msg = f"响应中没有图片数据 (finish_reason={finish_reason})" | 3508 | error_msg = f"响应中没有图片数据 (finish_reason={self.finish_reason})" |
| 3416 | if detail: | 3509 | if detail: |
| 3417 | error_msg += f"\n模型说明: {detail}" | 3510 | error_msg += f"\n模型说明: {detail}" |
| 3418 | self.logger.error(error_msg) | 3511 | self.logger.error(error_msg) |
| ... | @@ -3420,7 +3513,7 @@ class ImageGenerationWorker(QThread): | ... | @@ -3420,7 +3513,7 @@ class ImageGenerationWorker(QThread): |
| 3420 | 3513 | ||
| 3421 | except Exception as e: | 3514 | except Exception as e: |
| 3422 | error_msg = f"图片生成异常: {e}" | 3515 | error_msg = f"图片生成异常: {e}" |
| 3423 | self.logger.error(error_msg) | 3516 | self.logger.error(error_msg, exc_info=True) |
| 3424 | self.error.emit(error_msg) | 3517 | self.error.emit(error_msg) |
| 3425 | 3518 | ||
| 3426 | 3519 | ||
| ... | @@ -3903,7 +3996,14 @@ class StyleDesignerTab(QWidget): | ... | @@ -3903,7 +3996,14 @@ class StyleDesignerTab(QWidget): |
| 3903 | aspect_label.setStyleSheet("QLabel { font-size: 14px; line-height: 18px; }") | 3996 | aspect_label.setStyleSheet("QLabel { font-size: 14px; line-height: 18px; }") |
| 3904 | settings_layout.addWidget(aspect_label) | 3997 | settings_layout.addWidget(aspect_label) |
| 3905 | self.aspect_ratio = QComboBox() | 3998 | self.aspect_ratio = QComboBox() |
| 3906 | self.aspect_ratio.addItems(["1:1", "2:3", "3:2", "3:4", "4:3", "16:9"]) | 3999 | self.aspect_ratio.addItems([ |
| 4000 | "1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4", "9:16", "16:9", "21:9", | ||
| 4001 | # 以下为 Nano Banana 2 (极速模式) 独占,Pro 不支持 | ||
| 4002 | "1:4", "4:1", "1:8", "8:1", | ||
| 4003 | ]) | ||
| 4004 | # 记录上一次值用于用户拒绝切换模式时回滚 | ||
| 4005 | self._prev_aspect_ratio = self.aspect_ratio.currentText() | ||
| 4006 | self.aspect_ratio.currentTextChanged.connect(self._on_aspect_ratio_changed) | ||
| 3907 | settings_layout.addWidget(self.aspect_ratio) | 4007 | settings_layout.addWidget(self.aspect_ratio) |
| 3908 | 4008 | ||
| 3909 | settings_layout.addSpacing(10) | 4009 | settings_layout.addSpacing(10) |
| ... | @@ -4195,36 +4295,75 @@ class StyleDesignerTab(QWidget): | ... | @@ -4195,36 +4295,75 @@ class StyleDesignerTab(QWidget): |
| 4195 | 4295 | ||
| 4196 | def get_selected_model(self): | 4296 | def get_selected_model(self): |
| 4197 | """根据生成模式返回对应的模型名称""" | 4297 | """根据生成模式返回对应的模型名称""" |
| 4198 | if self.generation_mode.currentText() == "慢速模式": | 4298 | return MODEL_BY_MODE.get(self.generation_mode.currentText(), MODEL_BY_MODE["极速模式"]) |
| 4199 | return "gemini-3-pro-image-preview" | ||
| 4200 | else: | ||
| 4201 | return "gemini-2.5-flash-image" | ||
| 4202 | 4299 | ||
| 4203 | def on_generation_mode_changed(self, index): | 4300 | def on_generation_mode_changed(self, index): |
| 4204 | """生成模式切换时的处理""" | 4301 | """ |
| 4205 | if self.generation_mode.currentText() == "极速模式": | 4302 | 模式切换校验: |
| 4206 | # 极速模式强制使用1K | 4303 | 若当前宽高比是 Flash-only 且用户切到慢速模式 → 提示不支持,询问是否坚持切换。 |
| 4207 | self.image_size.setCurrentIndex(0) # 1K | 4304 | 坚持 → 宽高比自动重置为 1:1;拒绝 → 回滚模式回到极速模式。 |
| 4305 | """ | ||
| 4306 | new_mode = self.generation_mode.currentText() | ||
| 4307 | current_aspect = self.aspect_ratio.currentText() | ||
| 4308 | |||
| 4309 | if new_mode == "慢速模式" and current_aspect in FLASH_ONLY_ASPECT_RATIOS: | ||
| 4310 | reply = QMessageBox.question( | ||
| 4311 | self, | ||
| 4312 | "模式与宽高比不匹配", | ||
| 4313 | f"当前宽高比 {current_aspect} 仅【极速模式】支持,\n" | ||
| 4314 | f"【慢速模式】不支持该比例。\n\n" | ||
| 4315 | f"是否仍要切换到慢速模式?\n(切换后宽高比将自动改为 1:1)", | ||
| 4316 | QMessageBox.Yes | QMessageBox.No, | ||
| 4317 | QMessageBox.No | ||
| 4318 | ) | ||
| 4319 | if reply == QMessageBox.Yes: | ||
| 4320 | # 把宽高比回落到 1:1,避免留在无法提交的状态 | ||
| 4321 | self.aspect_ratio.blockSignals(True) | ||
| 4322 | idx = self.aspect_ratio.findText("1:1") | ||
| 4323 | if idx >= 0: | ||
| 4324 | self.aspect_ratio.setCurrentIndex(idx) | ||
| 4325 | self._prev_aspect_ratio = "1:1" | ||
| 4326 | self.aspect_ratio.blockSignals(False) | ||
| 4327 | else: | ||
| 4328 | # 回滚模式回到极速,blockSignals 避免回滚再触发本函数 | ||
| 4329 | self.generation_mode.blockSignals(True) | ||
| 4330 | fast_idx = self.generation_mode.findText("极速模式") | ||
| 4331 | if fast_idx >= 0: | ||
| 4332 | self.generation_mode.setCurrentIndex(fast_idx) | ||
| 4333 | self.generation_mode.blockSignals(False) | ||
| 4208 | 4334 | ||
| 4209 | def on_image_size_changed(self, index): | 4335 | def on_image_size_changed(self, index): |
| 4210 | """图片尺寸切换时的处理""" | 4336 | """图片尺寸切换时的处理。 |
| 4211 | selected_size = self.image_size.currentText() | 4337 | Nano Banana 2 与 Nano Banana Pro 两个模型均支持 1K/2K/4K 全分辨率, |
| 4212 | current_mode = self.generation_mode.currentText() | 4338 | 此槽保留作为信号连接锚点,不再做任何限制。""" |
| 4339 | pass | ||
| 4213 | 4340 | ||
| 4214 | # 如果选择2K或4K,且当前是极速模式,提示切换 | 4341 | def _on_aspect_ratio_changed(self, new_ratio: str): |
| 4215 | if selected_size in ["2K", "4K"] and current_mode == "极速模式": | 4342 | """ |
| 4343 | 用户选择宽高比即时校验: | ||
| 4344 | 选了 Flash-only 比例且当前是慢速模式 → 弹窗引导切换;拒绝则回滚。 | ||
| 4345 | """ | ||
| 4346 | if new_ratio in FLASH_ONLY_ASPECT_RATIOS and self.generation_mode.currentText() != "极速模式": | ||
| 4216 | reply = QMessageBox.question( | 4347 | reply = QMessageBox.question( |
| 4217 | self, | 4348 | self, |
| 4218 | "模式切换确认", | 4349 | "模式与宽高比不匹配", |
| 4219 | f"{selected_size} 只有慢速模式支持,是否确认切换到慢速模式?", | 4350 | f"宽高比 {new_ratio} 仅【极速模式】支持,\n" |
| 4351 | f"当前【慢速模式】不支持该比例。\n\n" | ||
| 4352 | f"是否切换到极速模式?", | ||
| 4220 | QMessageBox.Yes | QMessageBox.No, | 4353 | QMessageBox.Yes | QMessageBox.No, |
| 4221 | QMessageBox.No | 4354 | QMessageBox.Yes |
| 4222 | ) | 4355 | ) |
| 4223 | if reply == QMessageBox.Yes: | 4356 | if reply == QMessageBox.Yes: |
| 4224 | self.generation_mode.setCurrentIndex(1) # 切换到慢速模式 | 4357 | self.generation_mode.setCurrentIndex(0) |
| 4358 | self._prev_aspect_ratio = new_ratio | ||
| 4359 | else: | ||
| 4360 | self.aspect_ratio.blockSignals(True) | ||
| 4361 | idx = self.aspect_ratio.findText(self._prev_aspect_ratio) | ||
| 4362 | if idx >= 0: | ||
| 4363 | self.aspect_ratio.setCurrentIndex(idx) | ||
| 4364 | self.aspect_ratio.blockSignals(False) | ||
| 4225 | else: | 4365 | else: |
| 4226 | # 用户拒绝,恢复到1K | 4366 | self._prev_aspect_ratio = new_ratio |
| 4227 | self.image_size.setCurrentIndex(0) | ||
| 4228 | 4367 | ||
| 4229 | def generate_image(self): | 4368 | def generate_image(self): |
| 4230 | """Submit image generation task to queue""" | 4369 | """Submit image generation task to queue""" |
| ... | @@ -4239,6 +4378,22 @@ class StyleDesignerTab(QWidget): | ... | @@ -4239,6 +4378,22 @@ class StyleDesignerTab(QWidget): |
| 4239 | # 获取设置 | 4378 | # 获取设置 |
| 4240 | aspect_ratio = self.aspect_ratio.currentText() | 4379 | aspect_ratio = self.aspect_ratio.currentText() |
| 4241 | image_size = self.image_size.currentText() | 4380 | image_size = self.image_size.currentText() |
| 4381 | |||
| 4382 | # 兼容性校验: 1:4 / 4:1 / 1:8 / 8:1 仅 Nano Banana 2 支持,慢速模式要先切过来 | ||
| 4383 | if aspect_ratio in FLASH_ONLY_ASPECT_RATIOS and self.generation_mode.currentText() != "极速模式": | ||
| 4384 | reply = QMessageBox.question( | ||
| 4385 | self, | ||
| 4386 | "模式与宽高比不匹配", | ||
| 4387 | f"宽高比 {aspect_ratio} 仅【极速模式】支持,\n" | ||
| 4388 | f"当前【慢速模式】不支持该比例。\n\n" | ||
| 4389 | f"是否切换到极速模式继续生成?", | ||
| 4390 | QMessageBox.Yes | QMessageBox.No, | ||
| 4391 | QMessageBox.Yes | ||
| 4392 | ) | ||
| 4393 | if reply != QMessageBox.Yes: | ||
| 4394 | return | ||
| 4395 | self.generation_mode.setCurrentIndex(0) # 切到极速模式 | ||
| 4396 | |||
| 4242 | model = self.get_selected_model() | 4397 | model = self.get_selected_model() |
| 4243 | 4398 | ||
| 4244 | # 获取父窗口的 API key | 4399 | # 获取父窗口的 API key |
| ... | @@ -4479,85 +4634,83 @@ def main(): | ... | @@ -4479,85 +4634,83 @@ def main(): |
| 4479 | except Exception as e: | 4634 | except Exception as e: |
| 4480 | logger.warning(f"[BOOT] 清理剪贴板临时文件失败: {e}") | 4635 | logger.warning(f"[BOOT] 清理剪贴板临时文件失败: {e}") |
| 4481 | 4636 | ||
| 4482 | # 第3步:加载配置 | 4637 | # 第3步:定位 config 路径 + 从 bundled 拷贝(若用户目录下缺失) |
| 4483 | logger.info("[BOOT] Phase 3: 加载配置文件...") | 4638 | logger.info("[BOOT] Phase 3: 定位配置文件...") |
| 4484 | |||
| 4485 | # Load config for database info | ||
| 4486 | config_dir = Path('.') | ||
| 4487 | if getattr(sys, 'frozen', False): | ||
| 4488 | system = platform.system() | ||
| 4489 | if system == 'Darwin': | ||
| 4490 | config_dir = Path.home() / 'Library' / 'Application Support' / 'ZB100ImageGenerator' | ||
| 4491 | elif system == 'Windows': | ||
| 4492 | config_dir = Path(os.getenv('APPDATA', Path.home())) / 'ZB100ImageGenerator' | ||
| 4493 | else: | ||
| 4494 | config_dir = Path.home() / '.config' / 'zb100imagegenerator' | ||
| 4495 | 4639 | ||
| 4496 | config_dir.mkdir(parents=True, exist_ok=True) | 4640 | from config_util import get_config_dir, get_config_path |
| 4497 | config_path = config_dir / 'config.json' | 4641 | config_dir = get_config_dir() |
| 4642 | config_path = get_config_path() | ||
| 4498 | 4643 | ||
| 4499 | # Always try to ensure user config exists - copy from bundled if needed | 4644 | # 如果用户配置不存在,从打包资源里拷一份出来(不抛异常就行,不存在 preflight 会拦) |
| 4500 | if not config_path.exists(): | 4645 | if not config_path.exists() and getattr(sys, 'frozen', False): |
| 4501 | if getattr(sys, 'frozen', False): | ||
| 4502 | # Running as bundled app - look for bundled config | ||
| 4503 | bundled_config = None | 4646 | bundled_config = None |
| 4504 | |||
| 4505 | if platform.system() == 'Darwin': | 4647 | if platform.system() == 'Darwin': |
| 4506 | # macOS: Contents/Resources/config.json | ||
| 4507 | bundled_config = Path(sys.executable).parent.parent / 'Resources' / 'config.json' | 4648 | bundled_config = Path(sys.executable).parent.parent / 'Resources' / 'config.json' |
| 4508 | else: | 4649 | else: |
| 4509 | # Windows/Linux: same directory as executable | ||
| 4510 | bundled_config = Path(sys.executable).parent / 'config.json' | 4650 | bundled_config = Path(sys.executable).parent / 'config.json' |
| 4511 | 4651 | if not bundled_config.exists() and hasattr(sys, '_MEIPASS'): | |
| 4512 | # Also try _MEIPASS directory (PyInstaller temp directory) | ||
| 4513 | if not bundled_config.exists(): | ||
| 4514 | meipass_bundled = Path(sys._MEIPASS) / 'config.json' | 4652 | meipass_bundled = Path(sys._MEIPASS) / 'config.json' |
| 4515 | if meipass_bundled.exists(): | 4653 | if meipass_bundled.exists(): |
| 4516 | bundled_config = meipass_bundled | 4654 | bundled_config = meipass_bundled |
| 4517 | |||
| 4518 | if bundled_config and bundled_config.exists(): | 4655 | if bundled_config and bundled_config.exists(): |
| 4519 | try: | 4656 | try: |
| 4520 | # Create config directory if it doesn't exist | ||
| 4521 | config_path.parent.mkdir(parents=True, exist_ok=True) | 4657 | config_path.parent.mkdir(parents=True, exist_ok=True) |
| 4522 | shutil.copy2(bundled_config, config_path) | 4658 | shutil.copy2(bundled_config, config_path) |
| 4523 | print(f"✓ Copied config from {bundled_config} to {config_path}") | 4659 | logger.info(f"[BOOT] 已从 bundled 拷贝 config 到 {config_path}") |
| 4524 | except Exception as e: | ||
| 4525 | print(f"✗ Failed to copy bundled config: {e}") | ||
| 4526 | # If copy fails, try to use bundled config directly | ||
| 4527 | config_path = bundled_config | ||
| 4528 | else: | ||
| 4529 | print(f"✗ Bundled config not found at {bundled_config}") | ||
| 4530 | # Try to use current directory config as fallback | ||
| 4531 | current_dir_config = Path('.') / 'config.json' | ||
| 4532 | if current_dir_config.exists(): | ||
| 4533 | config_path = current_dir_config | ||
| 4534 | print(f"✓ Using current directory config: {config_path}") | ||
| 4535 | else: | ||
| 4536 | print(f"✗ No config file found at all") | ||
| 4537 | |||
| 4538 | db_config = None | ||
| 4539 | last_user = "" | ||
| 4540 | saved_password_hash = "" | ||
| 4541 | |||
| 4542 | if config_path.exists(): | ||
| 4543 | try: | ||
| 4544 | with open(config_path, 'r', encoding='utf-8') as f: | ||
| 4545 | config = json.load(f) | ||
| 4546 | db_config = config.get("db_config") | ||
| 4547 | last_user = config.get("last_user", "") | ||
| 4548 | saved_password_hash = config.get("saved_password_hash", "") | ||
| 4549 | logger.info(f"[BOOT] Phase 3 完成: 配置已加载, db_config={'有' if db_config else '无'}") | ||
| 4550 | except Exception as e: | 4660 | except Exception as e: |
| 4551 | logger.error(f"[BOOT] Phase 3 失败: 配置加载异常: {e}") | 4661 | logger.error(f"[BOOT] 从 bundled 拷贝 config 失败: {e}") |
| 4552 | print(f"Failed to load config: {e}") | ||
| 4553 | else: | ||
| 4554 | logger.warning(f"[BOOT] Phase 3: 配置文件不存在: {config_path}") | ||
| 4555 | 4662 | ||
| 4556 | # 第4步:创建 QApplication | 4663 | # 第4步:创建 QApplication(preflight 对话框需要) |
| 4557 | logger.info("[BOOT] Phase 4: 创建 QApplication...") | 4664 | logger.info("[BOOT] Phase 4: 创建 QApplication...") |
| 4558 | app = QApplication(sys.argv) | 4665 | app = QApplication(sys.argv) |
| 4559 | logger.info("[BOOT] Phase 4 完成: QApplication 已创建") | 4666 | logger.info("[BOOT] Phase 4 完成: QApplication 已创建") |
| 4560 | 4667 | ||
| 4668 | # 第 4.5 步:启动门禁 preflight | ||
| 4669 | # 任一检查失败 → 弹"应用启动失败,请联系 @柴进" → sys.exit(1) | ||
| 4670 | logger.info("[BOOT] Phase 4.5: 启动门禁 preflight...") | ||
| 4671 | from preflight import preflight_check, handle_preflight_failure | ||
| 4672 | from audit_logger import init_audit_logger | ||
| 4673 | |||
| 4674 | audit_queue_path = config_dir / 'audit_queue.ndjson' | ||
| 4675 | # 复用 init_logging 里决定的 logs_dir: | ||
| 4676 | # 以 logger 的第一个 RotatingFileHandler 的 baseFilename 的父目录为准 | ||
| 4677 | logs_dir = config_dir # 兜底 | ||
| 4678 | try: | ||
| 4679 | for h in logging.getLogger().handlers: | ||
| 4680 | base = getattr(h, 'baseFilename', None) | ||
| 4681 | if base: | ||
| 4682 | logs_dir = Path(base).parent | ||
| 4683 | break | ||
| 4684 | except Exception: | ||
| 4685 | pass | ||
| 4686 | |||
| 4687 | preflight_ok, preflight_err, loaded_config = preflight_check(config_path, audit_queue_path) | ||
| 4688 | if not preflight_ok: | ||
| 4689 | logger.error(f"[BOOT] preflight 失败: {preflight_err}") | ||
| 4690 | handle_preflight_failure(preflight_err, logs_dir) | ||
| 4691 | return # handle_preflight_failure 内部会 sys.exit(1) | ||
| 4692 | |||
| 4693 | # preflight 通过后,db_config 必定存在且可用 | ||
| 4694 | db_config = loaded_config["db_config"] | ||
| 4695 | last_user = loaded_config.get("last_user", "") | ||
| 4696 | saved_password_hash = loaded_config.get("saved_password_hash", "") | ||
| 4697 | logger.info("[BOOT] Phase 4.5 完成: preflight 通过,启动审计 logger...") | ||
| 4698 | |||
| 4699 | # 启动审计 logger 单例(后台 UploadWorker 开始跑) | ||
| 4700 | init_audit_logger(db_config, audit_queue_path, logs_dir) | ||
| 4701 | logger.info("[BOOT] audit logger 已初始化") | ||
| 4702 | |||
| 4703 | # 应用退出时 flush 审计队列 | ||
| 4704 | def _flush_audit_on_quit(): | ||
| 4705 | try: | ||
| 4706 | from audit_logger import get_audit_logger | ||
| 4707 | auditor = get_audit_logger() | ||
| 4708 | if auditor is not None: | ||
| 4709 | auditor.shutdown(timeout=5.0) | ||
| 4710 | except Exception as e: | ||
| 4711 | logger.error(f"退出时 flush 审计队列失败: {e}") | ||
| 4712 | app.aboutToQuit.connect(_flush_audit_on_quit) | ||
| 4713 | |||
| 4561 | # 第5步:设置应用图标 | 4714 | # 第5步:设置应用图标 |
| 4562 | logger.info("[BOOT] Phase 5: 设置应用图标...") | 4715 | logger.info("[BOOT] Phase 5: 设置应用图标...") |
| 4563 | # Set application icon | 4716 | # Set application icon |
| ... | @@ -4585,17 +4738,7 @@ def main(): | ... | @@ -4585,17 +4738,7 @@ def main(): |
| 4585 | else: | 4738 | else: |
| 4586 | logger.info(f"[BOOT] Phase 5: 跳过图标设置 (icon_path={icon_path})") | 4739 | logger.info(f"[BOOT] Phase 5: 跳过图标设置 (icon_path={icon_path})") |
| 4587 | 4740 | ||
| 4588 | # Check database config - if missing, start app without database authentication | 4741 | # preflight 保证 db_config 必定非空,无需再分支判断 |
| 4589 | if not db_config: | ||
| 4590 | logger.warning("[BOOT] 无数据库配置,跳过登录直接进入主窗口") | ||
| 4591 | print("警告:未找到数据库配置,将跳过数据库认证") | ||
| 4592 | # Create main window directly without login | ||
| 4593 | logger.info("[BOOT] Phase 6: 创建主窗口(无登录模式)...") | ||
| 4594 | main_window = ImageGeneratorWindow() | ||
| 4595 | logger.info("[BOOT] Phase 6 完成: 主窗口已创建") | ||
| 4596 | main_window.show() | ||
| 4597 | logger.info("[BOOT] Phase 7: 主窗口已显示,进入事件循环") | ||
| 4598 | sys.exit(app.exec()) | ||
| 4599 | 4742 | ||
| 4600 | # 第6步:显示登录对话框 | 4743 | # 第6步:显示登录对话框 |
| 4601 | logger.info("[BOOT] Phase 6: 创建登录对话框...") | 4744 | logger.info("[BOOT] Phase 6: 创建登录对话框...") | ... | ... |
| ... | @@ -66,6 +66,9 @@ class Task: | ... | @@ -66,6 +66,9 @@ class Task: |
| 66 | result_bytes: Optional[bytes] = None | 66 | result_bytes: Optional[bytes] = None |
| 67 | error_message: Optional[str] = None | 67 | error_message: Optional[str] = None |
| 68 | 68 | ||
| 69 | # 审计元信息 | ||
| 70 | finish_reason: Optional[str] = None | ||
| 71 | |||
| 69 | # UI 相关 | 72 | # UI 相关 |
| 70 | thumbnail: Optional[bytes] = None | 73 | thumbnail: Optional[bytes] = None |
| 71 | progress: float = 0.0 | 74 | progress: float = 0.0 |
| ... | @@ -106,59 +109,11 @@ class TaskQueueManager(QObject): | ... | @@ -106,59 +109,11 @@ class TaskQueueManager(QObject): |
| 106 | self._max_queue_size = 10 | 109 | self._max_queue_size = 10 |
| 107 | self._max_history_size = 10 # 只保留最近10条完成任务 | 110 | self._max_history_size = 10 # 只保留最近10条完成任务 |
| 108 | 111 | ||
| 109 | # 加载数据库配置用于日志记录 | 112 | # 审计日志走 audit_logger 单例,不再在此处加载 db_config |
| 110 | self._db_config = None | ||
| 111 | self._load_db_config() | ||
| 112 | 113 | ||
| 113 | self._initialized = True | 114 | self._initialized = True |
| 114 | self.logger.info("TaskQueueManager 初始化完成") | 115 | self.logger.info("TaskQueueManager 初始化完成") |
| 115 | 116 | ||
| 116 | def _load_db_config(self): | ||
| 117 | """加载数据库配置(与 image_generator.py 相同的多路径逻辑)""" | ||
| 118 | try: | ||
| 119 | import json | ||
| 120 | import sys | ||
| 121 | import os | ||
| 122 | import platform | ||
| 123 | from pathlib import Path | ||
| 124 | |||
| 125 | config_paths = [] | ||
| 126 | |||
| 127 | # 1. 用户配置目录(打包后优先) | ||
| 128 | if getattr(sys, 'frozen', False): | ||
| 129 | system = platform.system() | ||
| 130 | if system == 'Darwin': | ||
| 131 | user_config = Path.home() / 'Library' / 'Application Support' / 'ZB100ImageGenerator' / 'config.json' | ||
| 132 | elif system == 'Windows': | ||
| 133 | user_config = Path(os.getenv('APPDATA', Path.home())) / 'ZB100ImageGenerator' / 'config.json' | ||
| 134 | else: | ||
| 135 | user_config = Path.home() / '.config' / 'zb100imagegenerator' / 'config.json' | ||
| 136 | config_paths.append(user_config) | ||
| 137 | |||
| 138 | # 2. 可执行文件所在目录 | ||
| 139 | config_paths.append(Path(sys.executable).parent / 'config.json') | ||
| 140 | |||
| 141 | # 3. PyInstaller _MEIPASS 目录 | ||
| 142 | if hasattr(sys, '_MEIPASS'): | ||
| 143 | config_paths.append(Path(sys._MEIPASS) / 'config.json') | ||
| 144 | |||
| 145 | # 4. 当前工作目录(开发模式) | ||
| 146 | config_paths.append(Path('config.json')) | ||
| 147 | |||
| 148 | # 尝试所有路径 | ||
| 149 | for config_file in config_paths: | ||
| 150 | if config_file.exists(): | ||
| 151 | with open(config_file, 'r', encoding='utf-8') as f: | ||
| 152 | config = json.load(f) | ||
| 153 | self._db_config = config.get('db_config') | ||
| 154 | if self._db_config: | ||
| 155 | self.logger.info(f"数据库配置已加载: {config_file}") | ||
| 156 | return | ||
| 157 | |||
| 158 | self.logger.warning("未找到 config.json,日志记录将被禁用") | ||
| 159 | except Exception as e: | ||
| 160 | self.logger.warning(f"加载数据库配置失败: {e}") | ||
| 161 | |||
| 162 | def submit_task( | 117 | def submit_task( |
| 163 | self, | 118 | self, |
| 164 | task_type: TaskType, | 119 | task_type: TaskType, |
| ... | @@ -249,15 +204,18 @@ class TaskQueueManager(QObject): | ... | @@ -249,15 +204,18 @@ class TaskQueueManager(QObject): |
| 249 | task.model | 204 | task.model |
| 250 | ) | 205 | ) |
| 251 | 206 | ||
| 252 | # 绑定信号 | 207 | # 绑定信号;用 worker 局部引用捕获 finish_reason,避免后续 _current_worker 被替换 |
| 253 | self._current_worker.finished.connect( | 208 | worker_ref = self._current_worker |
| 209 | worker_ref.finished.connect( | ||
| 254 | lambda img_bytes, prompt, ref_imgs, ar, size, model: | 210 | lambda img_bytes, prompt, ref_imgs, ar, size, model: |
| 255 | self._on_task_completed(task_id, img_bytes, prompt, ref_imgs, ar, size, model) | 211 | self._on_task_completed(task_id, img_bytes, prompt, ref_imgs, ar, size, model, |
| 212 | getattr(worker_ref, 'finish_reason', None)) | ||
| 256 | ) | 213 | ) |
| 257 | self._current_worker.error.connect( | 214 | worker_ref.error.connect( |
| 258 | lambda error: self._on_task_failed(task_id, error) | 215 | lambda error: self._on_task_failed(task_id, error, |
| 216 | getattr(worker_ref, 'finish_reason', None)) | ||
| 259 | ) | 217 | ) |
| 260 | self._current_worker.progress.connect( | 218 | worker_ref.progress.connect( |
| 261 | lambda status: self.task_progress.emit(task_id, 0.5, status) | 219 | lambda status: self.task_progress.emit(task_id, 0.5, status) |
| 262 | ) | 220 | ) |
| 263 | 221 | ||
| ... | @@ -265,16 +223,23 @@ class TaskQueueManager(QObject): | ... | @@ -265,16 +223,23 @@ class TaskQueueManager(QObject): |
| 265 | self._current_worker.start() | 223 | self._current_worker.start() |
| 266 | 224 | ||
| 267 | def _on_task_completed(self, task_id: str, image_bytes: bytes, prompt: str, | 225 | def _on_task_completed(self, task_id: str, image_bytes: bytes, prompt: str, |
| 268 | reference_images: list, aspect_ratio: str, image_size: str, model: str): | 226 | reference_images: list, aspect_ratio: str, image_size: str, model: str, |
| 227 | finish_reason: Optional[str] = None): | ||
| 269 | """任务完成回调""" | 228 | """任务完成回调""" |
| 270 | task = self._tasks.get(task_id) | 229 | task = self._tasks.get(task_id) |
| 271 | if not task: | 230 | if not task: |
| 272 | self.logger.error(f"任务 {task_id[:8]} 不存在") | 231 | self.logger.error(f"任务 {task_id[:8]} 不存在") |
| 273 | return | 232 | return |
| 274 | 233 | ||
| 234 | # 软取消: 用户在运行期间取消了该任务,丢弃这次回调结果(worker 已在别处被替换,下一个任务已启动) | ||
| 235 | if task.status == TaskStatus.CANCELLED: | ||
| 236 | self.logger.info(f"任务 {task_id[:8]} 已被取消,丢弃完成回调") | ||
| 237 | return | ||
| 238 | |||
| 275 | task.status = TaskStatus.COMPLETED | 239 | task.status = TaskStatus.COMPLETED |
| 276 | task.completed_at = datetime.now() | 240 | task.completed_at = datetime.now() |
| 277 | task.result_bytes = image_bytes | 241 | task.result_bytes = image_bytes |
| 242 | task.finish_reason = finish_reason | ||
| 278 | 243 | ||
| 279 | # 生成缩略图 | 244 | # 生成缩略图 |
| 280 | try: | 245 | try: |
| ... | @@ -283,7 +248,7 @@ class TaskQueueManager(QObject): | ... | @@ -283,7 +248,7 @@ class TaskQueueManager(QObject): |
| 283 | self.logger.warning(f"生成缩略图失败: {e}") | 248 | self.logger.warning(f"生成缩略图失败: {e}") |
| 284 | 249 | ||
| 285 | elapsed = (task.completed_at - task.started_at).total_seconds() | 250 | elapsed = (task.completed_at - task.started_at).total_seconds() |
| 286 | self.logger.info(f"任务完成: {task_id[:8]} - 耗时 {elapsed:.1f}s") | 251 | self.logger.info(f"任务完成: {task_id[:8]} - 耗时 {elapsed:.1f}s, finish_reason={finish_reason}") |
| 287 | 252 | ||
| 288 | # 记录使用日志 | 253 | # 记录使用日志 |
| 289 | self._log_usage(task_id, 'success', 'memory', None) | 254 | self._log_usage(task_id, 'success', 'memory', None) |
| ... | @@ -297,18 +262,24 @@ class TaskQueueManager(QObject): | ... | @@ -297,18 +262,24 @@ class TaskQueueManager(QObject): |
| 297 | # 处理下一个任务 | 262 | # 处理下一个任务 |
| 298 | self._process_next() | 263 | self._process_next() |
| 299 | 264 | ||
| 300 | def _on_task_failed(self, task_id: str, error: str): | 265 | def _on_task_failed(self, task_id: str, error: str, finish_reason: Optional[str] = None): |
| 301 | """任务失败回调""" | 266 | """任务失败回调""" |
| 302 | task = self._tasks.get(task_id) | 267 | task = self._tasks.get(task_id) |
| 303 | if not task: | 268 | if not task: |
| 304 | self.logger.error(f"任务 {task_id[:8]} 不存在") | 269 | self.logger.error(f"任务 {task_id[:8]} 不存在") |
| 305 | return | 270 | return |
| 306 | 271 | ||
| 272 | # 软取消: 用户取消后任务实际报错返回也走这里,直接丢弃 | ||
| 273 | if task.status == TaskStatus.CANCELLED: | ||
| 274 | self.logger.info(f"任务 {task_id[:8]} 已被取消,丢弃失败回调") | ||
| 275 | return | ||
| 276 | |||
| 307 | task.status = TaskStatus.FAILED | 277 | task.status = TaskStatus.FAILED |
| 308 | task.completed_at = datetime.now() | 278 | task.completed_at = datetime.now() |
| 309 | task.error_message = error | 279 | task.error_message = error |
| 280 | task.finish_reason = finish_reason | ||
| 310 | 281 | ||
| 311 | self.logger.error(f"任务失败: {task_id[:8]} - {error}") | 282 | self.logger.error(f"任务失败: {task_id[:8]} - {error}, finish_reason={finish_reason}") |
| 312 | 283 | ||
| 313 | # 记录使用日志 | 284 | # 记录使用日志 |
| 314 | self._log_usage(task_id, 'failure', None, error) | 285 | self._log_usage(task_id, 'failure', None, error) |
| ... | @@ -326,7 +297,7 @@ class TaskQueueManager(QObject): | ... | @@ -326,7 +297,7 @@ class TaskQueueManager(QObject): |
| 326 | # 获取所有已完成和失败的任务,按完成时间排序 | 297 | # 获取所有已完成和失败的任务,按完成时间排序 |
| 327 | finished_tasks = [ | 298 | finished_tasks = [ |
| 328 | t for t in self._tasks.values() | 299 | t for t in self._tasks.values() |
| 329 | if t.status in [TaskStatus.COMPLETED, TaskStatus.FAILED] and t.completed_at | 300 | if t.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED] and t.completed_at |
| 330 | ] | 301 | ] |
| 331 | finished_tasks.sort(key=lambda t: t.completed_at, reverse=True) | 302 | finished_tasks.sort(key=lambda t: t.completed_at, reverse=True) |
| 332 | 303 | ||
| ... | @@ -372,11 +343,20 @@ class TaskQueueManager(QObject): | ... | @@ -372,11 +343,20 @@ class TaskQueueManager(QObject): |
| 372 | 343 | ||
| 373 | def cancel_task(self, task_id: str): | 344 | def cancel_task(self, task_id: str): |
| 374 | """ | 345 | """ |
| 375 | 取消任务 (仅等待中任务) | 346 | 取消任务 - 支持 PENDING 和 RUNNING 两种状态 |
| 376 | 将任务状态设为 CANCELLED 并从队列中移除 | 347 | |
| 348 | - PENDING: 从队列里移除,状态置 CANCELLED | ||
| 349 | - RUNNING: 软取消。标记 CANCELLED,脱钩 _current_worker,立刻启动下一个任务。 | ||
| 350 | 被丢弃的 worker 线程在后台跑完后,回调在 _on_task_completed / _on_task_failed | ||
| 351 | 入口处通过 status == CANCELLED 自检被丢弃(不显示结果、不记审计日志)。 | ||
| 352 | API 调用费已经发出,无法退回。 | ||
| 377 | """ | 353 | """ |
| 378 | task = self._tasks.get(task_id) | 354 | task = self._tasks.get(task_id) |
| 379 | if task and task.status == TaskStatus.PENDING: | 355 | if not task: |
| 356 | self.logger.warning(f"取消任务失败: {task_id[:8]} 不存在") | ||
| 357 | return | ||
| 358 | |||
| 359 | if task.status == TaskStatus.PENDING: | ||
| 380 | task.status = TaskStatus.CANCELLED | 360 | task.status = TaskStatus.CANCELLED |
| 381 | task.completed_at = datetime.now() | 361 | task.completed_at = datetime.now() |
| 382 | 362 | ||
| ... | @@ -386,80 +366,68 @@ class TaskQueueManager(QObject): | ... | @@ -386,80 +366,68 @@ class TaskQueueManager(QObject): |
| 386 | tid = self._queue.get() | 366 | tid = self._queue.get() |
| 387 | if tid != task_id: | 367 | if tid != task_id: |
| 388 | temp_queue.put(tid) | 368 | temp_queue.put(tid) |
| 389 | |||
| 390 | # 替换原队列 | ||
| 391 | self._queue = temp_queue | 369 | self._queue = temp_queue |
| 392 | 370 | ||
| 393 | self.logger.info(f"任务已取消: {task_id[:8]}") | 371 | self.logger.info(f"任务已取消(等待中): {task_id[:8]}") |
| 394 | self.task_failed.emit(task_id, "用户取消") # 发送取消信号以更新 UI | 372 | self.task_failed.emit(task_id, "用户取消") |
| 373 | |||
| 374 | elif task.status == TaskStatus.RUNNING: | ||
| 375 | task.status = TaskStatus.CANCELLED | ||
| 376 | task.completed_at = datetime.now() | ||
| 377 | |||
| 378 | # 脱钩当前 worker。worker 线程仍在后台跑(Gemini API 同步阻塞无法中止), | ||
| 379 | # 但它的 finished/error 信号回调会在入口处因为 status == CANCELLED 而丢弃。 | ||
| 380 | self._current_worker = None | ||
| 381 | |||
| 382 | self.logger.info(f"任务已取消(运行中): {task_id[:8]} - worker 后台继续,结果将丢弃") | ||
| 383 | self.task_failed.emit(task_id, "用户取消") | ||
| 384 | |||
| 385 | # 立刻启动下一个任务,不等后台那个废 worker | ||
| 386 | self._process_next() | ||
| 387 | else: | ||
| 388 | self.logger.info(f"任务 {task_id[:8]} 状态为 {task.status.value},无需取消") | ||
| 395 | 389 | ||
| 396 | def _log_usage(self, task_id: str, status: str, result_path: Optional[str], error_message: Optional[str]): | 390 | def _log_usage(self, task_id: str, status: str, result_path: Optional[str], error_message: Optional[str]): |
| 397 | """ | 391 | """ |
| 398 | 记录用户使用日志到数据库 | 392 | 记录用户使用日志 -> 审计队列(本地落盘 + 后台异步上传到 MySQL)。 |
| 399 | 393 | ||
| 400 | Args: | 394 | 关键变化:原先直接 INSERT,有多条静默失败路径;现在统一走 audit_logger 的 |
| 401 | task_id: 任务ID | 395 | 本地 NDJSON 队列,fsync 后返回。数据库层面的故障在后台 worker 重试, |
| 402 | status: 'success' 或 'failure' | 396 | 不再影响主流程、也不会让事件丢失。 |
| 403 | result_path: 成功时的图片路径,失败时为 None | ||
| 404 | error_message: 失败时的错误信息,成功时为 None | ||
| 405 | """ | 397 | """ |
| 406 | if not self._db_config: | ||
| 407 | self.logger.debug("数据库配置未加载,跳过日志记录") | ||
| 408 | return | ||
| 409 | |||
| 410 | task = self._tasks.get(task_id) | 398 | task = self._tasks.get(task_id) |
| 411 | if not task: | 399 | if not task: |
| 412 | self.logger.warning(f"任务 {task_id[:8]} 不存在,无法记录日志") | 400 | self.logger.warning(f"任务 {task_id[:8]} 不存在,无法记录日志") |
| 413 | return | 401 | return |
| 414 | 402 | ||
| 403 | # duration_ms: 从任务开始到现在 | ||
| 404 | duration_ms: Optional[int] = None | ||
| 405 | if task.started_at and task.completed_at: | ||
| 406 | duration_ms = int((task.completed_at - task.started_at).total_seconds() * 1000) | ||
| 407 | |||
| 415 | try: | 408 | try: |
| 416 | import pymysql | 409 | from audit_logger import get_audit_logger |
| 417 | 410 | auditor = get_audit_logger() | |
| 418 | # 处理未登录用户 | 411 | if auditor is None: |
| 419 | user_name = task.user_name if task.user_name else "未知用户" | 412 | # preflight 通过后单例应当已初始化;防御性日志 |
| 420 | device_name = task.device_name if task.device_name else "未知设备" | 413 | self.logger.error( |
| 421 | 414 | "audit logger 未初始化,任务日志无法记录 (task=%s)", task_id[:8] | |
| 422 | # 连接数据库 | ||
| 423 | connection = pymysql.connect( | ||
| 424 | host=self._db_config['host'], | ||
| 425 | port=self._db_config['port'], | ||
| 426 | user=self._db_config['user'], | ||
| 427 | password=self._db_config['password'], | ||
| 428 | database=self._db_config['database'] | ||
| 429 | ) | 415 | ) |
| 430 | 416 | return | |
| 431 | with connection.cursor() as cursor: | 417 | auditor.log_use( |
| 432 | sql = """ | 418 | user_name=task.user_name or "未知用户", |
| 433 | INSERT INTO `nano_banana_user_use_log` | 419 | device_name=task.device_name or "未知设备", |
| 434 | (`user_name`, `device_name`, `prompt`, `result_path`, `status`, `error_message`) | 420 | prompt=task.prompt, |
| 435 | VALUES (%s, %s, %s, %s, %s, %s) | 421 | result_path=result_path, |
| 436 | """ | 422 | status=status, |
| 437 | cursor.execute(sql, ( | 423 | error_message=error_message, |
| 438 | user_name, | 424 | model=task.model, |
| 439 | device_name, | 425 | duration_ms=duration_ms, |
| 440 | task.prompt, | 426 | finish_reason=task.finish_reason, |
| 441 | result_path, | 427 | ) |
| 442 | status, | 428 | self.logger.info(f"使用日志已入队: {task_id[:8]} - {status}") |
| 443 | error_message | ||
| 444 | )) | ||
| 445 | |||
| 446 | connection.commit() | ||
| 447 | connection.close() | ||
| 448 | |||
| 449 | self.logger.info(f"使用日志已记录: {task_id[:8]} - {status}") | ||
| 450 | |||
| 451 | except Exception as e: | 429 | except Exception as e: |
| 452 | # 日志记录失败不应影响主流程 | 430 | self.logger.error(f"使用日志入队失败: {e}", exc_info=True) |
| 453 | self.logger.error(f"记录使用日志失败: {e}", exc_info=True) | ||
| 454 | |||
| 455 | # 在Mac打包版本中输出到控制台进行调试 | ||
| 456 | import sys | ||
| 457 | import platform | ||
| 458 | if getattr(sys, 'frozen', False) and platform.system() == 'Darwin': | ||
| 459 | print(f"DEBUG - 数据库记录失败: {e}") | ||
| 460 | print(f"DEBUG - 用户名: {user_name}") | ||
| 461 | print(f"DEBUG - 设备名: {device_name}") | ||
| 462 | print(f"DEBUG - 提示词: {task.prompt[:50]}...") | ||
| 463 | 431 | ||
| 464 | 432 | ||
| 465 | class TaskQueueWidget(QWidget): | 433 | class TaskQueueWidget(QWidget): |
| ... | @@ -488,7 +456,7 @@ class TaskQueueWidget(QWidget): | ... | @@ -488,7 +456,7 @@ class TaskQueueWidget(QWidget): |
| 488 | title = QLabel("任务队列") | 456 | title = QLabel("任务队列") |
| 489 | title.setStyleSheet("QLabel { font-weight: bold; font-size: 10px; color: #666; }") | 457 | title.setStyleSheet("QLabel { font-weight: bold; font-size: 10px; color: #666; }") |
| 490 | title.setAlignment(Qt.AlignCenter) | 458 | title.setAlignment(Qt.AlignCenter) |
| 491 | title.setToolTip("鼠标悬停查看详情\n右键等待中的任务可取消") | 459 | title.setToolTip("鼠标悬停查看详情\n右键等待中或运行中的任务可取消") |
| 492 | layout.addWidget(title) | 460 | layout.addWidget(title) |
| 493 | 461 | ||
| 494 | # 分隔线 | 462 | # 分隔线 |
| ... | @@ -569,6 +537,9 @@ class TaskQueueWidget(QWidget): | ... | @@ -569,6 +537,9 @@ class TaskQueueWidget(QWidget): |
| 569 | elif task.status == TaskStatus.FAILED: | 537 | elif task.status == TaskStatus.FAILED: |
| 570 | status_text = "失败" | 538 | status_text = "失败" |
| 571 | color = "#FF3B30" # 红色 | 539 | color = "#FF3B30" # 红色 |
| 540 | elif task.status == TaskStatus.CANCELLED: | ||
| 541 | status_text = "已取消" | ||
| 542 | color = "#8E8E93" # 中性灰 | ||
| 572 | else: | 543 | else: |
| 573 | status_text = "未知" | 544 | status_text = "未知" |
| 574 | color = "#666666" # 灰色 | 545 | color = "#666666" # 灰色 |
| ... | @@ -616,10 +587,11 @@ class TaskQueueWidget(QWidget): | ... | @@ -616,10 +587,11 @@ class TaskQueueWidget(QWidget): |
| 616 | if not task: | 587 | if not task: |
| 617 | return | 588 | return |
| 618 | 589 | ||
| 619 | # 仅为 PENDING 状态的任务显示取消选项 | 590 | # PENDING 和 RUNNING 均可取消 |
| 620 | if task.status == TaskStatus.PENDING: | 591 | if task.status in (TaskStatus.PENDING, TaskStatus.RUNNING): |
| 621 | menu = QMenu() | 592 | menu = QMenu() |
| 622 | cancel_action = menu.addAction("取消任务") | 593 | label = "取消任务" if task.status == TaskStatus.PENDING else "取消任务(运行中)" |
| 594 | cancel_action = menu.addAction(label) | ||
| 623 | 595 | ||
| 624 | action = menu.exec_(self.task_list.mapToGlobal(position)) | 596 | action = menu.exec_(self.task_list.mapToGlobal(position)) |
| 625 | 597 | ||
| ... | @@ -632,7 +604,7 @@ class TaskQueueWidget(QWidget): | ... | @@ -632,7 +604,7 @@ class TaskQueueWidget(QWidget): |
| 632 | self._update_summary() | 604 | self._update_summary() |
| 633 | 605 | ||
| 634 | def _on_task_item_clicked(self, item: QListWidgetItem): | 606 | def _on_task_item_clicked(self, item: QListWidgetItem): |
| 635 | """点击任务项 - 回填数据到主窗口""" | 607 | """点击任务项 - 回填数据到主窗口 (prompt/参考图/设置 + 如果完成则回显结果图)""" |
| 636 | task_id = item.data(Qt.UserRole) | 608 | task_id = item.data(Qt.UserRole) |
| 637 | if not task_id: | 609 | if not task_id: |
| 638 | return | 610 | return |
| ... | @@ -641,76 +613,83 @@ class TaskQueueWidget(QWidget): | ... | @@ -641,76 +613,83 @@ class TaskQueueWidget(QWidget): |
| 641 | if not task or not self.parent_window: | 613 | if not task or not self.parent_window: |
| 642 | return | 614 | return |
| 643 | 615 | ||
| 644 | # 切换到对应的标签页 | 616 | # 先切 tab + 回填参数 + 回填结果 |
| 645 | if task.type == TaskType.STYLE_DESIGN: | ||
| 646 | self.parent_window.tab_widget.setCurrentIndex(1) # 款式设计标签 | ||
| 647 | style_tab = self.parent_window.tab_widget.currentWidget() | ||
| 648 | else: | ||
| 649 | self.parent_window.tab_widget.setCurrentIndex(0) # 图片生成标签 | ||
| 650 | gen_tab = self.parent_window.tab_widget.currentWidget() | ||
| 651 | |||
| 652 | # 如果是已完成任务,直接在主窗口显示结果 | ||
| 653 | if task.status == TaskStatus.COMPLETED and task.result_bytes: | ||
| 654 | if task.type == TaskType.STYLE_DESIGN and hasattr(style_tab, '_display_generated_image_from_bytes'): | ||
| 655 | # 款式设计:将图片数据存储到样式标签并显示 | ||
| 656 | style_tab.generated_image_bytes = task.result_bytes | ||
| 657 | style_tab._display_generated_image_from_bytes() | ||
| 658 | elif hasattr(gen_tab, '_display_generated_image_from_bytes'): | ||
| 659 | # 图片生成:将图片数据存储到主窗口并显示 | ||
| 660 | self.parent_window.generated_image_bytes = task.result_bytes | ||
| 661 | gen_tab._display_generated_image_from_bytes() | ||
| 662 | |||
| 663 | # 回填参数到主窗口 | ||
| 664 | self._load_task_to_main_window(task) | 617 | self._load_task_to_main_window(task) |
| 665 | 618 | ||
| 666 | def _load_task_to_main_window(self, task: Task): | 619 | def _load_task_to_main_window(self, task: Task): |
| 667 | """将任务数据回填到主窗口""" | 620 | """ |
| 621 | 回填任务到左侧执行区。 | ||
| 622 | 数据来源: Task 对象本身 (prompt、reference_images、aspect_ratio、image_size)。 | ||
| 623 | 目标: | ||
| 624 | - 款式设计 Tab: StyleDesignerTab 实例 (prompt_preview / aspect_ratio / image_size / _display_generated_image_from_bytes) | ||
| 625 | - 图片生成 Tab: ImageGeneratorWindow 主窗口本身 | ||
| 626 | (prompt_text / uploaded_images + update_image_preview / aspect_ratio / image_size / display_image) | ||
| 627 | """ | ||
| 628 | main_window = self.parent_window | ||
| 629 | if main_window is None: | ||
| 630 | return | ||
| 631 | |||
| 668 | try: | 632 | try: |
| 669 | if task.type == TaskType.STYLE_DESIGN: | 633 | if task.type == TaskType.STYLE_DESIGN: |
| 670 | # 款式设计标签页 - 回填prompt到预览框 | 634 | main_window.tab_widget.setCurrentIndex(1) |
| 671 | self.parent_window.tab_widget.setCurrentIndex(1) # 款式设计标签 | 635 | style_tab = main_window.tab_widget.currentWidget() |
| 672 | style_tab = self.parent_window.tab_widget.currentWidget() | ||
| 673 | 636 | ||
| 674 | # 回填prompt到预览框 | 637 | # 款式设计: prompt 预览 + 设置 + 结果图 |
| 675 | if hasattr(style_tab, 'prompt_preview'): | 638 | if hasattr(style_tab, 'prompt_preview') and task.prompt: |
| 676 | style_tab.prompt_preview.setPlainText(task.prompt) | 639 | style_tab.prompt_preview.setPlainText(task.prompt) |
| 677 | 640 | ||
| 678 | # 回填设置 | ||
| 679 | if hasattr(style_tab, 'aspect_ratio') and task.aspect_ratio: | 641 | if hasattr(style_tab, 'aspect_ratio') and task.aspect_ratio: |
| 680 | index = style_tab.aspect_ratio.findText(task.aspect_ratio) | 642 | idx = style_tab.aspect_ratio.findText(task.aspect_ratio) |
| 681 | if index >= 0: | 643 | if idx >= 0: |
| 682 | style_tab.aspect_ratio.setCurrentIndex(index) | 644 | style_tab.aspect_ratio.setCurrentIndex(idx) |
| 645 | |||
| 683 | if hasattr(style_tab, 'image_size') and task.image_size: | 646 | if hasattr(style_tab, 'image_size') and task.image_size: |
| 684 | index = style_tab.image_size.findText(task.image_size) | 647 | idx = style_tab.image_size.findText(task.image_size) |
| 685 | if index >= 0: | 648 | if idx >= 0: |
| 686 | style_tab.image_size.setCurrentIndex(index) | 649 | style_tab.image_size.setCurrentIndex(idx) |
| 650 | |||
| 651 | # 已完成任务: 回显生成结果图 | ||
| 652 | if (task.status == TaskStatus.COMPLETED and task.result_bytes | ||
| 653 | and hasattr(style_tab, '_display_generated_image_from_bytes')): | ||
| 654 | style_tab.generated_image_bytes = task.result_bytes | ||
| 655 | style_tab._display_generated_image_from_bytes() | ||
| 687 | 656 | ||
| 688 | else: | 657 | else: |
| 689 | # 图片生成标签页 | 658 | # 图片生成: 所有控件都挂在主窗口上 (generation_tab 只是容器) |
| 690 | self.parent_window.tab_widget.setCurrentIndex(0) # 图片生成标签 | 659 | main_window.tab_widget.setCurrentIndex(0) |
| 691 | gen_tab = self.parent_window.tab_widget.currentWidget() | 660 | |
| 692 | 661 | if hasattr(main_window, 'prompt_text') and task.prompt: | |
| 693 | # 回填prompt | 662 | main_window.prompt_text.setPlainText(task.prompt) |
| 694 | if hasattr(gen_tab, 'prompt_text'): | 663 | |
| 695 | gen_tab.prompt_text.setPlainText(task.prompt) | 664 | # 参考图: 直接覆盖 uploaded_images + 刷缩略图 + 刷计数 |
| 696 | 665 | if hasattr(main_window, 'uploaded_images'): | |
| 697 | # 回填参考图片 | 666 | # 只保留仍存在于磁盘的路径,避免旧任务引用已删除文件 |
| 698 | if task.reference_images and hasattr(gen_tab, 'add_reference_image'): | 667 | import os |
| 699 | for ref_path in task.reference_images: | 668 | valid_paths = [p for p in (task.reference_images or []) if p and os.path.exists(p)] |
| 700 | gen_tab.add_reference_image(ref_path) | 669 | main_window.uploaded_images = list(valid_paths) |
| 701 | 670 | if hasattr(main_window, 'update_image_preview'): | |
| 702 | # 回填设置 | 671 | main_window.update_image_preview() |
| 703 | if hasattr(gen_tab, 'aspect_ratio') and task.aspect_ratio: | 672 | if hasattr(main_window, 'image_count_label'): |
| 704 | index = gen_tab.aspect_ratio.findText(task.aspect_ratio) | 673 | main_window.image_count_label.setText(f"已选择 {len(valid_paths)} 张") |
| 705 | if index >= 0: | 674 | |
| 706 | gen_tab.aspect_ratio.setCurrentIndex(index) | 675 | if hasattr(main_window, 'aspect_ratio') and task.aspect_ratio: |
| 707 | if hasattr(gen_tab, 'image_size') and task.image_size: | 676 | idx = main_window.aspect_ratio.findText(task.aspect_ratio) |
| 708 | index = gen_tab.image_size.findText(task.image_size) | 677 | if idx >= 0: |
| 709 | if index >= 0: | 678 | main_window.aspect_ratio.setCurrentIndex(idx) |
| 710 | gen_tab.image_size.setCurrentIndex(index) | 679 | |
| 680 | if hasattr(main_window, 'image_size') and task.image_size: | ||
| 681 | idx = main_window.image_size.findText(task.image_size) | ||
| 682 | if idx >= 0: | ||
| 683 | main_window.image_size.setCurrentIndex(idx) | ||
| 684 | |||
| 685 | # 已完成任务: 回显生成结果图 | ||
| 686 | if (task.status == TaskStatus.COMPLETED and task.result_bytes | ||
| 687 | and hasattr(main_window, 'display_image')): | ||
| 688 | main_window.generated_image_bytes = task.result_bytes | ||
| 689 | main_window.display_image() | ||
| 711 | 690 | ||
| 712 | except Exception as e: | 691 | except Exception as e: |
| 713 | self.logger.error(f"回填数据到主窗口失败: {e}") | 692 | self.logger.error(f"回填数据到主窗口失败: {e}", exc_info=True) |
| 714 | 693 | ||
| 715 | def _on_task_started(self, task_id: str): | 694 | def _on_task_started(self, task_id: str): |
| 716 | """任务开始回调""" | 695 | """任务开始回调""" |
| ... | @@ -789,69 +768,3 @@ class TaskQueueWidget(QWidget): | ... | @@ -789,69 +768,3 @@ class TaskQueueWidget(QWidget): |
| 789 | # 侧边栏模式下进度信息通过状态标签显示 | 768 | # 侧边栏模式下进度信息通过状态标签显示 |
| 790 | # 不需要额外更新,由_update_summary统一处理 | 769 | # 不需要额外更新,由_update_summary统一处理 |
| 791 | pass | 770 | pass |
| 792 | |||
| 793 | |||
| 794 | def _on_task_item_clicked(self, item: QListWidgetItem): | ||
| 795 | """单击任务 - 回填数据到主窗口或显示结果""" | ||
| 796 | task_id = item.data(Qt.UserRole) | ||
| 797 | task = self.manager.get_task(task_id) | ||
| 798 | |||
| 799 | if not task or not self.parent_window: | ||
| 800 | return | ||
| 801 | |||
| 802 | # 切换到对应的标签页 | ||
| 803 | if task.type == TaskType.STYLE_DESIGN: | ||
| 804 | self.parent_window.tab_widget.setCurrentIndex(1) # 款式设计标签 | ||
| 805 | style_tab = self.parent_window.tab_widget.currentWidget() | ||
| 806 | else: | ||
| 807 | self.parent_window.tab_widget.setCurrentIndex(0) # 图片生成标签 | ||
| 808 | gen_tab = self.parent_window.tab_widget.currentWidget() | ||
| 809 | |||
| 810 | # 如果是已完成任务,直接在主窗口显示结果 | ||
| 811 | if task.status == TaskStatus.COMPLETED and task.result_bytes: | ||
| 812 | self.parent_window.generated_image_bytes = task.result_bytes | ||
| 813 | # 显示生成的图片 | ||
| 814 | if hasattr(self.parent_window, 'display_generated_image'): | ||
| 815 | self.parent_window.display_generated_image() | ||
| 816 | elif task.type == TaskType.STYLE_DESIGN and hasattr(style_tab, '_display_generated_image_from_bytes'): | ||
| 817 | style_tab._display_generated_image_from_bytes() | ||
| 818 | elif hasattr(gen_tab, '_display_generated_image_from_bytes'): | ||
| 819 | gen_tab._display_generated_image_from_bytes() | ||
| 820 | |||
| 821 | # 回填参数 | ||
| 822 | self._load_task_to_main_window(task) | ||
| 823 | |||
| 824 | def _load_task_to_main_window(self, task: Task): | ||
| 825 | """将任务数据回填到主窗口""" | ||
| 826 | try: | ||
| 827 | if task.type == TaskType.STYLE_DESIGN: | ||
| 828 | # 切换到款式设计标签 | ||
| 829 | self.parent_window.tab_widget.setCurrentIndex(1) | ||
| 830 | style_tab = self.parent_window.tab_widget.currentWidget() | ||
| 831 | if hasattr(style_tab, 'library_manager'): | ||
| 832 | # 款式设计不需要回填prompt,因为是参数组合 | ||
| 833 | pass | ||
| 834 | else: | ||
| 835 | # 切换到图片生成标签 | ||
| 836 | self.parent_window.tab_widget.setCurrentIndex(0) | ||
| 837 | gen_tab = self.parent_window.tab_widget.currentWidget() | ||
| 838 | if hasattr(gen_tab, 'prompt_input'): | ||
| 839 | # 回填prompt | ||
| 840 | gen_tab.prompt_input.setPlainText(task.prompt) | ||
| 841 | # 回填参考图片 | ||
| 842 | if task.reference_images: | ||
| 843 | for ref_path in task.reference_images: | ||
| 844 | if hasattr(gen_tab, 'add_reference_image'): | ||
| 845 | gen_tab.add_reference_image(ref_path) | ||
| 846 | # 回填设置 | ||
| 847 | if hasattr(gen_tab, 'aspect_ratio') and task.aspect_ratio: | ||
| 848 | index = gen_tab.aspect_ratio.findText(task.aspect_ratio) | ||
| 849 | if index >= 0: | ||
| 850 | gen_tab.aspect_ratio.setCurrentIndex(index) | ||
| 851 | if hasattr(gen_tab, 'image_size') and task.image_size: | ||
| 852 | index = gen_tab.image_size.findText(task.image_size) | ||
| 853 | if index >= 0: | ||
| 854 | gen_tab.image_size.setCurrentIndex(index) | ||
| 855 | |||
| 856 | except Exception as e: | ||
| 857 | self.logger.error(f"回填数据到主窗口失败: {e}") | ... | ... |
-
Please register or sign in to post a comment