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 ...@@ -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}")
......