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:
- 收藏按钮:
收藏 / ✓ 已收藏 toggle,根据 promptArea.text + savedPrompts 实时切
- addUrlsValidated 统一"加 + 校验 + 反馈":用户拖 5 张图 3 张超 10MB 时
显示"已添加 2 张,丢弃 3 张(不支持 / 超 10MB / 损坏)"
- 粘贴也走 validateImageFiles,路径 A 拿用户文件时拦截超大
HistoryTab:
- 顶部加
️ 清空 按钮(带 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>
Showing
14 changed files
with
445 additions
and
92 deletions
| 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 缩略图本地路径(按需生成)。源图缺失时返回 ""。""" | ... | ... |
This diff is collapsed.
Click to expand it.
| ... | @@ -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): | ... | ... |
core/runtime.py
0 → 100644
This diff is collapsed.
Click to expand it.
This diff is collapsed.
Click to expand it.
| 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 | ||
| 296 | text: tab.selectedItem.model || "—" | ||
| 297 | font.family: App.Theme.fontFamily | ||
| 298 | font.pointSize: App.Theme.fontSm | ||
| 299 | color: App.Theme.textPrimary | ||
| 300 | elide: Text.ElideRight | ||
| 301 | Layout.fillWidth: true | 431 | Layout.fillWidth: true |
| 432 | spacing: App.Theme.space2 | ||
| 433 | |||
| 434 | CaptionLabel { text: "提示词" } | ||
| 435 | Item { Layout.fillWidth: true } | ||
| 436 | SecondaryButton { | ||
| 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() | ||
| 302 | } | 445 | } |
| 303 | } | 446 | } |
| 304 | 447 | ||
| 305 | // prompt | 448 | Timer { |
| 306 | CaptionLabel { text: "提示词" } | 449 | id: copyResetTimer |
| 450 | interval: 1500 | ||
| 451 | onTriggered: copyBtn.justCopied = false | ||
| 452 | } | ||
| 453 | } | ||
| 454 | } | ||
| 307 | ScrollView { | 455 | ScrollView { |
| 308 | Layout.fillWidth: true | 456 | Layout.fillWidth: true |
| 309 | Layout.preferredHeight: 80 | 457 | Layout.preferredHeight: 80 | ... | ... |
This diff is collapsed.
Click to expand it.
| ... | @@ -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 | ... | ... |
-
Please register or sign in to post a comment