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 缩略图本地路径(按需生成)。源图缺失时返回 ""。"""
......
......@@ -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):
......
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
......
......@@ -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
......