fc3cf071 by 柴进

feat: 系统化补齐老 image_generator.py 遗漏的逻辑 + 完整 logtrace

# 启动序列(与旧 main() 1:1 对齐)

新增 core/runtime.py 抽 6 个启动期工具:
  - flush_logs / cleanup_clipboard_tempfiles
  - get_crash_log_path / enable_crash_diagnostics
  - log_system_info / init_logging(RotatingFileHandler 5MB×5)

main_qml.py 完整 8-phase boot:
  Phase 0 enable_crash_diagnostics(faulthandler / excepthook / Qt msgHandler)
  Phase 1 init_logging → logs/app.log
  Phase 2 log_system_info(OS/Python/PySide6/Qt/Pillow/google-genai 版本)
  Phase 2.5 cleanup_clipboard_tempfiles(24h+ 旧文件)
  Phase 3 config 路径 + frozen 时从 bundle 拷贝 + sync_bundled_api_key
  Phase 4 QGuiApplication + 窗口图标(zb100_windows.ico / zb100_mac.icns)
  Phase 4.5 preflight_check(失败弹错退出,不让用户进登录页才撞错)
  Phase 5 业务核心 + Phase 5.5 init_audit_logger 单例
  Phase 6 桥层 + 信号串联
  Phase 7 QML 装载
  Phase 8 aboutToQuit hook → audit shutdown flush

# 完整 logtrace(异常带 stack 落到 app.log)

bridges/* + core/* 所有 try/except 统一改:
  logger.error(f"...: {e}") → logger.exception("...")
  logger.warning(f"...: {e}") → logger.exception("...")
共 24 处。Qt 警告 / fatal / segfault 也通过 enable_crash_diagnostics
落到 app.log 和 crash_log.txt。

# 桥层补齐遗漏的功能(对照旧 ImageGeneratorWindow / LoginDialog)

ImageGenBridge:
  - validateImageFile/validateImageFiles:扩展名 + ≤10MB + QPixmap 完整性,
    与旧 validate_image_file 等价;normalizeFileUrls 内置同样校验
  - toggleSavedPrompt / isSavedPrompt:收藏切换(已收藏点了取消,与旧
    toggle_favorite + check_favorite_status 等价)

HistoryBridge:
  - clearAll:shutil.rmtree(base_path) + 重建空目录 + reset model
    对应旧 clear_history

AuthBridge:
  - _get_public_ip:登录后同步拿公网 IP(3 个 API 兜底各 3s timeout),
    audit log_login 现在带 public_ip,对应旧 get_public_ip

# QML wiring

ImageGenTab:
  - 收藏按钮: :star: 收藏 / ✓ 已收藏 toggle,根据 promptArea.text + savedPrompts 实时切
  - addUrlsValidated 统一"加 + 校验 + 反馈":用户拖 5 张图 3 张超 10MB 时
    显示"已添加 2 张,丢弃 3 张(不支持 / 超 10MB / 损坏)"
  - 粘贴也走 validateImageFiles,路径 A 拿用户文件时拦截超大

HistoryTab:
  - 顶部加 :wastebasket:️ 清空 按钮(带 MessageDialog 确认)
  - 列表项右键菜单(QtQuick.Dialogs Menu):在文件管理器中打开 / 复制提示词 / 删除此项
  - 单条删除也走 MessageDialog 确认

# 验证

启动序列 app.log 完整:
  Phase 0..8 全部正确执行,窗口图标已设,audit logger 启动,QML 装载成功。
  系统信息日志记录 PySide6 6.10.1 / Qt 6.10.1 / Pillow 11.1.0 / google-genai 1.52.0。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2e9bf50f
1 """AuthBridge — 登录认证 + 当前用户。 1 """AuthBridge — 登录认证 + 当前用户 + 记住凭据
2 2
3 替代旧 LoginDialog。QML LoginScreen 调 auth.login(user, pwd),桥内部走 3 替代旧 LoginDialog。QML LoginScreen 调 auth.login(user, pwd)(首次输入)
4 DatabaseManager.authenticate(同步 MySQL,5s timeout)+ audit_logger.log_login。 4 或 auth.loginWithSavedPassword(user)("记住密码"路径,跳过 hash 直查 db)。
5 登录成功后 QML 调 auth.saveCredentials(...) 写回 config.json。
5 6
6 PoC 模式(db_config = None):接受任意非空用户名密码,便于无 db 环境调 UI。 7 PoC 模式(db_config = None):接受任意非空用户名密码,便于无 db 环境调 UI。
7 """ 8 """
8 import logging 9 import logging
9 import platform 10 import platform
10 import socket 11 import socket
12 from pathlib import Path
11 from typing import Optional 13 from typing import Optional
12 14
13 from PySide6.QtCore import Property, QObject, Signal, Slot 15 from PySide6.QtCore import Property, QObject, Signal, Slot
14 16
15 from core.database import DatabaseManager 17 from core.database import DatabaseManager, hash_password
16 18
17 19
18 class AuthBridge(QObject): 20 class AuthBridge(QObject):
...@@ -20,7 +22,9 @@ class AuthBridge(QObject): ...@@ -20,7 +22,9 @@ class AuthBridge(QObject):
20 currentUserChanged = Signal() 22 currentUserChanged = Signal()
21 loginFailed = Signal(str) # error_message 23 loginFailed = Signal(str) # error_message
22 24
23 def __init__(self, db_config: Optional[dict] = None, audit_logger=None, parent=None): 25 def __init__(self, db_config: Optional[dict] = None, audit_logger=None,
26 last_user: str = "", saved_password_hash: str = "",
27 config_path: Optional[Path] = None, parent=None):
24 super().__init__(parent) 28 super().__init__(parent)
25 self._logger = logging.getLogger(__name__) 29 self._logger = logging.getLogger(__name__)
26 self._db_config = db_config 30 self._db_config = db_config
...@@ -28,6 +32,9 @@ class AuthBridge(QObject): ...@@ -28,6 +32,9 @@ class AuthBridge(QObject):
28 self._audit = audit_logger 32 self._audit = audit_logger
29 self._logged_in = False 33 self._logged_in = False
30 self._current_user = "" 34 self._current_user = ""
35 self._last_user = last_user or ""
36 self._saved_password_hash = saved_password_hash or ""
37 self._config_path = config_path
31 38
32 @Property(bool, notify=loggedInChanged) 39 @Property(bool, notify=loggedInChanged)
33 def loggedIn(self) -> bool: 40 def loggedIn(self) -> bool:
...@@ -37,8 +44,19 @@ class AuthBridge(QObject): ...@@ -37,8 +44,19 @@ class AuthBridge(QObject):
37 def currentUser(self) -> str: 44 def currentUser(self) -> str:
38 return self._current_user 45 return self._current_user
39 46
47 @Property(str, constant=True)
48 def lastUser(self) -> str:
49 """启动期从 config.json 读到的"上次登录用户名",QML 用来预填 username 输入框。"""
50 return self._last_user
51
52 @Property(bool, constant=True)
53 def hasSavedPassword(self) -> bool:
54 """启动期 config.saved_password_hash 是否非空,QML 用来决定密码框是否显示 ••• 占位。"""
55 return bool(self._saved_password_hash)
56
40 @Slot(str, str, result=bool) 57 @Slot(str, str, result=bool)
41 def login(self, username: str, password: str) -> bool: 58 def login(self, username: str, password: str) -> bool:
59 """明文密码登录(用户首次输入或修改了密码框)。"""
42 username = (username or "").strip() 60 username = (username or "").strip()
43 if not username or not password: 61 if not username or not password:
44 self.loginFailed.emit("用户名和密码不能为空") 62 self.loginFailed.emit("用户名和密码不能为空")
...@@ -58,6 +76,58 @@ class AuthBridge(QObject): ...@@ -58,6 +76,58 @@ class AuthBridge(QObject):
58 self._on_login_success(username) 76 self._on_login_success(username)
59 return True 77 return True
60 78
79 @Slot(str, result=bool)
80 def loginWithSavedPassword(self, username: str) -> bool:
81 """用本地已存的 password_hash 登录("记住密码"路径,跳过 hash)。"""
82 username = (username or "").strip()
83 if not username:
84 self.loginFailed.emit("用户名不能为空")
85 return False
86 if not self._saved_password_hash:
87 self.loginFailed.emit("没有已保存的密码,请输入")
88 return False
89
90 if self._db is None:
91 self._on_login_success(username)
92 return True
93
94 ok, msg = self._db.authenticate_with_hash(username, self._saved_password_hash)
95 if not ok:
96 self._logger.warning(f"已存密码登录失败: {username} - {msg}")
97 self.loginFailed.emit(msg)
98 return False
99
100 self._on_login_success(username)
101 return True
102
103 @Slot(str, bool, bool)
104 def saveCredentials(self, password: str, remember_user: bool, remember_password: bool) -> None:
105 """登录成功后由 QML 调用,按勾选状态把 last_user / saved_password_hash 写回 config.json。
106
107 password: 用户当前输入的明文(passwordChanged=True 时非空,反之为空字符串);
108 remember_password=True 但 password 空时保留旧 hash 不动(避免清零)。
109 """
110 if self._config_path is None:
111 return
112 from config_util import load_config_safe, save_config
113
114 cfg, _ = load_config_safe(self._config_path)
115
116 cfg["last_user"] = self._current_user if remember_user else ""
117
118 if remember_password:
119 if password:
120 cfg["saved_password_hash"] = hash_password(password)
121 # password 空 = 用户没改密码,保留旧 hash 不动
122 else:
123 cfg["saved_password_hash"] = ""
124
125 if save_config(self._config_path, cfg):
126 self._last_user = cfg["last_user"]
127 self._saved_password_hash = cfg.get("saved_password_hash", "")
128 else:
129 self._logger.warning(f"saveCredentials 写盘失败: {self._config_path}")
130
61 @Slot() 131 @Slot()
62 def logout(self) -> None: 132 def logout(self) -> None:
63 self._logged_in = False 133 self._logged_in = False
...@@ -87,11 +157,11 @@ class AuthBridge(QObject): ...@@ -87,11 +157,11 @@ class AuthBridge(QObject):
87 self._audit.log_login( 157 self._audit.log_login(
88 user_name=username, 158 user_name=username,
89 local_ip=self._get_local_ip(), 159 local_ip=self._get_local_ip(),
90 public_ip=None, # public ip 走慢路径,task #13 再加 160 public_ip=self._get_public_ip(),
91 device_name=self.deviceName(), 161 device_name=self.deviceName(),
92 ) 162 )
93 except Exception as e: 163 except Exception:
94 self._logger.warning(f"audit log_login 失败(不影响登录): {e}") 164 self._logger.exception("audit log_login 失败(不影响登录)")
95 165
96 @staticmethod 166 @staticmethod
97 def _get_local_ip() -> Optional[str]: 167 def _get_local_ip() -> Optional[str]:
...@@ -102,3 +172,24 @@ class AuthBridge(QObject): ...@@ -102,3 +172,24 @@ class AuthBridge(QObject):
102 return s.getsockname()[0] 172 return s.getsockname()[0]
103 except Exception: 173 except Exception:
104 return None 174 return None
175
176 def _get_public_ip(self) -> Optional[str]:
177 """登录成功时拉一次公网 IP(与旧 LoginDialog.get_public_ip 一致)。
178
179 三个 API 兜底,每个 3s timeout。失败返回 None;只用于 audit log,不阻塞 UI 流程
180 (登录后跳主窗口前同步拿,最坏 ~3s,通常 < 500ms)。
181 """
182 try:
183 import requests
184 except Exception:
185 return None
186 for api in ("https://api.ipify.org", "https://ifconfig.me", "https://ipinfo.io/ip"):
187 try:
188 r = requests.get(api, timeout=3)
189 if r.status_code == 200:
190 ip = r.text.strip()
191 if len(ip.split(".")) == 4 or ":" in ip: # IPv4 / IPv6 粗筛
192 return ip
193 except Exception:
194 continue
195 return None
......
...@@ -7,9 +7,14 @@ itemAdded(task #16 wiring 时再补)。 ...@@ -7,9 +7,14 @@ itemAdded(task #16 wiring 时再补)。
7 详情查看走 getItem(timestamp) → dict,避免 QML 直接持有 HistoryItem dataclass。 7 详情查看走 getItem(timestamp) → dict,避免 QML 直接持有 HistoryItem dataclass。
8 """ 8 """
9 import logging 9 import logging
10 import platform
11 import shutil
12 import subprocess
13 from pathlib import Path
10 from typing import Any, Dict 14 from typing import Any, Dict
11 15
12 from PySide6.QtCore import Property, QObject, Signal, Slot 16 from PySide6.QtCore import Property, QObject, Signal, Slot
17 from PySide6.QtGui import QGuiApplication
13 18
14 from core.history import HistoryListModel 19 from core.history import HistoryListModel
15 from ._icons import build_placeholder_icon 20 from ._icons import build_placeholder_icon
...@@ -67,6 +72,27 @@ class HistoryBridge(QObject): ...@@ -67,6 +72,27 @@ class HistoryBridge(QObject):
67 self.itemAdded.emit(timestamp) 72 self.itemAdded.emit(timestamp)
68 self.countChanged.emit() 73 self.countChanged.emit()
69 74
75 @Slot(result=bool)
76 def clearAll(self) -> bool:
77 """清空所有历史记录:删除整个 base_path 目录后重建空目录。返回是否成功。
78
79 与旧 clear_history 等价:
80 shutil.rmtree(base_path) → mkdir → reset_timestamps([])
81 UI 层(QML)负责弹确认对话框,桥层不做交互。
82 """
83 try:
84 base = self._history.base_path
85 if base.exists():
86 shutil.rmtree(base)
87 base.mkdir(parents=True, exist_ok=True)
88 self._model.reset_timestamps([])
89 self._logger.info(f"历史记录已清空: {base}")
90 self.countChanged.emit()
91 return True
92 except Exception:
93 self._logger.exception("clearAll 失败")
94 return False
95
70 @Slot(str, result="QVariant") 96 @Slot(str, result="QVariant")
71 def getItem(self, timestamp: str) -> Dict[str, Any]: 97 def getItem(self, timestamp: str) -> Dict[str, Any]:
72 """返回单条历史的 QML 友好 dict(路径 → str / Path 不暴露)。""" 98 """返回单条历史的 QML 友好 dict(路径 → str / Path 不暴露)。"""
...@@ -84,6 +110,43 @@ class HistoryBridge(QObject): ...@@ -84,6 +110,43 @@ class HistoryBridge(QObject):
84 "createdAt": item.created_at.strftime("%Y-%m-%d %H:%M:%S"), 110 "createdAt": item.created_at.strftime("%Y-%m-%d %H:%M:%S"),
85 } 111 }
86 112
113 @Slot(str, result=bool)
114 def copyToClipboard(self, text: str) -> bool:
115 """复制纯文本到系统剪贴板。失败返回 False。"""
116 try:
117 cb = QGuiApplication.clipboard()
118 if cb is None:
119 return False
120 cb.setText(text or "")
121 return True
122 except Exception:
123 self._logger.exception("copyToClipboard 失败")
124 return False
125
126 @Slot(str)
127 def revealInExplorer(self, path: str) -> None:
128 """在系统文件管理器里打开 path 所在文件夹并选中该文件。
129
130 Windows: explorer /select,<path>
131 macOS: open -R <path>
132 Linux: xdg-open <parent_dir>(无法选中具体文件,只能开父目录)
133 """
134 p = Path(path)
135 if not p.exists():
136 self._logger.warning(f"revealInExplorer: 文件不存在 {path}")
137 return
138 try:
139 system = platform.system()
140 if system == "Windows":
141 # 注意:explorer /select, 需要逗号紧跟在 /select 后,path 用 native 反斜杠
142 subprocess.Popen(["explorer", f"/select,{p}"])
143 elif system == "Darwin":
144 subprocess.Popen(["open", "-R", str(p)])
145 else:
146 subprocess.Popen(["xdg-open", str(p.parent)])
147 except Exception:
148 self._logger.exception(f"revealInExplorer 失败 {path}")
149
87 @Slot(str, result=str) 150 @Slot(str, result=str)
88 def thumbnailPath(self, timestamp: str) -> str: 151 def thumbnailPath(self, timestamp: str) -> str:
89 """返回该 timestamp 缩略图本地路径(按需生成)。源图缺失时返回 ""。""" 152 """返回该 timestamp 缩略图本地路径(按需生成)。源图缺失时返回 ""。"""
......
...@@ -22,15 +22,16 @@ class DatabaseManager: ...@@ -22,15 +22,16 @@ class DatabaseManager:
22 self.logger = logging.getLogger(__name__) 22 self.logger = logging.getLogger(__name__)
23 23
24 def authenticate(self, username, password): 24 def authenticate(self, username, password):
25 """ 25 """验证明文密码(前端首次输入)。返回 (success, message)。"""
26 验证用户凭证 26 return self._do_query(username, hash_password(password))
27 返回: (success: bool, message: str)
28 """
29 try:
30 self.logger.info(f"开始用户认证: {username}")
31 27
32 password_hash = hash_password(password) 28 def authenticate_with_hash(self, username, password_hash):
29 """直接用已存的 hash 登录("记住密码"路径)。返回 (success, message)。"""
30 return self._do_query(username, password_hash)
33 31
32 def _do_query(self, username, password_hash):
33 try:
34 self.logger.info(f"开始用户认证: {username}")
34 self.logger.debug(f"连接数据库: {self.config['host']}:{self.config.get('port', 3306)}") 35 self.logger.debug(f"连接数据库: {self.config['host']}:{self.config.get('port', 3306)}")
35 conn = pymysql.connect( 36 conn = pymysql.connect(
36 host=self.config['host'], 37 host=self.config['host'],
...@@ -62,5 +63,5 @@ class DatabaseManager: ...@@ -62,5 +63,5 @@ class DatabaseManager:
62 return False, error_msg 63 return False, error_msg
63 except Exception as e: 64 except Exception as e:
64 error_msg = f"认证失败: {str(e)}" 65 error_msg = f"认证失败: {str(e)}"
65 self.logger.error(f"认证过程异常: {e}") 66 self.logger.exception("认证过程异常")
66 return False, error_msg 67 return False, error_msg
......
...@@ -153,5 +153,5 @@ class ImageGenerationWorker(QThread): ...@@ -153,5 +153,5 @@ class ImageGenerationWorker(QThread):
153 153
154 except Exception as e: 154 except Exception as e:
155 error_msg = f"图片生成异常: {e}" 155 error_msg = f"图片生成异常: {e}"
156 self.logger.error(error_msg, exc_info=True) 156 self.logger.exception("图片生成异常")
157 self.error.emit(error_msg) 157 self.error.emit(error_msg)
......
...@@ -175,8 +175,8 @@ class HistoryListModel(QAbstractListModel): ...@@ -175,8 +175,8 @@ class HistoryListModel(QAbstractListModel):
175 return cached 175 return cached
176 try: 176 try:
177 item = self._history_manager.load_history_item_fast(timestamp) 177 item = self._history_manager.load_history_item_fast(timestamp)
178 except Exception as e: 178 except Exception:
179 self._logger.warning(f"[HistoryListModel] load 失败 {timestamp}: {e}") 179 self._logger.exception(f"[HistoryListModel] load 失败 {timestamp}")
180 item = None 180 item = None
181 if item is None: 181 if item is None:
182 placeholder = { 182 placeholder = {
...@@ -220,8 +220,8 @@ class HistoryListModel(QAbstractListModel): ...@@ -220,8 +220,8 @@ class HistoryListModel(QAbstractListModel):
220 return self._build_placeholder_icon("图片\n加载失败") 220 return self._build_placeholder_icon("图片\n加载失败")
221 scaled = pixmap.scaled(120, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation) 221 scaled = pixmap.scaled(120, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation)
222 return QIcon(scaled) 222 return QIcon(scaled)
223 except Exception as e: 223 except Exception:
224 self._logger.error(f"[HistoryListModel] 缩略图异常 {item.timestamp}: {e}") 224 self._logger.exception(f"[HistoryListModel] 缩略图异常 {item.timestamp}")
225 return self._build_placeholder_icon("图片\n错误") 225 return self._build_placeholder_icon("图片\n错误")
226 226
227 def _put_cache(self, timestamp: str, value: Dict[str, Any]): 227 def _put_cache(self, timestamp: str, value: Dict[str, Any]):
...@@ -247,8 +247,8 @@ class HistoryManager: ...@@ -247,8 +247,8 @@ class HistoryManager:
247 # 之后 load_history_index 不再做任何 stat 循环 247 # 之后 load_history_index 不再做任何 stat 循环
248 try: 248 try:
249 self._migrate_paths_once() 249 self._migrate_paths_once()
250 except Exception as e: 250 except Exception:
251 self.logger.warning(f"启动路径迁移失败 (可忽略): {e}") 251 self.logger.exception("启动路径迁移失败 (可忽略)")
252 252
253 def save_generation(self, image_bytes: bytes, prompt: str, reference_images: List[bytes], 253 def save_generation(self, image_bytes: bytes, prompt: str, reference_images: List[bytes],
254 aspect_ratio: str, image_size: str, model: str) -> str: 254 aspect_ratio: str, image_size: str, model: str) -> str:
...@@ -302,8 +302,8 @@ class HistoryManager: ...@@ -302,8 +302,8 @@ class HistoryManager:
302 302
303 try: 303 try:
304 self.get_or_create_thumbnail(generated_image_path) 304 self.get_or_create_thumbnail(generated_image_path)
305 except Exception as e: 305 except Exception:
306 self.logger.warning(f"保存历史记录时生成缩略图失败: {e}") 306 self.logger.exception("保存历史记录时生成缩略图失败")
307 307
308 self._cleanup_old_records() 308 self._cleanup_old_records()
309 309
...@@ -336,9 +336,9 @@ class HistoryManager: ...@@ -336,9 +336,9 @@ class HistoryManager:
336 img.thumbnail((size, size), Image.LANCZOS) 336 img.thumbnail((size, size), Image.LANCZOS)
337 img.save(str(thumb_path), "JPEG", quality=75, optimize=True) 337 img.save(str(thumb_path), "JPEG", quality=75, optimize=True)
338 return thumb_path 338 return thumb_path
339 except Exception as e: 339 except Exception:
340 try: 340 try:
341 self.logger.warning(f"生成缩略图失败 {generated_image_path}: {e}") 341 self.logger.exception(f"生成缩略图失败 {generated_image_path}")
342 except Exception: 342 except Exception:
343 pass 343 pass
344 return None 344 return None
...@@ -359,8 +359,8 @@ class HistoryManager: ...@@ -359,8 +359,8 @@ class HistoryManager:
359 try: 359 try:
360 with open(self.history_index_file, 'r', encoding='utf-8') as f: 360 with open(self.history_index_file, 'r', encoding='utf-8') as f:
361 raw = json.load(f) 361 raw = json.load(f)
362 except Exception as e: 362 except Exception:
363 self.logger.warning(f"_migrate_paths_once 读取 index 失败: {e}") 363 self.logger.exception("_migrate_paths_once 读取 index 失败")
364 return 364 return
365 if not isinstance(raw, list) or not raw: 365 if not isinstance(raw, list) or not raw:
366 return 366 return
...@@ -415,8 +415,8 @@ class HistoryManager: ...@@ -415,8 +415,8 @@ class HistoryManager:
415 try: 415 try:
416 with open(self.history_index_file, 'w', encoding='utf-8') as f: 416 with open(self.history_index_file, 'w', encoding='utf-8') as f:
417 json.dump(raw, f, ensure_ascii=False, indent=2) 417 json.dump(raw, f, ensure_ascii=False, indent=2)
418 except Exception as e: 418 except Exception:
419 self.logger.error(f"[migrate_paths] 写回失败: {e}") 419 self.logger.exception("[migrate_paths] 写回失败")
420 420
421 def load_history_index(self) -> List[HistoryItem]: 421 def load_history_index(self) -> List[HistoryItem]:
422 """加载历史记录索引(仅 raw read + from_dict + sort)。 422 """加载历史记录索引(仅 raw read + from_dict + sort)。
...@@ -441,8 +441,8 @@ class HistoryManager: ...@@ -441,8 +441,8 @@ class HistoryManager:
441 continue 441 continue
442 items.sort(key=lambda x: x.timestamp, reverse=True) 442 items.sort(key=lambda x: x.timestamp, reverse=True)
443 return items 443 return items
444 except Exception as e: 444 except Exception:
445 self.logger.error(f"加载历史记录索引失败: {e}", exc_info=True) 445 self.logger.exception("加载历史记录索引失败")
446 return [] 446 return []
447 447
448 def get_history_item(self, timestamp: str) -> Optional[HistoryItem]: 448 def get_history_item(self, timestamp: str) -> Optional[HistoryItem]:
...@@ -487,8 +487,8 @@ class HistoryManager: ...@@ -487,8 +487,8 @@ class HistoryManager:
487 model=metadata.get('model', ''), 487 model=metadata.get('model', ''),
488 created_at=created_at, 488 created_at=created_at,
489 ) 489 )
490 except Exception as e: 490 except Exception:
491 self.logger.warning(f"load_history_item_fast 失败 {timestamp}: {e}") 491 self.logger.exception(f"load_history_item_fast 失败 {timestamp}")
492 return None 492 return None
493 493
494 def delete_history_item(self, timestamp: str) -> bool: 494 def delete_history_item(self, timestamp: str) -> bool:
...@@ -512,12 +512,12 @@ class HistoryManager: ...@@ -512,12 +512,12 @@ class HistoryManager:
512 try: 512 try:
513 with open(self.history_index_file, 'w', encoding='utf-8') as f: 513 with open(self.history_index_file, 'w', encoding='utf-8') as f:
514 json.dump(raw, f, ensure_ascii=False, indent=2) 514 json.dump(raw, f, ensure_ascii=False, indent=2)
515 except Exception as e: 515 except Exception:
516 self.logger.error(f"删除后写回索引失败: {e}") 516 self.logger.exception("删除后写回索引失败")
517 517
518 return True 518 return True
519 except Exception as e: 519 except Exception:
520 self.logger.error(f"删除历史记录失败: {e}") 520 self.logger.exception("删除历史记录失败")
521 return False 521 return False
522 522
523 def _update_history_index(self, history_item: HistoryItem): 523 def _update_history_index(self, history_item: HistoryItem):
...@@ -537,8 +537,8 @@ class HistoryManager: ...@@ -537,8 +537,8 @@ class HistoryManager:
537 raw = [] 537 raw = []
538 else: 538 else:
539 raw = [] 539 raw = []
540 except Exception as e: 540 except Exception:
541 self.logger.error(f"_update_history_index 读取索引失败: {e}") 541 self.logger.exception("_update_history_index 读取索引失败")
542 raw = [] 542 raw = []
543 543
544 new_ts = history_item.timestamp 544 new_ts = history_item.timestamp
...@@ -548,8 +548,8 @@ class HistoryManager: ...@@ -548,8 +548,8 @@ class HistoryManager:
548 try: 548 try:
549 with open(self.history_index_file, 'w', encoding='utf-8') as f: 549 with open(self.history_index_file, 'w', encoding='utf-8') as f:
550 json.dump(raw, f, ensure_ascii=False, indent=2) 550 json.dump(raw, f, ensure_ascii=False, indent=2)
551 except Exception as e: 551 except Exception:
552 self.logger.error(f"_update_history_index 写入索引失败: {e}") 552 self.logger.exception("_update_history_index 写入索引失败")
553 553
554 def _cleanup_old_records(self): 554 def _cleanup_old_records(self):
555 """清理旧的历史记录,保持最大数量限制。max_history_count <= 0 表示不限制。""" 555 """清理旧的历史记录,保持最大数量限制。max_history_count <= 0 表示不限制。"""
...@@ -562,8 +562,8 @@ class HistoryManager: ...@@ -562,8 +562,8 @@ class HistoryManager:
562 raw = json.load(f) 562 raw = json.load(f)
563 if not isinstance(raw, list): 563 if not isinstance(raw, list):
564 return 564 return
565 except Exception as e: 565 except Exception:
566 self.logger.error(f"_cleanup_old_records 读取索引失败: {e}") 566 self.logger.exception("_cleanup_old_records 读取索引失败")
567 return 567 return
568 568
569 raw.sort(key=lambda d: d.get('timestamp', '') if isinstance(d, dict) else '', reverse=True) 569 raw.sort(key=lambda d: d.get('timestamp', '') if isinstance(d, dict) else '', reverse=True)
...@@ -582,11 +582,11 @@ class HistoryManager: ...@@ -582,11 +582,11 @@ class HistoryManager:
582 if record_dir.exists(): 582 if record_dir.exists():
583 try: 583 try:
584 shutil.rmtree(record_dir) 584 shutil.rmtree(record_dir)
585 except Exception as e: 585 except Exception:
586 self.logger.warning(f"删除旧记录失败 {ts}: {e}") 586 self.logger.exception(f"删除旧记录失败 {ts}")
587 587
588 try: 588 try:
589 with open(self.history_index_file, 'w', encoding='utf-8') as f: 589 with open(self.history_index_file, 'w', encoding='utf-8') as f:
590 json.dump(keep, f, ensure_ascii=False, indent=2) 590 json.dump(keep, f, ensure_ascii=False, indent=2)
591 except Exception as e: 591 except Exception:
592 self.logger.error(f"_cleanup_old_records 写回索引失败: {e}") 592 self.logger.exception("_cleanup_old_records 写回索引失败")
......
...@@ -184,8 +184,8 @@ class JewelryLibraryManager: ...@@ -184,8 +184,8 @@ class JewelryLibraryManager:
184 184
185 self.logger.info(f"珠宝词库加载成功: {self.config_path}") 185 self.logger.info(f"珠宝词库加载成功: {self.config_path}")
186 return library 186 return library
187 except Exception as e: 187 except Exception:
188 self.logger.error(f"珠宝词库加载失败: {e},使用默认词库") 188 self.logger.exception("珠宝词库加载失败,使用默认词库")
189 library = DEFAULT_JEWELRY_LIBRARY.copy() 189 library = DEFAULT_JEWELRY_LIBRARY.copy()
190 try: 190 try:
191 self.save_library(library) 191 self.save_library(library)
...@@ -208,8 +208,8 @@ class JewelryLibraryManager: ...@@ -208,8 +208,8 @@ class JewelryLibraryManager:
208 with open(self.config_path, 'w', encoding='utf-8') as f: 208 with open(self.config_path, 'w', encoding='utf-8') as f:
209 json.dump(library, f, ensure_ascii=False, indent=2) 209 json.dump(library, f, ensure_ascii=False, indent=2)
210 self.logger.info(f"珠宝词库保存成功: {self.config_path}") 210 self.logger.info(f"珠宝词库保存成功: {self.config_path}")
211 except Exception as e: 211 except Exception:
212 self.logger.error(f"珠宝词库保存失败: {e}") 212 self.logger.exception("珠宝词库保存失败")
213 raise 213 raise
214 214
215 def add_item(self, category: str, value: str): 215 def add_item(self, category: str, value: str):
......
1 import QtQuick 1 import QtQuick
2 import QtQuick.Controls.Basic 2 import QtQuick.Controls.Basic
3 import QtQuick.Dialogs
3 import QtQuick.Layouts 4 import QtQuick.Layouts
4 import "components" 5 import "components"
5 import "." as App 6 import "." as App
...@@ -9,6 +10,62 @@ Item { ...@@ -9,6 +10,62 @@ Item {
9 10
10 property string selectedTimestamp: "" 11 property string selectedTimestamp: ""
11 property var selectedItem: ({}) 12 property var selectedItem: ({})
13 // 右键菜单当前操作的 timestamp
14 property string contextTimestamp: ""
15
16 // 单条删除确认
17 MessageDialog {
18 id: confirmDeleteDialog
19 title: "确认删除"
20 text: "确定要删除这条历史记录吗?这将删除相关的所有文件。"
21 buttons: MessageDialog.Yes | MessageDialog.No
22 onAccepted: {
23 if (tab.contextTimestamp) {
24 history.deleteItem(tab.contextTimestamp)
25 tab.contextTimestamp = ""
26 }
27 }
28 }
29
30 // 全部清空确认
31 MessageDialog {
32 id: confirmClearDialog
33 title: "确认清空"
34 text: "确定要清空所有历史记录吗?这将删除所有历史图片文件,且无法恢复。"
35 buttons: MessageDialog.Yes | MessageDialog.No
36 onAccepted: history.clearAll()
37 }
38
39 // 列表项右键菜单
40 Menu {
41 id: itemContextMenu
42
43 MenuItem {
44 text: "在文件管理器中打开"
45 onTriggered: {
46 if (tab.contextTimestamp) {
47 var item = history.getItem(tab.contextTimestamp)
48 if (item.generatedImagePath) {
49 history.revealInExplorer(item.generatedImagePath)
50 }
51 }
52 }
53 }
54 MenuItem {
55 text: "复制提示词"
56 onTriggered: {
57 if (tab.contextTimestamp) {
58 var item = history.getItem(tab.contextTimestamp)
59 if (item.prompt) history.copyToClipboard(item.prompt)
60 }
61 }
62 }
63 MenuSeparator {}
64 MenuItem {
65 text: "删除此项"
66 onTriggered: confirmDeleteDialog.open()
67 }
68 }
12 69
13 function selectTimestamp(ts) { 70 function selectTimestamp(ts) {
14 if (!ts) { 71 if (!ts) {
...@@ -60,7 +117,7 @@ Item { ...@@ -60,7 +117,7 @@ Item {
60 spacing: App.Theme.space3 117 spacing: App.Theme.space3
61 118
62 RowLayout { 119 RowLayout {
63 spacing: App.Theme.space3 120 spacing: App.Theme.space2
64 CaptionLabel { 121 CaptionLabel {
65 text: "历史记录" 122 text: "历史记录"
66 font.pointSize: App.Theme.fontLg 123 font.pointSize: App.Theme.fontLg
...@@ -73,9 +130,14 @@ Item { ...@@ -73,9 +130,14 @@ Item {
73 } 130 }
74 Item { Layout.fillWidth: true } 131 Item { Layout.fillWidth: true }
75 SecondaryButton { 132 SecondaryButton {
76 text: "刷新" 133 text: "🔄 刷新"
77 onClicked: history.refresh() 134 onClicked: history.refresh()
78 } 135 }
136 SecondaryButton {
137 text: "🗑️ 清空"
138 enabled: history.count > 0
139 onClicked: confirmClearDialog.open()
140 }
79 } 141 }
80 142
81 // 空态 143 // 空态
...@@ -173,7 +235,15 @@ Item { ...@@ -173,7 +235,15 @@ Item {
173 anchors.fill: parent 235 anchors.fill: parent
174 hoverEnabled: true 236 hoverEnabled: true
175 cursorShape: Qt.PointingHandCursor 237 cursorShape: Qt.PointingHandCursor
176 onClicked: tab.selectTimestamp(timestamp) 238 acceptedButtons: Qt.LeftButton | Qt.RightButton
239 onClicked: function(mouse) {
240 if (mouse.button === Qt.RightButton) {
241 tab.contextTimestamp = timestamp
242 itemContextMenu.popup()
243 } else {
244 tab.selectTimestamp(timestamp)
245 }
246 }
177 } 247 }
178 248
179 ToolTip.text: toolTip 249 ToolTip.text: toolTip
...@@ -225,7 +295,7 @@ Item { ...@@ -225,7 +295,7 @@ Item {
225 text: "在文件管理器中打开" 295 text: "在文件管理器中打开"
226 onClicked: { 296 onClicked: {
227 if (tab.selectedItem.generatedImagePath) { 297 if (tab.selectedItem.generatedImagePath) {
228 Qt.openUrlExternally("file:///" + tab.selectedItem.generatedImagePath) 298 history.revealInExplorer(tab.selectedItem.generatedImagePath)
229 } 299 }
230 } 300 }
231 } 301 }
...@@ -233,7 +303,8 @@ Item { ...@@ -233,7 +303,8 @@ Item {
233 text: "删除" 303 text: "删除"
234 onClicked: { 304 onClicked: {
235 if (tab.selectedTimestamp) { 305 if (tab.selectedTimestamp) {
236 history.deleteItem(tab.selectedTimestamp) 306 tab.contextTimestamp = tab.selectedTimestamp
307 confirmDeleteDialog.open()
237 } 308 }
238 } 309 }
239 } 310 }
...@@ -242,7 +313,7 @@ Item { ...@@ -242,7 +313,7 @@ Item {
242 // 大图 313 // 大图
243 Rectangle { 314 Rectangle {
244 Layout.fillWidth: true 315 Layout.fillWidth: true
245 Layout.preferredHeight: 360 316 Layout.preferredHeight: 320
246 color: App.Theme.bgSubtle 317 color: App.Theme.bgSubtle
247 radius: App.Theme.radiusMd 318 radius: App.Theme.radiusMd
248 319
...@@ -268,7 +339,71 @@ Item { ...@@ -268,7 +339,71 @@ Item {
268 } 339 }
269 } 340 }
270 341
271 // 元信息 342 // 参考图(可能没有)
343 ColumnLayout {
344 Layout.fillWidth: true
345 spacing: App.Theme.space2
346 visible: (tab.selectedItem.referenceImagePaths || []).length > 0
347
348 CaptionLabel {
349 text: "参考图(" + (tab.selectedItem.referenceImagePaths || []).length + " 张)"
350 }
351
352 Flow {
353 Layout.fillWidth: true
354 spacing: App.Theme.space2
355
356 Repeater {
357 model: tab.selectedItem.referenceImagePaths || []
358 delegate: Rectangle {
359 width: 80
360 height: 80
361 radius: App.Theme.radiusSm
362 color: App.Theme.bgSurface
363 border.width: 1
364 border.color: App.Theme.borderDefault
365
366 Image {
367 anchors.fill: parent
368 anchors.margins: 2
369 source: "file:///" + modelData
370 fillMode: Image.PreserveAspectCrop
371 smooth: true
372 asynchronous: true
373 }
374
375 // 左下角 "图 N" badge
376 Rectangle {
377 anchors.left: parent.left
378 anchors.bottom: parent.bottom
379 anchors.margins: 3
380 width: refIdx.implicitWidth + 8
381 height: 16
382 radius: App.Theme.radiusSm
383 color: Qt.rgba(0, 0, 0, 0.6)
384
385 Text {
386 id: refIdx
387 anchors.centerIn: parent
388 text: "图 " + (index + 1)
389 color: "white"
390 font.family: App.Theme.fontFamily
391 font.pointSize: App.Theme.fontXs
392 font.weight: Font.DemiBold
393 }
394 }
395
396 MouseArea {
397 anchors.fill: parent
398 cursorShape: Qt.PointingHandCursor
399 onDoubleClicked: Qt.openUrlExternally("file:///" + modelData)
400 }
401 }
402 }
403 }
404 }
405
406 // 元信息(不暴露具体模型 ID)
272 GridLayout { 407 GridLayout {
273 Layout.fillWidth: true 408 Layout.fillWidth: true
274 columns: 4 409 columns: 4
...@@ -289,21 +424,34 @@ Item { ...@@ -289,21 +424,34 @@ Item {
289 font.pointSize: App.Theme.fontSm 424 font.pointSize: App.Theme.fontSm
290 color: App.Theme.textPrimary 425 color: App.Theme.textPrimary
291 } 426 }
427 }
292 428
293 CaptionLabel { text: "模型" } 429 // prompt header + 复制按钮
294 Label { 430 RowLayout {
295 Layout.columnSpan: 3 431 Layout.fillWidth: true
296 text: tab.selectedItem.model || "—" 432 spacing: App.Theme.space2
297 font.family: App.Theme.fontFamily 433
298 font.pointSize: App.Theme.fontSm 434 CaptionLabel { text: "提示词" }
299 color: App.Theme.textPrimary 435 Item { Layout.fillWidth: true }
300 elide: Text.ElideRight 436 SecondaryButton {
301 Layout.fillWidth: true 437 id: copyBtn
438 property bool justCopied: false
439 text: justCopied ? "✓ 已复制" : "📋 复制"
440 enabled: (tab.selectedItem.prompt || "").length > 0
441 onClicked: {
442 if (history.copyToClipboard(tab.selectedItem.prompt || "")) {
443 copyBtn.justCopied = true
444 copyResetTimer.restart()
445 }
446 }
447
448 Timer {
449 id: copyResetTimer
450 interval: 1500
451 onTriggered: copyBtn.justCopied = false
452 }
302 } 453 }
303 } 454 }
304
305 // prompt
306 CaptionLabel { text: "提示词" }
307 ScrollView { 455 ScrollView {
308 Layout.fillWidth: true 456 Layout.fillWidth: true
309 Layout.preferredHeight: 80 457 Layout.preferredHeight: 80
......
...@@ -11,21 +11,56 @@ Rectangle { ...@@ -11,21 +11,56 @@ Rectangle {
11 11
12 // 提交期间禁止重复点击 / 回车 12 // 提交期间禁止重复点击 / 回车
13 property bool submitting: false 13 property bool submitting: false
14 // 用户是否在密码框输入过 — 决定走 login(明文) 还是 loginWithSavedPassword(已存 hash)
15 property bool passwordChanged: false
16
17 Component.onCompleted: {
18 usernameField.text = auth.lastUser
19 // 上次记住的用户 → 焦点到密码框;否则到用户名
20 if (usernameField.text.length > 0 && auth.hasSavedPassword) {
21 passwordField.focus = true
22 } else {
23 usernameField.focus = true
24 }
25 }
14 26
15 function doLogin() { 27 function doLogin() {
16 if (submitting) return 28 if (submitting) return
17 if (usernameField.text.length === 0 || passwordField.text.length === 0) { 29 var username = usernameField.text.trim()
18 errorLabel.text = "用户名和密码不能为空" 30 if (username.length === 0) {
31 errorLabel.text = "请输入用户名"
32 return
33 }
34
35 // 决定走哪条路径:
36 // passwordChanged && text != "" → 明文 login
37 // !passwordChanged && hasSavedPassword → 用已存 hash 登录
38 // 其他(密码框空 + 没有已存 hash)→ 报错
39 var useSaved = !passwordChanged && auth.hasSavedPassword
40 if (!useSaved && passwordField.text.length === 0) {
41 errorLabel.text = "请输入密码"
19 return 42 return
20 } 43 }
44
21 errorLabel.text = "" 45 errorLabel.text = ""
22 submitting = true 46 submitting = true
23 // AuthBridge.login 是同步(pymysql 5s timeout),主线程会卡一下; 47 var ok
24 // task #18 时若 db 慢需求改异步再说 48 if (useSaved) {
25 var ok = auth.login(usernameField.text, passwordField.text) 49 ok = auth.loginWithSavedPassword(username)
50 } else {
51 ok = auth.login(username, passwordField.text)
52 }
26 submitting = false 53 submitting = false
27 // 失败时 auth 会发 loginFailed 信号,由下面 Connections 接住 54
28 // 成功时 auth.loggedInChanged → AppState 转发 → Main.qml StackLayout 切换 55 // 登录成功 → 按勾选保存凭据。失败时 auth 已发 loginFailed 由 Connections 接
56 if (ok) {
57 // 没改过密码时传空字符串,桥层会保留旧 hash 不动
58 auth.saveCredentials(
59 passwordChanged ? passwordField.text : "",
60 rememberUser.checked,
61 rememberPassword.checked
62 )
63 }
29 } 64 }
30 65
31 Keys.onReturnPressed: doLogin() 66 Keys.onReturnPressed: doLogin()
...@@ -82,10 +117,12 @@ Rectangle { ...@@ -82,10 +117,12 @@ Rectangle {
82 ThemedTextField { 117 ThemedTextField {
83 id: passwordField 118 id: passwordField
84 echoMode: TextInput.Password 119 echoMode: TextInput.Password
85 placeholderText: "••••••••" 120 // 已存密码时显示 8 个圆点占位(与旧 LoginDialog 一致)
121 placeholderText: auth.hasSavedPassword ? "••••••••" : "请输入密码"
86 enabled: !root.submitting 122 enabled: !root.submitting
87 Layout.fillWidth: true 123 Layout.fillWidth: true
88 Layout.bottomMargin: App.Theme.space2 124 Layout.bottomMargin: App.Theme.space2
125 onTextEdited: root.passwordChanged = true
89 } 126 }
90 127
91 RowLayout { 128 RowLayout {
...@@ -96,7 +133,7 @@ Rectangle { ...@@ -96,7 +133,7 @@ Rectangle {
96 CheckBox { 133 CheckBox {
97 id: rememberUser 134 id: rememberUser
98 text: "记住用户名" 135 text: "记住用户名"
99 checked: true 136 checked: auth.lastUser.length > 0
100 enabled: !root.submitting 137 enabled: !root.submitting
101 font.family: App.Theme.fontFamily 138 font.family: App.Theme.fontFamily
102 font.pointSize: App.Theme.fontSm 139 font.pointSize: App.Theme.fontSm
...@@ -129,7 +166,7 @@ Rectangle { ...@@ -129,7 +166,7 @@ Rectangle {
129 CheckBox { 166 CheckBox {
130 id: rememberPassword 167 id: rememberPassword
131 text: "记住密码" 168 text: "记住密码"
132 checked: true 169 checked: auth.hasSavedPassword
133 enabled: !root.submitting 170 enabled: !root.submitting
134 font.family: App.Theme.fontFamily 171 font.family: App.Theme.fontFamily
135 font.pointSize: App.Theme.fontSm 172 font.pointSize: App.Theme.fontSm
......
...@@ -21,8 +21,17 @@ Item { ...@@ -21,8 +21,17 @@ Item {
21 }) 21 })
22 property var lockedFields: [] // 被 🔓 锁定的字段名(不参与随机) 22 property var lockedFields: [] // 被 🔓 锁定的字段名(不参与随机)
23 property string assembledPrompt: "" 23 property string assembledPrompt: ""
24 property string currentTaskId: "" 24 property var myTaskIds: [] // 多任务支持:本 tab 提交过且还在跑/排队的 ids
25 property string lastTaskId: "" // 最近一次提交的 task — 进度显示锚点
25 property string lastResultPath: "" 26 property string lastResultPath: ""
27
28 function isMyTask(tid) { return tab.myTaskIds.indexOf(tid) >= 0 }
29 function dropMyTask(tid) {
30 var copy = tab.myTaskIds.slice()
31 var i = copy.indexOf(tid)
32 if (i >= 0) copy.splice(i, 1)
33 tab.myTaskIds = copy
34 }
26 property string statusText: "● 就绪" 35 property string statusText: "● 就绪"
27 property color statusColor: App.Theme.success 36 property color statusColor: App.Theme.success
28 37
...@@ -78,7 +87,6 @@ Item { ...@@ -78,7 +87,6 @@ Item {
78 } 87 }
79 88
80 function submit() { 89 function submit() {
81 if (tab.currentTaskId !== "") return
82 if (tab.assembledPrompt.trim().length === 0) { 90 if (tab.assembledPrompt.trim().length === 0) {
83 tab.statusText = "● 选几个字段先" 91 tab.statusText = "● 选几个字段先"
84 tab.statusColor = App.Theme.danger 92 tab.statusColor = App.Theme.danger
...@@ -88,8 +96,9 @@ Item { ...@@ -88,8 +96,9 @@ Item {
88 var taskId = imageGen.submitTask( 96 var taskId = imageGen.submitTask(
89 tab.assembledPrompt, [], "1:1", "2K", "慢速模式" 97 tab.assembledPrompt, [], "1:1", "2K", "慢速模式"
90 ) 98 )
91 tab.currentTaskId = taskId 99 tab.myTaskIds = tab.myTaskIds.concat([taskId])
92 tab.statusText = "● 已提交" 100 tab.lastTaskId = taskId
101 tab.statusText = "● 已提交(我的队列 " + tab.myTaskIds.length + " 条)"
93 tab.statusColor = App.Theme.accent 102 tab.statusColor = App.Theme.accent
94 } catch (e) { 103 } catch (e) {
95 tab.statusText = "● " + e 104 tab.statusText = "● " + e
...@@ -112,20 +121,24 @@ Item { ...@@ -112,20 +121,24 @@ Item {
112 Connections { 121 Connections {
113 target: imageGen 122 target: imageGen
114 function onTaskProgress(taskId, progress, msg) { 123 function onTaskProgress(taskId, progress, msg) {
115 if (taskId !== tab.currentTaskId) return 124 if (taskId !== tab.lastTaskId) return
116 tab.statusText = "● " + (msg || "生成中…") 125 tab.statusText = "● " + (msg || "生成中…")
117 tab.statusColor = App.Theme.accent 126 tab.statusColor = App.Theme.accent
118 } 127 }
119 function onTaskCompleted(taskId, resultPath, prompt, model) { 128 function onTaskCompleted(taskId, resultPath, prompt, model) {
120 if (taskId !== tab.currentTaskId) return 129 if (!tab.isMyTask(taskId)) return
121 tab.lastResultPath = resultPath 130 tab.lastResultPath = resultPath
122 tab.currentTaskId = "" 131 tab.dropMyTask(taskId)
123 tab.statusText = "● 已完成" 132 if (tab.lastTaskId === taskId) tab.lastTaskId = ""
133 tab.statusText = tab.myTaskIds.length === 0
134 ? "● 已完成"
135 : "● 已完成(队列还剩 " + tab.myTaskIds.length + " 条)"
124 tab.statusColor = App.Theme.success 136 tab.statusColor = App.Theme.success
125 } 137 }
126 function onTaskFailed(taskId, error) { 138 function onTaskFailed(taskId, error) {
127 if (taskId !== tab.currentTaskId) return 139 if (!tab.isMyTask(taskId)) return
128 tab.currentTaskId = "" 140 tab.dropMyTask(taskId)
141 if (tab.lastTaskId === taskId) tab.lastTaskId = ""
129 tab.statusText = "● " + (error || "失败") 142 tab.statusText = "● " + (error || "失败")
130 tab.statusColor = App.Theme.danger 143 tab.statusColor = App.Theme.danger
131 } 144 }
...@@ -379,8 +392,7 @@ Item { ...@@ -379,8 +392,7 @@ Item {
379 Layout.fillWidth: true 392 Layout.fillWidth: true
380 393
381 PrimaryButton { 394 PrimaryButton {
382 text: tab.currentTaskId !== "" ? "生成中…" : "🎨 生成图片" 395 text: "🎨 生成图片"
383 enabled: tab.currentTaskId === ""
384 onClicked: tab.submit() 396 onClicked: tab.submit()
385 } 397 }
386 SecondaryButton { 398 SecondaryButton {
......
...@@ -18,6 +18,7 @@ Pillow==11.1.0 ...@@ -18,6 +18,7 @@ Pillow==11.1.0
18 httpx==0.28.1 18 httpx==0.28.1
19 h11==0.16.0 19 h11==0.16.0
20 httpcore==1.0.9 20 httpcore==1.0.9
21 socksio==1.0.0 # httpx SOCKS5 代理支持(Clash/V2Ray 用户必须)
21 websockets==15.0.1 22 websockets==15.0.1
22 requests==2.32.5 23 requests==2.32.5
23 24
......