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
"""AuthBridge — 登录认证 + 当前用户。
"""AuthBridge — 登录认证 + 当前用户 + 记住凭据。
替代旧 LoginDialog。QML LoginScreen 调 auth.login(user, pwd),桥内部走
DatabaseManager.authenticate(同步 MySQL,5s timeout)+ audit_logger.log_login。
替代旧 LoginDialog。QML LoginScreen 调 auth.login(user, pwd)(首次输入)
或 auth.loginWithSavedPassword(user)("记住密码"路径,跳过 hash 直查 db)。
登录成功后 QML 调 auth.saveCredentials(...) 写回 config.json。
PoC 模式(db_config = None):接受任意非空用户名密码,便于无 db 环境调 UI。
"""
import logging
import platform
import socket
from pathlib import Path
from typing import Optional
from PySide6.QtCore import Property, QObject, Signal, Slot
from core.database import DatabaseManager
from core.database import DatabaseManager, hash_password
class AuthBridge(QObject):
......@@ -20,7 +22,9 @@ class AuthBridge(QObject):
currentUserChanged = Signal()
loginFailed = Signal(str) # error_message
def __init__(self, db_config: Optional[dict] = None, audit_logger=None, parent=None):
def __init__(self, db_config: Optional[dict] = None, audit_logger=None,
last_user: str = "", saved_password_hash: str = "",
config_path: Optional[Path] = None, parent=None):
super().__init__(parent)
self._logger = logging.getLogger(__name__)
self._db_config = db_config
......@@ -28,6 +32,9 @@ class AuthBridge(QObject):
self._audit = audit_logger
self._logged_in = False
self._current_user = ""
self._last_user = last_user or ""
self._saved_password_hash = saved_password_hash or ""
self._config_path = config_path
@Property(bool, notify=loggedInChanged)
def loggedIn(self) -> bool:
......@@ -37,8 +44,19 @@ class AuthBridge(QObject):
def currentUser(self) -> str:
return self._current_user
@Property(str, constant=True)
def lastUser(self) -> str:
"""启动期从 config.json 读到的"上次登录用户名",QML 用来预填 username 输入框。"""
return self._last_user
@Property(bool, constant=True)
def hasSavedPassword(self) -> bool:
"""启动期 config.saved_password_hash 是否非空,QML 用来决定密码框是否显示 ••• 占位。"""
return bool(self._saved_password_hash)
@Slot(str, str, result=bool)
def login(self, username: str, password: str) -> bool:
"""明文密码登录(用户首次输入或修改了密码框)。"""
username = (username or "").strip()
if not username or not password:
self.loginFailed.emit("用户名和密码不能为空")
......@@ -58,6 +76,58 @@ class AuthBridge(QObject):
self._on_login_success(username)
return True
@Slot(str, result=bool)
def loginWithSavedPassword(self, username: str) -> bool:
"""用本地已存的 password_hash 登录("记住密码"路径,跳过 hash)。"""
username = (username or "").strip()
if not username:
self.loginFailed.emit("用户名不能为空")
return False
if not self._saved_password_hash:
self.loginFailed.emit("没有已保存的密码,请输入")
return False
if self._db is None:
self._on_login_success(username)
return True
ok, msg = self._db.authenticate_with_hash(username, self._saved_password_hash)
if not ok:
self._logger.warning(f"已存密码登录失败: {username} - {msg}")
self.loginFailed.emit(msg)
return False
self._on_login_success(username)
return True
@Slot(str, bool, bool)
def saveCredentials(self, password: str, remember_user: bool, remember_password: bool) -> None:
"""登录成功后由 QML 调用,按勾选状态把 last_user / saved_password_hash 写回 config.json。
password: 用户当前输入的明文(passwordChanged=True 时非空,反之为空字符串);
remember_password=True 但 password 空时保留旧 hash 不动(避免清零)。
"""
if self._config_path is None:
return
from config_util import load_config_safe, save_config
cfg, _ = load_config_safe(self._config_path)
cfg["last_user"] = self._current_user if remember_user else ""
if remember_password:
if password:
cfg["saved_password_hash"] = hash_password(password)
# password 空 = 用户没改密码,保留旧 hash 不动
else:
cfg["saved_password_hash"] = ""
if save_config(self._config_path, cfg):
self._last_user = cfg["last_user"]
self._saved_password_hash = cfg.get("saved_password_hash", "")
else:
self._logger.warning(f"saveCredentials 写盘失败: {self._config_path}")
@Slot()
def logout(self) -> None:
self._logged_in = False
......@@ -87,11 +157,11 @@ class AuthBridge(QObject):
self._audit.log_login(
user_name=username,
local_ip=self._get_local_ip(),
public_ip=None, # public ip 走慢路径,task #13 再加
public_ip=self._get_public_ip(),
device_name=self.deviceName(),
)
except Exception as e:
self._logger.warning(f"audit log_login 失败(不影响登录): {e}")
except Exception:
self._logger.exception("audit log_login 失败(不影响登录)")
@staticmethod
def _get_local_ip() -> Optional[str]:
......@@ -102,3 +172,24 @@ class AuthBridge(QObject):
return s.getsockname()[0]
except Exception:
return None
def _get_public_ip(self) -> Optional[str]:
"""登录成功时拉一次公网 IP(与旧 LoginDialog.get_public_ip 一致)。
三个 API 兜底,每个 3s timeout。失败返回 None;只用于 audit log,不阻塞 UI 流程
(登录后跳主窗口前同步拿,最坏 ~3s,通常 < 500ms)。
"""
try:
import requests
except Exception:
return None
for api in ("https://api.ipify.org", "https://ifconfig.me", "https://ipinfo.io/ip"):
try:
r = requests.get(api, timeout=3)
if r.status_code == 200:
ip = r.text.strip()
if len(ip.split(".")) == 4 or ":" in ip: # IPv4 / IPv6 粗筛
return ip
except Exception:
continue
return None
......
......@@ -7,9 +7,14 @@ itemAdded(task #16 wiring 时再补)。
详情查看走 getItem(timestamp) → dict,避免 QML 直接持有 HistoryItem dataclass。
"""
import logging
import platform
import shutil
import subprocess
from pathlib import Path
from typing import Any, Dict
from PySide6.QtCore import Property, QObject, Signal, Slot
from PySide6.QtGui import QGuiApplication
from core.history import HistoryListModel
from ._icons import build_placeholder_icon
......@@ -67,6 +72,27 @@ class HistoryBridge(QObject):
self.itemAdded.emit(timestamp)
self.countChanged.emit()
@Slot(result=bool)
def clearAll(self) -> bool:
"""清空所有历史记录:删除整个 base_path 目录后重建空目录。返回是否成功。
与旧 clear_history 等价:
shutil.rmtree(base_path) → mkdir → reset_timestamps([])
UI 层(QML)负责弹确认对话框,桥层不做交互。
"""
try:
base = self._history.base_path
if base.exists():
shutil.rmtree(base)
base.mkdir(parents=True, exist_ok=True)
self._model.reset_timestamps([])
self._logger.info(f"历史记录已清空: {base}")
self.countChanged.emit()
return True
except Exception:
self._logger.exception("clearAll 失败")
return False
@Slot(str, result="QVariant")
def getItem(self, timestamp: str) -> Dict[str, Any]:
"""返回单条历史的 QML 友好 dict(路径 → str / Path 不暴露)。"""
......@@ -84,6 +110,43 @@ class HistoryBridge(QObject):
"createdAt": item.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
@Slot(str, result=bool)
def copyToClipboard(self, text: str) -> bool:
"""复制纯文本到系统剪贴板。失败返回 False。"""
try:
cb = QGuiApplication.clipboard()
if cb is None:
return False
cb.setText(text or "")
return True
except Exception:
self._logger.exception("copyToClipboard 失败")
return False
@Slot(str)
def revealInExplorer(self, path: str) -> None:
"""在系统文件管理器里打开 path 所在文件夹并选中该文件。
Windows: explorer /select,<path>
macOS: open -R <path>
Linux: xdg-open <parent_dir>(无法选中具体文件,只能开父目录)
"""
p = Path(path)
if not p.exists():
self._logger.warning(f"revealInExplorer: 文件不存在 {path}")
return
try:
system = platform.system()
if system == "Windows":
# 注意:explorer /select, 需要逗号紧跟在 /select 后,path 用 native 反斜杠
subprocess.Popen(["explorer", f"/select,{p}"])
elif system == "Darwin":
subprocess.Popen(["open", "-R", str(p)])
else:
subprocess.Popen(["xdg-open", str(p.parent)])
except Exception:
self._logger.exception(f"revealInExplorer 失败 {path}")
@Slot(str, result=str)
def thumbnailPath(self, timestamp: str) -> str:
"""返回该 timestamp 缩略图本地路径(按需生成)。源图缺失时返回 ""。"""
......
......@@ -14,11 +14,15 @@ from pathlib import Path
from typing import Optional
from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
from PySide6.QtGui import QGuiApplication
from PySide6.QtGui import QGuiApplication, QImage, QPixmap
from core.generation import MODEL_BY_MODE, MODEL_PRO
_PASTE_VALID_EXT = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
_IMAGE_MAX_BYTES = 10 * 1024 * 1024 # 10MB,与旧 validate_image_file 一致
class ImageGenBridge(QObject):
apiKeyChanged = Signal()
busyChanged = Signal()
......@@ -86,6 +90,27 @@ class ImageGenBridge(QObject):
self._persist_saved_prompts()
self.savedPromptsChanged.emit()
@Slot(str, result=bool)
def isSavedPrompt(self, prompt: str) -> bool:
"""QML 用来实时切换 ⭐/✓ 按钮态"""
return (prompt or "").strip() in self._saved_prompts
@Slot(str, result=bool)
def toggleSavedPrompt(self, prompt: str) -> bool:
"""已收藏 → 取消(返回 False);未收藏且非空 → 添加(返回 True)。"""
prompt = (prompt or "").strip()
if not prompt:
return False
if prompt in self._saved_prompts:
self._saved_prompts.remove(prompt)
self._persist_saved_prompts()
self.savedPromptsChanged.emit()
return False
self._saved_prompts.insert(0, prompt)
self._persist_saved_prompts()
self.savedPromptsChanged.emit()
return True
def _persist_saved_prompts(self) -> None:
"""更新 config.json 的 saved_prompts 字段(保留其他字段)。"""
if self._config_path is None:
......@@ -130,29 +155,124 @@ class ImageGenBridge(QObject):
self._tqm.cancel_task(task_id)
self.busyChanged.emit()
@Slot(result=str)
def pasteFromClipboard(self) -> str:
"""从系统剪贴板拿图片,存到 temp 目录,返回本地路径。
@Slot(result="QVariantList")
def pasteFromClipboard(self) -> list:
"""从系统剪贴板拿图片,返回本地路径列表(正斜杠风格)。失败返回空列表。
失败(剪贴板无图)返回空字符串。tempdir 启动期已被
_cleanup_clipboard_tempfiles 清过 24h+ 旧文件。
三条路径(按优先级,与旧 paste_from_clipboard / _safe_get_clipboard_image 对齐):
路径 A — mimeData.hasUrls():剪贴板是文件 URL(资源管理器 Ctrl+C 图片文件)
→ 直接拿 toLocalFile() 路径
路径 B — mimeData.data(mime_type) → QImage.loadFromData:剪贴板是图像 raw bytes
走 image/png / image/jpeg / image/bmp / image/tiff / application/x-qt-image,
按 mime 顺序尝试。微信截图 / QQ 截图 / 系统截图工具大多写 raw PNG / DIB 进剪贴板,
这条最稳,覆盖 clipboard.image() 不识别的截图工具。
路径 C — mimeData.hasImage() / imageData() / clipboard.image():兜底
"""
clipboard = QGuiApplication.clipboard()
if clipboard is None:
return ""
image = clipboard.image()
if image.isNull():
return ""
return []
mime_data = clipboard.mimeData()
# 路径 A:文件 URL
if mime_data is not None and mime_data.hasUrls():
files = []
for url in mime_data.urls():
if not url.isLocalFile():
continue
p = url.toLocalFile()
if p and Path(p).suffix.lower() in _PASTE_VALID_EXT:
files.append(Path(p).as_posix())
if files:
self._logger.info(f"剪贴板拿到 {len(files)} 个图片文件")
return files
# 路径 B / C:从剪贴板拿 QImage
image = self._extract_clipboard_image(clipboard)
if image is None or image.isNull():
self._logger.info("剪贴板既无图片文件也无图像数据")
return []
# 写入 temp PNG(先 normalize 到 ARGB32 防止 Indexed/Mono 格式产生异常 PNG)
normalized = image.convertToFormat(QImage.Format.Format_ARGB32)
tmp_dir = Path(tempfile.gettempdir()) / "nano_banana_app"
tmp_dir.mkdir(parents=True, exist_ok=True)
ts = time.strftime("%Y%m%d_%H%M%S")
path = tmp_dir / f"clipboard_{ts}_{uuid.uuid4().hex[:6]}.png"
if not image.save(str(path), "PNG"):
self._logger.warning(f"剪贴板图片保存失败: {path}")
return ""
ok = normalized.save(str(path), "PNG")
if not ok:
self._logger.warning(f"normalized.save 失败,回退原 image.save: {path}")
ok = image.save(str(path), "PNG")
if not ok:
self._logger.error(f"剪贴板图片保存失败: {path}")
return []
self._logger.info(f"剪贴板图片已存: {path}")
return str(path)
return [path.as_posix()]
def _extract_clipboard_image(self, clipboard) -> Optional[QImage]:
"""从剪贴板拿 QImage,多路径 fallback(路径 B + C)。"""
mime_data = clipboard.mimeData()
if mime_data is None:
try:
img = clipboard.image()
return img.copy() if img and not img.isNull() else None
except Exception:
return None
available_formats = list(mime_data.formats())
self._logger.info(f"[clipboard] 可用 MIME: {available_formats}")
# 路径 B:mimeData.data(mime) raw bytes → QImage.loadFromData
# 顺序:常见图片格式 → application/x-qt-image (Qt 内部 PNG-encoded)
for mime_type in ("image/png", "image/jpeg", "image/bmp",
"image/tiff", "application/x-qt-image"):
if mime_type in available_formats:
try:
raw = mime_data.data(mime_type)
if raw and len(raw) > 0:
img = QImage()
if img.loadFromData(raw):
self._logger.info(
f"[clipboard] 从 {mime_type} 加载成功, "
f"{img.width()}x{img.height()}, {len(raw)} bytes"
)
return img.copy()
else:
self._logger.warning(
f"[clipboard] {mime_type} loadFromData 失败"
f" ({len(raw)} bytes)"
)
except Exception:
self._logger.exception(f"[clipboard] 读 {mime_type} 异常")
# 路径 C1:mimeData.hasImage() / imageData()
try:
if mime_data.hasImage():
image_data = mime_data.imageData()
if isinstance(image_data, QImage) and not image_data.isNull():
self._logger.info(
f"[clipboard] imageData() 成功, "
f"{image_data.width()}x{image_data.height()}"
)
return image_data.copy()
except Exception:
self._logger.exception("[clipboard] imageData() 异常")
# 路径 C2:clipboard.image() 兜底
try:
img = clipboard.image()
if img and not img.isNull():
self._logger.info(f"[clipboard] clipboard.image() 成功")
return img.copy()
except Exception:
self._logger.exception("[clipboard] clipboard.image() 异常")
return None
@Slot(str, str, result=bool)
def saveFile(self, src: str, dest: str) -> bool:
......@@ -167,27 +287,90 @@ class ImageGenBridge(QObject):
shutil.copy2(str(src_path), str(dest_path))
self._logger.info(f"图片已下载: {dest}")
return True
except Exception as e:
self._logger.error(f"saveFile 失败 src={src} dest={dest}: {e}")
except Exception:
self._logger.exception(f"saveFile 失败 src={src} dest={dest}")
return False
@Slot("QVariantList", result="QVariantList")
def normalizeFileUrls(self, urls) -> list:
"""QML DropArea 给的是 file:/// QUrl 列表,转成本地路径字符串列表(过滤非图片)。"""
"""QML DropArea/FileDialog 给的是 file:/// QUrl 列表,转成本地路径字符串列表
(正斜杠风格,过滤非图片扩展名 + 10MB 大小校验 + QPixmap 完整性验证)。
"""
result = []
valid_ext = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
for u in urls or []:
try:
if isinstance(u, QUrl):
p = u.toLocalFile()
else:
p = QUrl(str(u)).toLocalFile() or str(u)
if p and Path(p).suffix.lower() in valid_ext:
result.append(p)
if not p:
continue
if not self._validate_image_file(p):
continue
# 统一正斜杠,避免 QML Image "file:///" + path 拼接异常
result.append(Path(p).as_posix())
except Exception:
self._logger.exception(f"normalizeFileUrls 处理 {u} 异常")
continue
return result
@Slot(str, result="QVariantMap")
def validateImageFile(self, file_path: str) -> dict:
"""对外暴露的图片校验,返回 {ok: bool, reason: str}(QML 友好)。
用于参考图来源(FileDialog / 拖拽 / 粘贴)的入口校验:
- 文件存在
- 扩展名在白名单(png/jpg/jpeg/gif/bmp/webp)
- 大小在 0 < size <= 10MB
- QPixmap 加载不为 isNull(PNG/JPEG header 完整)
"""
ok, reason = self._validate_image_file_with_reason(file_path)
return {"ok": ok, "reason": reason}
@Slot("QVariantList", result="QVariantList")
def validateImageFiles(self, paths) -> list:
"""批量校验:返回通过校验的本地路径列表(正斜杠风格)。"""
result = []
for p in paths or []:
sp = str(p)
if self._validate_image_file(sp):
result.append(Path(sp).as_posix())
return result
def _validate_image_file(self, file_path: str) -> bool:
ok, _ = self._validate_image_file_with_reason(file_path)
return ok
def _validate_image_file_with_reason(self, file_path: str):
"""与旧 validate_image_file 等价:扩展名 + 10MB + QPixmap 完整性。返回 (ok, reason)。"""
try:
p = Path(file_path)
if not p.exists():
return False, f"文件不存在: {file_path}"
ext = p.suffix.lower()
if ext not in _PASTE_VALID_EXT:
return False, f"不支持的格式: {ext}"
size = p.stat().st_size
if size == 0:
return False, f"文件为空: {file_path}"
if size > _IMAGE_MAX_BYTES:
mb = size / 1024 / 1024
return False, f"图片过大 {mb:.1f}MB(上限 10MB): {file_path}"
pix = QPixmap(str(p))
if pix.isNull():
return False, f"图片损坏或格式无效: {file_path}"
self._logger.info(
f"图片校验通过: {file_path} ({size} bytes, {pix.width()}x{pix.height()})"
)
return True, ""
except Exception:
self._logger.exception(f"_validate_image_file 异常: {file_path}")
return False, f"校验异常: {file_path}"
# ---- 内部信号转发 ----------------------------------------------------
def _on_task_added(self, task) -> None:
......
......@@ -22,15 +22,16 @@ class DatabaseManager:
self.logger = logging.getLogger(__name__)
def authenticate(self, username, password):
"""
验证用户凭证
返回: (success: bool, message: str)
"""
try:
self.logger.info(f"开始用户认证: {username}")
"""验证明文密码(前端首次输入)。返回 (success, message)。"""
return self._do_query(username, hash_password(password))
password_hash = hash_password(password)
def authenticate_with_hash(self, username, password_hash):
"""直接用已存的 hash 登录("记住密码"路径)。返回 (success, message)。"""
return self._do_query(username, password_hash)
def _do_query(self, username, password_hash):
try:
self.logger.info(f"开始用户认证: {username}")
self.logger.debug(f"连接数据库: {self.config['host']}:{self.config.get('port', 3306)}")
conn = pymysql.connect(
host=self.config['host'],
......@@ -62,5 +63,5 @@ class DatabaseManager:
return False, error_msg
except Exception as e:
error_msg = f"认证失败: {str(e)}"
self.logger.error(f"认证过程异常: {e}")
self.logger.exception("认证过程异常")
return False, error_msg
......
......@@ -153,5 +153,5 @@ class ImageGenerationWorker(QThread):
except Exception as e:
error_msg = f"图片生成异常: {e}"
self.logger.error(error_msg, exc_info=True)
self.logger.exception("图片生成异常")
self.error.emit(error_msg)
......
......@@ -175,8 +175,8 @@ class HistoryListModel(QAbstractListModel):
return cached
try:
item = self._history_manager.load_history_item_fast(timestamp)
except Exception as e:
self._logger.warning(f"[HistoryListModel] load 失败 {timestamp}: {e}")
except Exception:
self._logger.exception(f"[HistoryListModel] load 失败 {timestamp}")
item = None
if item is None:
placeholder = {
......@@ -220,8 +220,8 @@ class HistoryListModel(QAbstractListModel):
return self._build_placeholder_icon("图片\n加载失败")
scaled = pixmap.scaled(120, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation)
return QIcon(scaled)
except Exception as e:
self._logger.error(f"[HistoryListModel] 缩略图异常 {item.timestamp}: {e}")
except Exception:
self._logger.exception(f"[HistoryListModel] 缩略图异常 {item.timestamp}")
return self._build_placeholder_icon("图片\n错误")
def _put_cache(self, timestamp: str, value: Dict[str, Any]):
......@@ -247,8 +247,8 @@ class HistoryManager:
# 之后 load_history_index 不再做任何 stat 循环
try:
self._migrate_paths_once()
except Exception as e:
self.logger.warning(f"启动路径迁移失败 (可忽略): {e}")
except Exception:
self.logger.exception("启动路径迁移失败 (可忽略)")
def save_generation(self, image_bytes: bytes, prompt: str, reference_images: List[bytes],
aspect_ratio: str, image_size: str, model: str) -> str:
......@@ -302,8 +302,8 @@ class HistoryManager:
try:
self.get_or_create_thumbnail(generated_image_path)
except Exception as e:
self.logger.warning(f"保存历史记录时生成缩略图失败: {e}")
except Exception:
self.logger.exception("保存历史记录时生成缩略图失败")
self._cleanup_old_records()
......@@ -336,9 +336,9 @@ class HistoryManager:
img.thumbnail((size, size), Image.LANCZOS)
img.save(str(thumb_path), "JPEG", quality=75, optimize=True)
return thumb_path
except Exception as e:
except Exception:
try:
self.logger.warning(f"生成缩略图失败 {generated_image_path}: {e}")
self.logger.exception(f"生成缩略图失败 {generated_image_path}")
except Exception:
pass
return None
......@@ -359,8 +359,8 @@ class HistoryManager:
try:
with open(self.history_index_file, 'r', encoding='utf-8') as f:
raw = json.load(f)
except Exception as e:
self.logger.warning(f"_migrate_paths_once 读取 index 失败: {e}")
except Exception:
self.logger.exception("_migrate_paths_once 读取 index 失败")
return
if not isinstance(raw, list) or not raw:
return
......@@ -415,8 +415,8 @@ class HistoryManager:
try:
with open(self.history_index_file, 'w', encoding='utf-8') as f:
json.dump(raw, f, ensure_ascii=False, indent=2)
except Exception as e:
self.logger.error(f"[migrate_paths] 写回失败: {e}")
except Exception:
self.logger.exception("[migrate_paths] 写回失败")
def load_history_index(self) -> List[HistoryItem]:
"""加载历史记录索引(仅 raw read + from_dict + sort)。
......@@ -441,8 +441,8 @@ class HistoryManager:
continue
items.sort(key=lambda x: x.timestamp, reverse=True)
return items
except Exception as e:
self.logger.error(f"加载历史记录索引失败: {e}", exc_info=True)
except Exception:
self.logger.exception("加载历史记录索引失败")
return []
def get_history_item(self, timestamp: str) -> Optional[HistoryItem]:
......@@ -487,8 +487,8 @@ class HistoryManager:
model=metadata.get('model', ''),
created_at=created_at,
)
except Exception as e:
self.logger.warning(f"load_history_item_fast 失败 {timestamp}: {e}")
except Exception:
self.logger.exception(f"load_history_item_fast 失败 {timestamp}")
return None
def delete_history_item(self, timestamp: str) -> bool:
......@@ -512,12 +512,12 @@ class HistoryManager:
try:
with open(self.history_index_file, 'w', encoding='utf-8') as f:
json.dump(raw, f, ensure_ascii=False, indent=2)
except Exception as e:
self.logger.error(f"删除后写回索引失败: {e}")
except Exception:
self.logger.exception("删除后写回索引失败")
return True
except Exception as e:
self.logger.error(f"删除历史记录失败: {e}")
except Exception:
self.logger.exception("删除历史记录失败")
return False
def _update_history_index(self, history_item: HistoryItem):
......@@ -537,8 +537,8 @@ class HistoryManager:
raw = []
else:
raw = []
except Exception as e:
self.logger.error(f"_update_history_index 读取索引失败: {e}")
except Exception:
self.logger.exception("_update_history_index 读取索引失败")
raw = []
new_ts = history_item.timestamp
......@@ -548,8 +548,8 @@ class HistoryManager:
try:
with open(self.history_index_file, 'w', encoding='utf-8') as f:
json.dump(raw, f, ensure_ascii=False, indent=2)
except Exception as e:
self.logger.error(f"_update_history_index 写入索引失败: {e}")
except Exception:
self.logger.exception("_update_history_index 写入索引失败")
def _cleanup_old_records(self):
"""清理旧的历史记录,保持最大数量限制。max_history_count <= 0 表示不限制。"""
......@@ -562,8 +562,8 @@ class HistoryManager:
raw = json.load(f)
if not isinstance(raw, list):
return
except Exception as e:
self.logger.error(f"_cleanup_old_records 读取索引失败: {e}")
except Exception:
self.logger.exception("_cleanup_old_records 读取索引失败")
return
raw.sort(key=lambda d: d.get('timestamp', '') if isinstance(d, dict) else '', reverse=True)
......@@ -582,11 +582,11 @@ class HistoryManager:
if record_dir.exists():
try:
shutil.rmtree(record_dir)
except Exception as e:
self.logger.warning(f"删除旧记录失败 {ts}: {e}")
except Exception:
self.logger.exception(f"删除旧记录失败 {ts}")
try:
with open(self.history_index_file, 'w', encoding='utf-8') as f:
json.dump(keep, f, ensure_ascii=False, indent=2)
except Exception as e:
self.logger.error(f"_cleanup_old_records 写回索引失败: {e}")
except Exception:
self.logger.exception("_cleanup_old_records 写回索引失败")
......
......@@ -184,8 +184,8 @@ class JewelryLibraryManager:
self.logger.info(f"珠宝词库加载成功: {self.config_path}")
return library
except Exception as e:
self.logger.error(f"珠宝词库加载失败: {e},使用默认词库")
except Exception:
self.logger.exception("珠宝词库加载失败,使用默认词库")
library = DEFAULT_JEWELRY_LIBRARY.copy()
try:
self.save_library(library)
......@@ -208,8 +208,8 @@ class JewelryLibraryManager:
with open(self.config_path, 'w', encoding='utf-8') as f:
json.dump(library, f, ensure_ascii=False, indent=2)
self.logger.info(f"珠宝词库保存成功: {self.config_path}")
except Exception as e:
self.logger.error(f"珠宝词库保存失败: {e}")
except Exception:
self.logger.exception("珠宝词库保存失败")
raise
def add_item(self, category: str, value: str):
......
"""启动期 / 运行期工具:崩溃诊断、日志初始化、temp 清理、系统信息。
从 image_generator.py 顶部抽出,让 main_qml.py(QML 主入口)和旧 main()
(QWidget 入口,task #19 删)共用同一套启动序列。
"""
import faulthandler
import json
import logging
import os
import platform
import sys
import tempfile
import traceback
from datetime import datetime
from pathlib import Path
def flush_logs() -> None:
"""强制刷盘所有日志 handlers。
macOS 对 UI 线程卡顿超过阈值会 SIGKILL,缓冲区未刷盘的日志会丢失,
下次崩溃定位不到。在可疑阶段边界调用此函数保证日志落盘。
"""
try:
for h in logging.getLogger().handlers:
try:
h.flush()
except Exception:
pass
except Exception:
pass
def cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int:
"""清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。
仅删除 {tempdir}/nano_banana_app/clipboard_*.png / _clipboard_tmp.png 中
超过 max_age_hours 小时的文件。返回删除数量。
"""
try:
import time
temp_dir = Path(tempfile.gettempdir()) / "nano_banana_app"
if not temp_dir.exists():
return 0
cutoff = time.time() - max_age_hours * 3600
removed = 0
for p in temp_dir.iterdir():
try:
if not p.is_file():
continue
name = p.name
if not (name.startswith("clipboard_") or name == "_clipboard_tmp.png"):
continue
if p.stat().st_mtime < cutoff:
p.unlink()
removed += 1
except Exception:
pass
return removed
except Exception:
return 0
def get_crash_log_path() -> Path:
"""获取崩溃日志文件路径(尽早可用,不依赖任何初始化)"""
system = platform.system()
if system == "Darwin":
p = Path.home() / "Library/Application Support/ZB100ImageGenerator"
elif system == "Windows":
p = Path(os.environ.get("APPDATA", str(Path.home()))) / "ZB100ImageGenerator"
else:
p = Path.home() / ".config/zb100imagegenerator"
try:
p.mkdir(parents=True, exist_ok=True)
except Exception:
p = Path(tempfile.gettempdir()) / "ZB100ImageGenerator"
p.mkdir(parents=True, exist_ok=True)
return p / "crash_log.txt"
def enable_crash_diagnostics() -> None:
"""在最早期启用崩溃诊断工具:faulthandler / 全局 excepthook / Qt msg handler。"""
crash_log = get_crash_log_path()
# 1. faulthandler: segfault 时自动输出 Python 调用栈到文件
try:
crash_fh = open(crash_log, "a", encoding="utf-8")
crash_fh.write(f"\n{'=' * 60}\n")
crash_fh.write(f"[STARTUP] {datetime.now().isoformat()} - faulthandler 已启用\n")
crash_fh.flush()
faulthandler.enable(file=crash_fh, all_threads=True)
# 同时输出到 stderr (仅在 stderr 真实存在时;windowed build 下跳过)
if sys.stderr is not None:
try:
faulthandler.enable(file=sys.stderr, all_threads=True)
except (RuntimeError, ValueError):
pass
try:
print(f"崩溃诊断日志路径: {crash_log}")
except Exception:
pass
except Exception as e:
try:
print(f"faulthandler 启用失败: {e}")
except Exception:
pass
if sys.stderr is not None:
try:
faulthandler.enable()
except (RuntimeError, ValueError):
pass
# 2. 全局 Python 异常钩子
def _global_excepthook(exc_type, exc_value, exc_tb):
msg = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
logging.critical(f"未捕获异常:\n{msg}")
try:
with open(crash_log, "a", encoding="utf-8") as f:
f.write(f"\n[UNCAUGHT EXCEPTION] {datetime.now().isoformat()}\n{msg}\n")
except Exception:
pass
sys.__excepthook__(exc_type, exc_value, exc_tb)
sys.excepthook = _global_excepthook
# 3. Qt 消息拦截器
try:
from PySide6.QtCore import qInstallMessageHandler, QtMsgType
def _qt_message_handler(mode, context, message):
level_map = {
QtMsgType.QtDebugMsg: "QT_DEBUG",
QtMsgType.QtInfoMsg: "QT_INFO",
QtMsgType.QtWarningMsg: "QT_WARNING",
QtMsgType.QtCriticalMsg: "QT_CRITICAL",
QtMsgType.QtFatalMsg: "QT_FATAL",
}
level = level_map.get(mode, "QT_UNKNOWN")
location = f"{context.file}:{context.line}" if context.file else "unknown"
full_msg = f"[{level}] {location} - {message}"
if mode in (QtMsgType.QtWarningMsg, QtMsgType.QtCriticalMsg, QtMsgType.QtFatalMsg):
logging.warning(full_msg)
try:
with open(crash_log, "a", encoding="utf-8") as f:
f.write(f"[{datetime.now().isoformat()}] {full_msg}\n")
except Exception:
pass
else:
logging.debug(full_msg)
qInstallMessageHandler(_qt_message_handler)
except Exception as e:
print(f"Qt 消息拦截器安装失败: {e}")
def log_system_info() -> None:
"""记录系统环境信息,用于排查兼容性问题"""
logger = logging.getLogger(__name__)
info_lines = [
f"操作系统: {platform.system()} {platform.release()} ({platform.version()})",
f"架构: {platform.machine()}",
f"Python: {sys.version}",
f"打包模式: {'PyInstaller' if getattr(sys, 'frozen', False) else '开发环境'}",
]
try:
import PySide6
info_lines.append(f"PySide6: {PySide6.__version__}")
except Exception:
info_lines.append("PySide6: 版本获取失败")
try:
from PySide6.QtCore import qVersion
info_lines.append(f"Qt: {qVersion()}")
except Exception:
info_lines.append("Qt: 版本获取失败")
try:
import PIL
info_lines.append(f"Pillow: {PIL.__version__}")
except Exception:
info_lines.append("Pillow: 版本获取失败")
try:
from google import genai as _genai
info_lines.append(
f"google-genai: {_genai.__version__}" if hasattr(_genai, '__version__')
else "google-genai: 已加载"
)
except Exception:
info_lines.append("google-genai: 未安装或加载失败")
if getattr(sys, 'frozen', False):
info_lines.append(f"可执行路径: {sys.executable}")
if hasattr(sys, '_MEIPASS'):
info_lines.append(f"_MEIPASS: {sys._MEIPASS}")
full_info = "\n ".join(info_lines)
logger.info(f"系统环境信息:\n {full_info}")
# 同时写入崩溃日志
try:
crash_log = get_crash_log_path()
with open(crash_log, "a", encoding="utf-8") as f:
f.write(f"[SYSTEM INFO] {datetime.now().isoformat()}\n {full_info}\n")
except Exception:
pass
def init_logging(log_level=logging.INFO) -> bool:
"""初始化日志:智能选 logs 目录 + RotatingFileHandler + 可选 console。
从 config.json 的 logging_config 读配置(enabled / level / log_to_console /
max_bytes / backup_count),失败时用 INFO 级别 + 默认 5MB 滚动 + 控制台。
成功返回 True,失败返回 False。
"""
try:
# 智能选择 logs 目录
def _candidates():
system = platform.system()
cands = []
if getattr(sys, 'frozen', False) and system == "Darwin":
cands.append(Path.home() / "Library/Application Support/ZB100ImageGenerator/logs")
elif getattr(sys, 'frozen', False):
cands.append(Path(sys.executable).parent / "logs")
else:
# 开发环境:项目根目录(core/ 的上一级)
cands.append(Path(__file__).resolve().parent.parent / "logs")
if system == "Darwin":
cands.append(Path.home() / "Library/Application Support/ZB100ImageGenerator/logs")
cands.append(Path.home() / "Documents/ZB100ImageGenerator/logs")
elif system == "Windows":
cands.append(Path(os.environ.get("APPDATA", "")) / "ZB100ImageGenerator/logs")
cands.append(Path.home() / "Documents/ZB100ImageGenerator/logs")
else:
cands.append(Path.home() / ".config/zb100imagegenerator/logs")
cands.append(Path.home() / "Documents/ZB100ImageGenerator/logs")
return cands
def _writable(path: Path) -> bool:
try:
path.mkdir(parents=True, exist_ok=True)
test = path / ".write_test"
test.write_text("test")
test.unlink()
return True
except Exception:
return False
logs_dir = None
for c in _candidates():
if _writable(c):
logs_dir = c
print(f"使用logs目录: {logs_dir}")
break
if logs_dir is None:
logs_dir = Path(tempfile.gettempdir()) / "ZB100ImageGenerator_logs"
logs_dir.mkdir(exist_ok=True)
print(f"警告: 所有logs路径都不可用,使用临时目录: {logs_dir}")
# 读取 config.json 的 logging_config
script_dir = (
Path(__file__).resolve().parent.parent
if not getattr(sys, 'frozen', False)
else Path(sys.executable).parent
)
config_path = script_dir / "config.json"
logging_config = {"enabled": True, "level": "INFO", "log_to_console": True}
if config_path.exists():
try:
with open(config_path, 'r', encoding='utf-8') as f:
cfg = json.load(f)
logging_config = cfg.get("logging_config", logging_config)
except Exception as e:
print(f"加载日志配置失败: {e}")
if not logging_config.get("enabled", True):
print("日志系统已禁用")
return True
level_str = logging_config.get("level", "INFO").upper()
log_level = getattr(logging, level_str, logging.INFO)
logs_dir.mkdir(parents=True, exist_ok=True)
log_file = logs_dir / "app.log"
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
from logging.handlers import RotatingFileHandler
max_bytes = int(logging_config.get("max_bytes", 5 * 1024 * 1024))
backup_count = int(logging_config.get("backup_count", 5))
handlers = [RotatingFileHandler(log_file, maxBytes=max_bytes,
backupCount=backup_count, encoding='utf-8')]
if logging_config.get("log_to_console", True):
handlers.append(logging.StreamHandler())
logging.basicConfig(level=log_level, format=log_format, handlers=handlers, force=True)
logging.info(f"日志系统初始化完成 - 级别: {level_str}, 文件: {log_file}")
return True
except Exception as e:
print(f"日志系统初始化失败: {e}")
return False
"""QML 主入口 — 装载业务桥层 + Apple 风格 QtQuick UI。
启动序列:
1. 加载 config.json(API key / db_config / 历史最大数)
2. 实例化 core 业务(HistoryManager / JewelryLibraryManager / TaskQueueManager)
3. 实例化 audit_logger(落盘 + 后台 worker,task #18 全量启用)
4. 实例化 5 个桥(auth / imageGen / history / taskQueue / jewelry)+ AppState
5. 把它们注入 QQmlApplicationEngine context
6. load Main.qml
启动序列(与旧 image_generator.main() 对齐):
Phase 0 enable_crash_diagnostics(faulthandler / excepthook / Qt msgHandler)
Phase 1 init_logging(RotatingFileHandler 写 app.log,5MB×5 滚动)
Phase 2 log_system_info(OS / Python / PySide6 / Qt / Pillow / google-genai 版本)
Phase 2.5 cleanup_clipboard_tempfiles
Phase 3 config 路径 + 从 bundled 拷贝(frozen 时) + sync_bundled_api_key
Phase 4 QGuiApplication + 窗口图标
Phase 4.5 preflight_check(任一失败弹错退出,避免后续撞错)
Phase 5 业务核心(HistoryManager / JewelryLibraryManager / TaskQueueManager)
Phase 5.5 init_audit_logger(单例,后台 UploadWorker 跑)
Phase 6 实例化 5 个桥 + AppState
Phase 7 QQmlApplicationEngine 装 contextProperty 并 load Main.qml
Phase 8 退出前 audit shutdown / flush
跑:.venv/Scripts/pythonw.exe qml_poc/main_qml.py
"""
import logging
import os
import platform
import shutil
import sys
from pathlib import Path
from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot
from PySide6.QtGui import QGuiApplication
from PySide6.QtGui import QGuiApplication, QIcon
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
......@@ -25,16 +33,25 @@ ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from audit_logger import AuditLogger # noqa: E402
from audit_logger import init_audit_logger # noqa: E402
from bridges.auth import AuthBridge # noqa: E402
from bridges.history import HistoryBridge # noqa: E402
from bridges.imagegen import ImageGenBridge # noqa: E402
from bridges.jewelry import JewelryBridge # noqa: E402
from bridges.taskqueue import TaskQueueBridge # noqa: E402
from config_util import get_config_dir, get_config_path, load_config_safe # noqa: E402
from config_util import ( # noqa: E402
get_config_dir,
get_config_path,
sync_bundled_api_key,
)
from core.history import HistoryManager # noqa: E402
from core.jewelry import JewelryLibraryManager # noqa: E402
from core.paths import get_app_data_path # noqa: E402
from core.runtime import ( # noqa: E402
cleanup_clipboard_tempfiles,
enable_crash_diagnostics,
init_logging,
log_system_info,
)
from task_queue import TaskQueueManager # noqa: E402
logger = logging.getLogger(__name__)
......@@ -43,8 +60,9 @@ logger = logging.getLogger(__name__)
class AppState(QObject):
"""全局轻量状态。登录态委托给 AuthBridge,本类只管当前 tab 索引等纯 UI 状态。
PoC 调试:QML_AUTO_LOGIN=1 时跳过登录直接进主窗口;task #13 后 QML 改用
auth.loggedIn 替代 appState.loggedIn,本类的 loggedIn 字段也会被删掉。
PoC 调试:
QML_AUTO_LOGIN=1 跳过登录
QML_DEBUG_TAB=0|1|2 控制启动 tab
"""
loggedInChanged = Signal()
currentTabChanged = Signal()
......@@ -52,9 +70,7 @@ class AppState(QObject):
def __init__(self, auth_bridge: AuthBridge):
super().__init__()
self._auth = auth_bridge
# 兼容现有 QML:env QML_AUTO_LOGIN=1 时强制 loggedIn=True
self._poc_force_login = os.environ.get("QML_AUTO_LOGIN", "") == "1"
# 调试便捷:env QML_DEBUG_TAB=0/1/2 控制启动时默认 tab
try:
self._current_tab = int(os.environ.get("QML_DEBUG_TAB", "0"))
except ValueError:
......@@ -71,7 +87,6 @@ class AppState(QObject):
@Slot(str, str, result=bool)
def login(self, username: str, password: str) -> bool:
"""兼容旧 QML PoC:转发到 AuthBridge。task #13 后 QML 直接调 auth.login。"""
return self._auth.login(username, password)
@Slot()
......@@ -84,52 +99,184 @@ class AppState(QObject):
self.currentTabChanged.emit()
def _setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
# -------------------- 启动期 helpers --------------------
def _resolve_app_icon_path() -> Path | None:
"""定位窗口图标:开发环境读项目根,frozen 读 _MEIPASS / exe 同目录。"""
system = platform.system()
icon_name = "zb100_windows.ico" if system == "Windows" else "zb100_mac.icns"
candidates = []
if getattr(sys, "frozen", False):
if hasattr(sys, "_MEIPASS"):
candidates.append(Path(sys._MEIPASS) / icon_name)
candidates.append(Path(sys.executable).parent / icon_name)
if system == "Darwin":
candidates.append(Path(sys.executable).parent.parent / "Resources" / icon_name)
else:
candidates.append(ROOT / icon_name)
for p in candidates:
if p.exists():
return p
return None
def _ensure_user_config_from_bundle(config_path: Path) -> None:
"""frozen 启动时若用户目录缺 config.json,从打包 bundle 拷一份过来。"""
if config_path.exists() or not getattr(sys, "frozen", False):
return
bundled = None
if platform.system() == "Darwin":
bundled = Path(sys.executable).parent.parent / "Resources" / "config.json"
else:
bundled = Path(sys.executable).parent / "config.json"
if not bundled.exists() and hasattr(sys, "_MEIPASS"):
meipass = Path(sys._MEIPASS) / "config.json"
if meipass.exists():
bundled = meipass
if bundled and bundled.exists():
try:
config_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(bundled, config_path)
logger.info(f"[BOOT] 已从 bundled 拷贝 config 到 {config_path}")
except Exception:
logger.exception(f"[BOOT] 从 bundled 拷贝 config 失败")
def _resolve_logs_dir() -> Path:
"""从 init_logging 设过的 RotatingFileHandler 取 logs 目录路径,给 audit_logger 用。"""
for h in logging.getLogger().handlers:
base = getattr(h, "baseFilename", None)
if base:
return Path(base).parent
# 兜底
return get_config_dir()
# -------------------- main --------------------
def main():
_setup_logging()
QQuickStyle.setStyle("Basic")
# Phase 0: 崩溃诊断(最早执行)
print("[BOOT] Phase 0: 启用崩溃诊断…")
enable_crash_diagnostics()
# Phase 1: 日志系统
print("[BOOT] Phase 1: 初始化日志系统…")
if not init_logging():
print("警告:日志系统初始化失败,将继续运行但不写文件日志")
logger.info("[BOOT] Phase 1 完成: 日志系统就绪")
# Phase 2: 系统环境信息
log_system_info()
# Phase 2.5: 清理遗留剪贴板临时文件
try:
removed = cleanup_clipboard_tempfiles(max_age_hours=24)
if removed > 0:
logger.info(f"[BOOT] 已清理 {removed} 个超过 24 小时的剪贴板临时文件")
except Exception:
logger.exception("[BOOT] 清理剪贴板临时文件失败")
# Phase 3: config 路径 + 从 bundled 拷贝 + sync api_key
logger.info("[BOOT] Phase 3: 定位配置文件…")
config_dir = get_config_dir()
config_path = get_config_path()
_ensure_user_config_from_bundle(config_path)
try:
sync_bundled_api_key(config_path)
except Exception:
logger.exception("[BOOT] api_key 同步异常,继续启动")
# Phase 4: QGuiApplication + 窗口图标
logger.info("[BOOT] Phase 4: 创建 QGuiApplication…")
QQuickStyle.setStyle("Basic")
app = QGuiApplication(sys.argv)
app.setApplicationName("珠宝壹佰图像生成器")
app.setOrganizationName("ZB100")
# ---- 配置 + 业务核心 ------------------------------------------------
config_path = get_config_path()
config, err = load_config_safe(config_path)
if err:
logger.warning(f"config 加载警告: {err}")
icon_path = _resolve_app_icon_path()
if icon_path is not None:
app_icon = QIcon(str(icon_path))
if not app_icon.isNull():
app.setWindowIcon(app_icon)
logger.info(f"[BOOT] 窗口图标已设: {icon_path}")
else:
logger.warning(f"[BOOT] 图标文件无效: {icon_path}")
else:
logger.info("[BOOT] 未找到窗口图标资源(开发环境正常)")
# Phase 4.5: preflight 启动门禁
logger.info("[BOOT] Phase 4.5: 启动门禁 preflight…")
audit_queue_path = config_dir / "audit_queue.ndjson"
logs_dir = _resolve_logs_dir()
api_key = config.get("api_key", "") or ""
db_config = config.get("db_config") # None 时桥层走 PoC 模式
saved_prompts = config.get("saved_prompts", []) or []
config_dir = get_config_dir()
try:
from preflight import (
handle_preflight_failure,
handle_version_too_old,
is_version_error,
preflight_check,
)
except Exception:
logger.exception("[BOOT] preflight 模块 import 失败,跳过门禁")
loaded_config = {}
preflight_ok = True
else:
try:
preflight_ok, preflight_err, loaded_config = preflight_check(
config_path, audit_queue_path
)
except Exception:
logger.exception("[BOOT] preflight_check 抛异常,按通过处理")
preflight_ok = True
loaded_config = {}
preflight_err = ""
if not preflight_ok:
logger.error(f"[BOOT] preflight 失败: {preflight_err}")
try:
if is_version_error(preflight_err):
handle_version_too_old(preflight_err, logs_dir)
else:
handle_preflight_failure(preflight_err, logs_dir)
except Exception:
logger.exception("[BOOT] preflight 失败处理本身又出异常")
return # handle_* 会 sys.exit(1)
# Phase 5: 加载 config 字段
api_key = loaded_config.get("api_key", "") or ""
db_config = loaded_config.get("db_config") # preflight 通过时必非空
saved_prompts = loaded_config.get("saved_prompts", []) or []
last_user = loaded_config.get("last_user", "") or ""
saved_password_hash = loaded_config.get("saved_password_hash", "") or ""
# 业务核心
logger.info("[BOOT] Phase 5: 实例化业务核心…")
history_manager = HistoryManager()
jewelry_manager = JewelryLibraryManager(config_dir)
task_queue_manager = TaskQueueManager() # 单例
audit_logger = None
# Phase 5.5: audit_logger 单例
audit_logger_inst = None
if db_config:
try:
app_data = get_app_data_path()
audit_logger = AuditLogger(
audit_logger_inst = init_audit_logger(db_config, audit_queue_path, logs_dir)
audit_logger_inst.start()
logger.info("[BOOT] audit logger 已启动")
except Exception:
logger.exception("[BOOT] audit_logger 启动失败(不影响 UI)")
audit_logger_inst = None
# Phase 6: 桥层
logger.info("[BOOT] Phase 6: 实例化 QML 桥层…")
auth_bridge = AuthBridge(
db_config=db_config,
queue_path=app_data / "audit_queue.ndjson",
logs_dir=app_data / "logs",
audit_logger=audit_logger_inst,
last_user=last_user,
saved_password_hash=saved_password_hash,
config_path=config_path,
)
audit_logger.start()
except Exception as e:
logger.warning(f"audit_logger 启动失败(不影响 UI): {e}")
audit_logger = None
# ---- 桥层 ----------------------------------------------------------
auth_bridge = AuthBridge(db_config=db_config, audit_logger=audit_logger)
image_gen_bridge = ImageGenBridge(
task_queue_manager=task_queue_manager,
history_manager=history_manager,
......@@ -143,7 +290,7 @@ def main():
jewelry_bridge = JewelryBridge(library_manager=jewelry_manager)
app_state = AppState(auth_bridge=auth_bridge)
# 新生成完图后让 history 列表加新行(绕过手动 refresh)
# 新生成完图自动入历史 tab 列表
image_gen_bridge.taskCompleted.connect(
lambda task_id, result_path, prompt, model:
history_bridge.addNew(Path(result_path).parent.name)
......@@ -152,7 +299,8 @@ def main():
# 启动期预填一次历史
history_bridge.refresh()
# ---- QML 装载 ------------------------------------------------------
# Phase 7: QML 装载
logger.info("[BOOT] Phase 7: 装载 QML…")
engine = QQmlApplicationEngine()
ctx = engine.rootContext()
ctx.setContextProperty("appState", app_state)
......@@ -167,18 +315,24 @@ def main():
engine.load(QUrl.fromLocalFile(str(qml_dir / "Main.qml")))
if not engine.rootObjects():
print("QML load failed", file=sys.stderr)
logger.error("QML load failed")
sys.exit(1)
rc = app.exec()
# 退出前 flush audit 日志
if audit_logger:
# Phase 8: 退出 hook
def _flush_audit_on_quit():
if audit_logger_inst is None:
return
try:
audit_logger.shutdown(timeout=5.0)
audit_logger_inst.shutdown(timeout=5.0)
logger.info("[QUIT] audit logger 已 flush")
except Exception:
pass
logger.exception("[QUIT] audit shutdown 失败")
app.aboutToQuit.connect(_flush_audit_on_quit)
logger.info("[BOOT] 启动完成,进入主循环")
rc = app.exec()
logger.info(f"[QUIT] 主循环退出 rc={rc}")
sys.exit(rc)
......
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Dialogs
import QtQuick.Layouts
import "components"
import "." as App
......@@ -9,6 +10,62 @@ Item {
property string selectedTimestamp: ""
property var selectedItem: ({})
// 右键菜单当前操作的 timestamp
property string contextTimestamp: ""
// 单条删除确认
MessageDialog {
id: confirmDeleteDialog
title: "确认删除"
text: "确定要删除这条历史记录吗?这将删除相关的所有文件。"
buttons: MessageDialog.Yes | MessageDialog.No
onAccepted: {
if (tab.contextTimestamp) {
history.deleteItem(tab.contextTimestamp)
tab.contextTimestamp = ""
}
}
}
// 全部清空确认
MessageDialog {
id: confirmClearDialog
title: "确认清空"
text: "确定要清空所有历史记录吗?这将删除所有历史图片文件,且无法恢复。"
buttons: MessageDialog.Yes | MessageDialog.No
onAccepted: history.clearAll()
}
// 列表项右键菜单
Menu {
id: itemContextMenu
MenuItem {
text: "在文件管理器中打开"
onTriggered: {
if (tab.contextTimestamp) {
var item = history.getItem(tab.contextTimestamp)
if (item.generatedImagePath) {
history.revealInExplorer(item.generatedImagePath)
}
}
}
}
MenuItem {
text: "复制提示词"
onTriggered: {
if (tab.contextTimestamp) {
var item = history.getItem(tab.contextTimestamp)
if (item.prompt) history.copyToClipboard(item.prompt)
}
}
}
MenuSeparator {}
MenuItem {
text: "删除此项"
onTriggered: confirmDeleteDialog.open()
}
}
function selectTimestamp(ts) {
if (!ts) {
......@@ -60,7 +117,7 @@ Item {
spacing: App.Theme.space3
RowLayout {
spacing: App.Theme.space3
spacing: App.Theme.space2
CaptionLabel {
text: "历史记录"
font.pointSize: App.Theme.fontLg
......@@ -73,9 +130,14 @@ Item {
}
Item { Layout.fillWidth: true }
SecondaryButton {
text: "刷新"
text: "🔄 刷新"
onClicked: history.refresh()
}
SecondaryButton {
text: "🗑️ 清空"
enabled: history.count > 0
onClicked: confirmClearDialog.open()
}
}
// 空态
......@@ -173,7 +235,15 @@ Item {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: tab.selectTimestamp(timestamp)
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) {
tab.contextTimestamp = timestamp
itemContextMenu.popup()
} else {
tab.selectTimestamp(timestamp)
}
}
}
ToolTip.text: toolTip
......@@ -225,7 +295,7 @@ Item {
text: "在文件管理器中打开"
onClicked: {
if (tab.selectedItem.generatedImagePath) {
Qt.openUrlExternally("file:///" + tab.selectedItem.generatedImagePath)
history.revealInExplorer(tab.selectedItem.generatedImagePath)
}
}
}
......@@ -233,7 +303,8 @@ Item {
text: "删除"
onClicked: {
if (tab.selectedTimestamp) {
history.deleteItem(tab.selectedTimestamp)
tab.contextTimestamp = tab.selectedTimestamp
confirmDeleteDialog.open()
}
}
}
......@@ -242,7 +313,7 @@ Item {
// 大图
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 360
Layout.preferredHeight: 320
color: App.Theme.bgSubtle
radius: App.Theme.radiusMd
......@@ -268,7 +339,71 @@ Item {
}
}
// 元信息
// 参考图(可能没有)
ColumnLayout {
Layout.fillWidth: true
spacing: App.Theme.space2
visible: (tab.selectedItem.referenceImagePaths || []).length > 0
CaptionLabel {
text: "参考图(" + (tab.selectedItem.referenceImagePaths || []).length + " 张)"
}
Flow {
Layout.fillWidth: true
spacing: App.Theme.space2
Repeater {
model: tab.selectedItem.referenceImagePaths || []
delegate: Rectangle {
width: 80
height: 80
radius: App.Theme.radiusSm
color: App.Theme.bgSurface
border.width: 1
border.color: App.Theme.borderDefault
Image {
anchors.fill: parent
anchors.margins: 2
source: "file:///" + modelData
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: true
}
// 左下角 "图 N" badge
Rectangle {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: 3
width: refIdx.implicitWidth + 8
height: 16
radius: App.Theme.radiusSm
color: Qt.rgba(0, 0, 0, 0.6)
Text {
id: refIdx
anchors.centerIn: parent
text: "图 " + (index + 1)
color: "white"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontXs
font.weight: Font.DemiBold
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onDoubleClicked: Qt.openUrlExternally("file:///" + modelData)
}
}
}
}
}
// 元信息(不暴露具体模型 ID)
GridLayout {
Layout.fillWidth: true
columns: 4
......@@ -289,21 +424,34 @@ Item {
font.pointSize: App.Theme.fontSm
color: App.Theme.textPrimary
}
}
CaptionLabel { text: "模型" }
Label {
Layout.columnSpan: 3
text: tab.selectedItem.model || "—"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
color: App.Theme.textPrimary
elide: Text.ElideRight
// prompt header + 复制按钮
RowLayout {
Layout.fillWidth: true
spacing: App.Theme.space2
CaptionLabel { text: "提示词" }
Item { Layout.fillWidth: true }
SecondaryButton {
id: copyBtn
property bool justCopied: false
text: justCopied ? "✓ 已复制" : "📋 复制"
enabled: (tab.selectedItem.prompt || "").length > 0
onClicked: {
if (history.copyToClipboard(tab.selectedItem.prompt || "")) {
copyBtn.justCopied = true
copyResetTimer.restart()
}
}
// prompt
CaptionLabel { text: "提示词" }
Timer {
id: copyResetTimer
interval: 1500
onTriggered: copyBtn.justCopied = false
}
}
}
ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: 80
......
......@@ -10,11 +10,28 @@ Item {
// ===== 状态 =====
property var refImages: [] // list[str] 参考图本地路径
property string currentTaskId: "" // 当前进行中的任务 id(同一时刻最多一个)
property var myTaskIds: [] // 我提交过且还在跑/排队的 task id(支持多任务)
property string lastTaskId: "" // 最近一次提交的 task — 状态 / 进度显示锚点
property string lastResultPath: "" // 最近一次 taskCompleted 的图片路径
property string statusText: "● 就绪"
property color statusColor: App.Theme.success
// FLASH_ONLY_ASPECT_RATIOS — 与 core/generation.py 保持一致;这些比例仅极速模式支持
readonly property var flashOnlyAspectRatios: ["1:4", "4:1", "1:8", "8:1"]
function isMyTask(tid) {
return tab.myTaskIds.indexOf(tid) >= 0
}
function dropMyTask(tid) {
var copy = tab.myTaskIds.slice()
var i = copy.indexOf(tid)
if (i >= 0) copy.splice(i, 1)
tab.myTaskIds = copy
}
function isFlashOnly(ratio) {
return tab.flashOnlyAspectRatios.indexOf(ratio) >= 0
}
function addRefPath(p) {
if (!p) return
if (tab.refImages.indexOf(p) >= 0) return // 去重
......@@ -30,13 +47,87 @@ Item {
copy.splice(idx, 1)
tab.refImages = copy
}
function reorderRefAt(from, to) {
// pop + insert,与旧 reorder_image(QWidget 版) 等价
if (from === to) return
var copy = tab.refImages.slice()
if (from < 0 || from >= copy.length) return
if (to < 0) to = 0
if (to >= copy.length) to = copy.length - 1
var item = copy.splice(from, 1)[0]
copy.splice(to, 0, item)
tab.refImages = copy
}
// 拖拽中的源 index(-1 表示无拖拽进行中),用来给 source delegate 高亮
property int draggingIndex: -1
// 拖拽过程中鼠标 hover 到的目标 index(-1 表示当前不在任何 delegate 上)
// 只有 release 时才把 draggingIndex 真正 reorder 到这里
property int dropTargetIndex: -1
// 拖拽中鼠标坐标(相对 dropZone),驱动 ghost 跟手
property real dragGhostX: -1
property real dragGhostY: -1
FileDialog {
id: addImageDialog
title: "选择参考图片"
fileMode: FileDialog.OpenFiles
nameFilters: ["图片 (*.png *.jpg *.jpeg *.webp *.bmp)"]
onAccepted: tab.addRefPaths(imageGen.normalizeFileUrls(selectedFiles))
onAccepted: tab.addUrlsValidated(selectedFiles, "图片")
}
// 统一的"加 + 校验 + 反馈"流程,让用户知道是否有图被 10MB / 损坏拦截
function addUrlsValidated(urls, srcLabel) {
if (!urls || urls.length === 0) return
var paths = imageGen.normalizeFileUrls(urls) // 内部已过滤扩展名 + 10MB + QPixmap 完整性
var dropped = urls.length - paths.length
if (paths.length === 0) {
tab.statusText = "● 所有 " + urls.length + " 个" + srcLabel + "都校验失败(不支持的格式 / 超 10MB / 损坏)"
tab.statusColor = App.Theme.danger
return
}
tab.addRefPaths(paths)
if (dropped > 0) {
tab.statusText = "● 已添加 " + paths.length + " 张,丢弃 " + dropped + " 张(不支持 / 超 10MB / 损坏)"
tab.statusColor = App.Theme.warning
} else {
tab.statusText = "● 已添加 " + paths.length + " 张" + srcLabel
tab.statusColor = App.Theme.success
}
}
function pasteFromClipboardAction() {
var ps = imageGen.pasteFromClipboard()
if (!ps || ps.length === 0) {
tab.statusText = "● 剪贴板没有图片"
tab.statusColor = App.Theme.warning
return
}
// 路径 A 拿到的可能是用户复制的本地文件,要校验大小;
// 路径 B/C 是 Qt 写入 temp 的 PNG,必定通过校验
var validated = imageGen.validateImageFiles(ps)
var dropped = ps.length - validated.length
if (validated.length === 0) {
tab.statusText = "● 剪贴板图片校验失败(>10MB 或损坏)"
tab.statusColor = App.Theme.danger
return
}
tab.addRefPaths(validated)
if (dropped > 0) {
tab.statusText = "● 已粘贴 " + validated.length + " 张,丢弃 " + dropped + " 张(>10MB 或损坏)"
tab.statusColor = App.Theme.warning
} else {
tab.statusText = "● 已粘贴 " + validated.length + " 张图片"
tab.statusColor = App.Theme.success
}
}
// 全局 Ctrl+V 粘贴图片(仅在图片生成 tab 激活时生效;
// 焦点在 TextArea/TextField 里 Ctrl+V 优先走文本粘贴,不会触发这个 Shortcut)
Shortcut {
sequences: [StandardKey.Paste]
enabled: appState.currentTab === 0
onActivated: tab.pasteFromClipboardAction()
}
FileDialog {
......@@ -62,50 +153,56 @@ Item {
Connections {
target: imageGen
function onTaskSubmitted(taskId) {
tab.currentTaskId = taskId
tab.statusText = "● 已提交"
tab.statusColor = App.Theme.accent
}
// 进度只显示"最后提交的那一条"(最相关)
function onTaskProgress(taskId, progress, msg) {
if (taskId !== tab.currentTaskId) return
if (taskId !== tab.lastTaskId) return
tab.statusText = "● " + (msg || "生成中…")
tab.statusColor = App.Theme.accent
}
// 任何我提交过的 task 完成都更新预览(覆盖式 — 最后完成的优先)
function onTaskCompleted(taskId, resultPath, prompt, model) {
if (taskId !== tab.currentTaskId) return
if (!tab.isMyTask(taskId)) return
tab.lastResultPath = resultPath
tab.currentTaskId = ""
tab.dropMyTask(taskId)
if (tab.lastTaskId === taskId) tab.lastTaskId = ""
if (tab.myTaskIds.length === 0) {
tab.statusText = "● 已完成"
} else {
tab.statusText = "● 已完成(队列还剩 " + tab.myTaskIds.length + " 条)"
}
tab.statusColor = App.Theme.success
}
function onTaskFailed(taskId, error) {
if (taskId !== tab.currentTaskId) return
tab.currentTaskId = ""
if (!tab.isMyTask(taskId)) return
tab.dropMyTask(taskId)
if (tab.lastTaskId === taskId) tab.lastTaskId = ""
tab.statusText = "● " + (error || "失败")
tab.statusColor = App.Theme.danger
}
}
function submit() {
if (tab.currentTaskId !== "") return // busy
if (promptArea.text.trim().length === 0) {
tab.statusText = "● 请输入提示词"
tab.statusColor = App.Theme.danger
return
}
try {
imageGen.submitTask(
var taskId = imageGen.submitTask(
promptArea.text.trim(),
tab.refImages,
aspectCombo.currentText,
sizeCombo.currentText,
modeCombo.currentText
)
tab.myTaskIds = tab.myTaskIds.concat([taskId])
tab.lastTaskId = taskId
tab.statusText = "● 已提交(我的队列 " + tab.myTaskIds.length + " 条)"
tab.statusColor = App.Theme.accent
} catch (e) {
// TaskQueueManager 队列满(>10)时抛 RuntimeError,提示给用户
tab.statusText = "● " + e
tab.statusColor = App.Theme.danger
}
......@@ -144,14 +241,7 @@ Item {
}
SecondaryButton {
text: "📋 粘贴图片"
onClicked: {
var p = imageGen.pasteFromClipboard()
if (p) tab.addRefPath(p)
else {
tab.statusText = "● 剪贴板没有图片"
tab.statusColor = App.Theme.warning
}
}
onClicked: tab.pasteFromClipboardAction()
}
Label {
text: "已选择 " + tab.refImages.length + " 张"
......@@ -186,26 +276,51 @@ Item {
visible: tab.refImages.length === 0
}
// 缩略图 Flow(有图时显示)
ScrollView {
// 缩略图横向 ListView(与旧版 QHBoxLayout 一致),支持内部拖拽重排
ListView {
id: refList
anchors.fill: parent
anchors.margins: App.Theme.space2
orientation: ListView.Horizontal
spacing: App.Theme.space2
clip: true
interactive: false // 禁用 flick,让外部 ScrollBar 控制
visible: tab.refImages.length > 0
model: tab.refImages
Flow {
width: dropZone.width - App.Theme.space2 * 2
spacing: App.Theme.space2
ScrollBar.horizontal: ScrollBar {
policy: refList.contentWidth > refList.width
? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
}
Repeater {
model: tab.refImages
delegate: Rectangle {
id: thumb
width: 96
height: 96
radius: App.Theme.radiusSm
color: App.Theme.bgSurface
border.width: 1
border.color: App.Theme.borderDefault
// 三态视觉:
// 源(draggingIndex):scale 1.08 + accent 边
// drop target:accent 边 + accentSubtle 底色("放这里"指示)
// 普通:默认
readonly property bool isSource: tab.draggingIndex === index
readonly property bool isDropTarget: tab.dropTargetIndex === index && tab.draggingIndex >= 0 && tab.draggingIndex !== index
color: isDropTarget ? App.Theme.accentSubtle : App.Theme.bgSurface
scale: isSource ? 1.08 : 1.0
border.width: (isSource || isDropTarget) ? 3 : 1
border.color: (isSource || isDropTarget) ? App.Theme.accent : App.Theme.borderDefault
z: isSource ? 100 : 0
antialiasing: true
Behavior on scale {
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
}
Behavior on border.width {
NumberAnimation { duration: 120 }
}
Behavior on color {
ColorAnimation { duration: 100 }
}
Image {
anchors.fill: parent
......@@ -216,13 +331,109 @@ Item {
asynchronous: true
}
// 右上角删除按钮
// 左下角编号 badge "图 N"
Rectangle {
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: 4
width: idxText.implicitWidth + 10
height: 18
radius: App.Theme.radiusSm
color: Qt.rgba(0, 0, 0, 0.6)
z: 2
Text {
id: idxText
anchors.centerIn: parent
text: "图 " + (index + 1)
color: "white"
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontXs
font.weight: Font.DemiBold
}
}
// 用 DragHandler 替代 MouseArea:active 一旦激活就 grab 全局指针,
// 即使鼠标拖出 delegate / ListView / 主窗口范围,释放事件也保证收到,
// 不会出现 ghost 卡住 / 状态泄漏的情况。
HoverHandler {
id: hoverHandler
cursorShape: dragHandler.active ? Qt.ClosedHandCursor : Qt.OpenHandCursor
}
DragHandler {
id: dragHandler
target: null // 自己不移动 delegate;ghost 单独绘制
property bool moveBeyondThreshold: false
onActiveChanged: {
if (active) {
tab.draggingIndex = index
tab.dropTargetIndex = -1
moveBeyondThreshold = false
var p = mapToItem(dropZone, centroid.position.x, centroid.position.y)
tab.dragGhostX = p.x
tab.dragGhostY = p.y
} else {
// 释放:先把 reorder 决策快照下来,再立刻清状态,最后才 reorder。
// 关键顺序:reorder 改 model → ListView 重建 delegate → DragHandler 销毁,
// 后续代码可能不执行。所以必须先清状态。
var shouldReorder = moveBeyondThreshold
&& tab.draggingIndex >= 0
&& tab.dropTargetIndex >= 0
&& tab.dropTargetIndex !== tab.draggingIndex
var fromIdx = tab.draggingIndex
var toIdx = tab.dropTargetIndex
moveBeyondThreshold = false
tab.draggingIndex = -1
tab.dropTargetIndex = -1
tab.dragGhostX = -1
tab.dragGhostY = -1
if (shouldReorder) {
tab.reorderRefAt(fromIdx, toIdx)
}
}
}
onCentroidChanged: {
if (!active) return
// ghost 跟手
var p = mapToItem(dropZone, centroid.position.x, centroid.position.y)
tab.dragGhostX = p.x
tab.dragGhostY = p.y
// 8px 阈值防误触
if (!moveBeyondThreshold) {
var dx = centroid.position.x - centroid.pressPosition.x
if (Math.abs(dx) <= 8) return
moveBeyondThreshold = true
}
// 找鼠标 hover 的 delegate 作为 drop target
var pt = mapToItem(refList.contentItem, centroid.position.x, centroid.position.y)
// y 用 list 中线避免鼠标 y 越界(横向 list 行高 96,鼠标拖到上下空白时
// indexAt 会返回 -1,但 x 是有效的)
var t = refList.indexAt(pt.x, refList.height / 2)
if (t < 0 && tab.refImages.length > 0) {
// x 超出最后一个 delegate 右边界 → drop 到末尾
if (pt.x >= refList.contentWidth) {
t = tab.refImages.length - 1
} else if (pt.x < 0) {
t = 0
}
}
tab.dropTargetIndex = t
}
}
// 右上角删除按钮(z 最高,不被拖拽 MouseArea 拦截)
Rectangle {
width: 20; height: 20; radius: 10
color: App.Theme.danger
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 2
z: 3
Text {
anchors.centerIn: parent
......@@ -240,20 +451,48 @@ Item {
}
}
}
}
}
// 外部文件拖入(与内部缩略图重排是两套,靠 keys 区分)
DropArea {
id: dropArea
anchors.fill: parent
keys: ["text/uri-list"]
onDropped: function(drop) {
if (drop.urls && drop.urls.length > 0) {
tab.addRefPaths(imageGen.normalizeFileUrls(drop.urls))
tab.addUrlsValidated(drop.urls, "拖入图片")
drop.acceptProposedAction()
}
}
}
// 拖拽 ghost — 拖拽时跟随鼠标的浮层,让"被拖动的图片"明确跟手
Rectangle {
id: dragGhost
width: 96
height: 96
radius: App.Theme.radiusSm
color: App.Theme.bgSurface
border.width: 2
border.color: App.Theme.accent
visible: tab.draggingIndex >= 0 && tab.dragGhostX >= 0
x: tab.dragGhostX - 48
y: tab.dragGhostY - 48
z: 200
opacity: 0.9
scale: 1.08
antialiasing: true
Image {
anchors.fill: parent
anchors.margins: 2
source: (tab.draggingIndex >= 0 && tab.draggingIndex < tab.refImages.length)
? "file:///" + tab.refImages[tab.draggingIndex]
: ""
fillMode: Image.PreserveAspectCrop
smooth: true
asynchronous: true
}
}
}
}
}
......@@ -284,9 +523,19 @@ Item {
RowLayout {
spacing: App.Theme.space2
SecondaryButton {
text: "⭐ 收藏"
// isFavorited binding 依赖 promptArea.text + imageGen.savedPrompts,
// 任一变化都会重新评估
readonly property bool isFavorited: {
var p = promptArea.text.trim()
return p.length > 0 && imageGen.savedPrompts.indexOf(p) >= 0
}
text: isFavorited ? "✓ 已收藏" : "⭐ 收藏"
enabled: promptArea.text.trim().length > 0
onClicked: imageGen.addSavedPrompt(promptArea.text.trim())
onClicked: {
var nowFav = imageGen.toggleSavedPrompt(promptArea.text.trim())
tab.statusText = nowFav ? "● 已收藏" : "● 已取消收藏"
tab.statusColor = App.Theme.success
}
}
Label {
text: "快速选择:"
......@@ -306,6 +555,8 @@ Item {
onClicked: {
if (quickPromptCombo.currentText) {
imageGen.removeSavedPrompt(quickPromptCombo.currentText)
tab.statusText = "● 已删除提示词"
tab.statusColor = App.Theme.success
}
}
}
......@@ -356,6 +607,15 @@ Item {
id: modeCombo
Layout.fillWidth: true
model: ["极速模式", "慢速模式"]
onActivated: {
// 切到慢速但当前比例仅极速支持 → 自动回退到 1:1 + 提示
if (currentText === "慢速模式" && tab.isFlashOnly(aspectCombo.currentText)) {
var oldRatio = aspectCombo.currentText
aspectCombo.currentIndex = 0 // 1:1
tab.statusText = "● 已切到 1:1(慢速模式不支持 " + oldRatio + ")"
tab.statusColor = App.Theme.warning
}
}
}
}
......@@ -366,7 +626,18 @@ Item {
ThemedComboBox {
id: aspectCombo
Layout.fillWidth: true
model: ["1:1", "2:3", "3:2", "16:9", "9:16", "4:3", "3:4"]
// 与 core/generation.py 一致:14 个比例(前 10 通用,后 4 仅极速)
model: ["1:1", "2:3", "3:2", "3:4", "4:3", "4:5", "5:4",
"9:16", "16:9", "21:9",
"1:4", "4:1", "1:8", "8:1"]
onActivated: {
// 选了极速独占比例但当前是慢速 → 自动切到极速 + 提示
if (tab.isFlashOnly(currentText) && modeCombo.currentText !== "极速模式") {
modeCombo.currentIndex = 0
tab.statusText = "● 已自动切到极速模式(" + currentText + " 仅极速支持)"
tab.statusColor = App.Theme.warning
}
}
}
}
......@@ -392,8 +663,7 @@ Item {
Layout.fillWidth: true
PrimaryButton {
text: tab.currentTaskId !== "" ? "生成中…" : "生成图片"
enabled: tab.currentTaskId === ""
text: "生成图片"
onClicked: tab.submit()
}
SecondaryButton {
......
......@@ -11,21 +11,56 @@ Rectangle {
// 提交期间禁止重复点击 / 回车
property bool submitting: false
// 用户是否在密码框输入过 — 决定走 login(明文) 还是 loginWithSavedPassword(已存 hash)
property bool passwordChanged: false
Component.onCompleted: {
usernameField.text = auth.lastUser
// 上次记住的用户 → 焦点到密码框;否则到用户名
if (usernameField.text.length > 0 && auth.hasSavedPassword) {
passwordField.focus = true
} else {
usernameField.focus = true
}
}
function doLogin() {
if (submitting) return
if (usernameField.text.length === 0 || passwordField.text.length === 0) {
errorLabel.text = "用户名和密码不能为空"
var username = usernameField.text.trim()
if (username.length === 0) {
errorLabel.text = "请输入用户名"
return
}
// 决定走哪条路径:
// passwordChanged && text != "" → 明文 login
// !passwordChanged && hasSavedPassword → 用已存 hash 登录
// 其他(密码框空 + 没有已存 hash)→ 报错
var useSaved = !passwordChanged && auth.hasSavedPassword
if (!useSaved && passwordField.text.length === 0) {
errorLabel.text = "请输入密码"
return
}
errorLabel.text = ""
submitting = true
// AuthBridge.login 是同步(pymysql 5s timeout),主线程会卡一下;
// task #18 时若 db 慢需求改异步再说
var ok = auth.login(usernameField.text, passwordField.text)
var ok
if (useSaved) {
ok = auth.loginWithSavedPassword(username)
} else {
ok = auth.login(username, passwordField.text)
}
submitting = false
// 失败时 auth 会发 loginFailed 信号,由下面 Connections 接住
// 成功时 auth.loggedInChanged → AppState 转发 → Main.qml StackLayout 切换
// 登录成功 → 按勾选保存凭据。失败时 auth 已发 loginFailed 由 Connections 接
if (ok) {
// 没改过密码时传空字符串,桥层会保留旧 hash 不动
auth.saveCredentials(
passwordChanged ? passwordField.text : "",
rememberUser.checked,
rememberPassword.checked
)
}
}
Keys.onReturnPressed: doLogin()
......@@ -82,10 +117,12 @@ Rectangle {
ThemedTextField {
id: passwordField
echoMode: TextInput.Password
placeholderText: "••••••••"
// 已存密码时显示 8 个圆点占位(与旧 LoginDialog 一致)
placeholderText: auth.hasSavedPassword ? "••••••••" : "请输入密码"
enabled: !root.submitting
Layout.fillWidth: true
Layout.bottomMargin: App.Theme.space2
onTextEdited: root.passwordChanged = true
}
RowLayout {
......@@ -96,7 +133,7 @@ Rectangle {
CheckBox {
id: rememberUser
text: "记住用户名"
checked: true
checked: auth.lastUser.length > 0
enabled: !root.submitting
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
......@@ -129,7 +166,7 @@ Rectangle {
CheckBox {
id: rememberPassword
text: "记住密码"
checked: true
checked: auth.hasSavedPassword
enabled: !root.submitting
font.family: App.Theme.fontFamily
font.pointSize: App.Theme.fontSm
......
......@@ -21,8 +21,17 @@ Item {
})
property var lockedFields: [] // 被 🔓 锁定的字段名(不参与随机)
property string assembledPrompt: ""
property string currentTaskId: ""
property var myTaskIds: [] // 多任务支持:本 tab 提交过且还在跑/排队的 ids
property string lastTaskId: "" // 最近一次提交的 task — 进度显示锚点
property string lastResultPath: ""
function isMyTask(tid) { return tab.myTaskIds.indexOf(tid) >= 0 }
function dropMyTask(tid) {
var copy = tab.myTaskIds.slice()
var i = copy.indexOf(tid)
if (i >= 0) copy.splice(i, 1)
tab.myTaskIds = copy
}
property string statusText: "● 就绪"
property color statusColor: App.Theme.success
......@@ -78,7 +87,6 @@ Item {
}
function submit() {
if (tab.currentTaskId !== "") return
if (tab.assembledPrompt.trim().length === 0) {
tab.statusText = "● 选几个字段先"
tab.statusColor = App.Theme.danger
......@@ -88,8 +96,9 @@ Item {
var taskId = imageGen.submitTask(
tab.assembledPrompt, [], "1:1", "2K", "慢速模式"
)
tab.currentTaskId = taskId
tab.statusText = "● 已提交"
tab.myTaskIds = tab.myTaskIds.concat([taskId])
tab.lastTaskId = taskId
tab.statusText = "● 已提交(我的队列 " + tab.myTaskIds.length + " 条)"
tab.statusColor = App.Theme.accent
} catch (e) {
tab.statusText = "● " + e
......@@ -112,20 +121,24 @@ Item {
Connections {
target: imageGen
function onTaskProgress(taskId, progress, msg) {
if (taskId !== tab.currentTaskId) return
if (taskId !== tab.lastTaskId) return
tab.statusText = "● " + (msg || "生成中…")
tab.statusColor = App.Theme.accent
}
function onTaskCompleted(taskId, resultPath, prompt, model) {
if (taskId !== tab.currentTaskId) return
if (!tab.isMyTask(taskId)) return
tab.lastResultPath = resultPath
tab.currentTaskId = ""
tab.statusText = "● 已完成"
tab.dropMyTask(taskId)
if (tab.lastTaskId === taskId) tab.lastTaskId = ""
tab.statusText = tab.myTaskIds.length === 0
? "● 已完成"
: "● 已完成(队列还剩 " + tab.myTaskIds.length + " 条)"
tab.statusColor = App.Theme.success
}
function onTaskFailed(taskId, error) {
if (taskId !== tab.currentTaskId) return
tab.currentTaskId = ""
if (!tab.isMyTask(taskId)) return
tab.dropMyTask(taskId)
if (tab.lastTaskId === taskId) tab.lastTaskId = ""
tab.statusText = "● " + (error || "失败")
tab.statusColor = App.Theme.danger
}
......@@ -379,8 +392,7 @@ Item {
Layout.fillWidth: true
PrimaryButton {
text: tab.currentTaskId !== "" ? "生成中…" : "🎨 生成图片"
enabled: tab.currentTaskId === ""
text: "🎨 生成图片"
onClicked: tab.submit()
}
SecondaryButton {
......
......@@ -18,6 +18,7 @@ Pillow==11.1.0
httpx==0.28.1
h11==0.16.0
httpcore==1.0.9
socksio==1.0.0 # httpx SOCKS5 代理支持(Clash/V2Ray 用户必须)
websockets==15.0.1
requests==2.32.5
......