feat(qml): task #12 业务桥层 — 5 个 QObject 暴露给 QML
bridges/
auth.py AuthBridge login/logout + currentUser/loggedIn (PoC 模式接受任意非空)
imagegen.py ImageGenBridge submitTask + 信号转发 (TaskQueueManager → QML)
history.py HistoryBridge 暴露 HistoryListModel + refresh/getItem/deleteItem
taskqueue.py TaskQueueBridge 自带 _TaskListModel (QAbstractListModel + roleNames)
jewelry.py JewelryBridge 词库增删 + previewPrompt
_icons.py build_placeholder_icon (旧 ImageGeneratorWindow.create_placeholder_icon
是实例方法,桥层独立一份)
main_qml.py 改造:
- 顶部加载 config.json + 实例化 core 业务 (HistoryManager / JewelryLibraryManager /
TaskQueueManager) + 启 audit_logger(有 db_config 时)
- 5 个桥通过 setContextProperty 注入:appState / auth / imageGen / history /
taskQueue / jewelry
- imageGen.taskCompleted → history.addNew(timestamp) 串起新生成图自动入历史
- AppState 保留作 currentTab 等 UI 状态(task #13 后 loggedIn 字段删,QML 改用 auth.loggedIn)
冒烟测试:
- 5 桥全 import OK,依赖注入构造 OK
- PoC 模式 login('test','x') 通过 / login('','') 拒绝
- history.refresh() 加载 2 条历史成功
- jewelry.previewPrompt({...}) 正确组装中文 prompt
- QML PoC 启动,登录页 + 主窗口(QML_AUTO_LOGIN=1)渲染完整无视觉回归
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Showing
8 changed files
with
694 additions
and
29 deletions
bridges/__init__.py
0 → 100644
bridges/_icons.py
0 → 100644
| 1 | """桥层共享的占位图标生成。 | ||
| 2 | |||
| 3 | 旧 ImageGeneratorWindow.create_placeholder_icon 是 QWidget 实例方法(依赖 self), | ||
| 4 | 桥层独立用一份不依赖 widget 实例的版本。 | ||
| 5 | """ | ||
| 6 | from PySide6.QtCore import Qt | ||
| 7 | from PySide6.QtGui import QFont, QIcon, QPainter, QPixmap | ||
| 8 | |||
| 9 | |||
| 10 | def build_placeholder_icon(text: str, size: int = 120) -> QIcon: | ||
| 11 | """绘制一张 size×size 的浅灰底中央文字占位图,用于历史项缩略图缺失时回退。""" | ||
| 12 | pixmap = QPixmap(size, size) | ||
| 13 | pixmap.fill(Qt.lightGray) | ||
| 14 | |||
| 15 | painter = QPainter(pixmap) | ||
| 16 | painter.setPen(Qt.black) | ||
| 17 | painter.setFont(QFont("Arial", 10)) | ||
| 18 | painter.drawText(pixmap.rect(), Qt.AlignCenter, text) | ||
| 19 | painter.end() | ||
| 20 | |||
| 21 | return QIcon(pixmap) |
bridges/auth.py
0 → 100644
| 1 | """AuthBridge — 登录认证 + 当前用户。 | ||
| 2 | |||
| 3 | 替代旧 LoginDialog。QML LoginScreen 调 auth.login(user, pwd),桥内部走 | ||
| 4 | DatabaseManager.authenticate(同步 MySQL,5s timeout)+ audit_logger.log_login。 | ||
| 5 | |||
| 6 | PoC 模式(db_config = None):接受任意非空用户名密码,便于无 db 环境调 UI。 | ||
| 7 | """ | ||
| 8 | import logging | ||
| 9 | import platform | ||
| 10 | import socket | ||
| 11 | from typing import Optional | ||
| 12 | |||
| 13 | from PySide6.QtCore import Property, QObject, Signal, Slot | ||
| 14 | |||
| 15 | from core.database import DatabaseManager | ||
| 16 | |||
| 17 | |||
| 18 | class AuthBridge(QObject): | ||
| 19 | loggedInChanged = Signal() | ||
| 20 | currentUserChanged = Signal() | ||
| 21 | loginFailed = Signal(str) # error_message | ||
| 22 | |||
| 23 | def __init__(self, db_config: Optional[dict] = None, audit_logger=None, parent=None): | ||
| 24 | super().__init__(parent) | ||
| 25 | self._logger = logging.getLogger(__name__) | ||
| 26 | self._db_config = db_config | ||
| 27 | self._db = DatabaseManager(db_config) if db_config else None | ||
| 28 | self._audit = audit_logger | ||
| 29 | self._logged_in = False | ||
| 30 | self._current_user = "" | ||
| 31 | |||
| 32 | @Property(bool, notify=loggedInChanged) | ||
| 33 | def loggedIn(self) -> bool: | ||
| 34 | return self._logged_in | ||
| 35 | |||
| 36 | @Property(str, notify=currentUserChanged) | ||
| 37 | def currentUser(self) -> str: | ||
| 38 | return self._current_user | ||
| 39 | |||
| 40 | @Slot(str, str, result=bool) | ||
| 41 | def login(self, username: str, password: str) -> bool: | ||
| 42 | username = (username or "").strip() | ||
| 43 | if not username or not password: | ||
| 44 | self.loginFailed.emit("用户名和密码不能为空") | ||
| 45 | return False | ||
| 46 | |||
| 47 | # PoC 模式:无 db_config 时接受任意非空 | ||
| 48 | if self._db is None: | ||
| 49 | self._on_login_success(username) | ||
| 50 | return True | ||
| 51 | |||
| 52 | ok, msg = self._db.authenticate(username, password) | ||
| 53 | if not ok: | ||
| 54 | self._logger.warning(f"登录失败: {username} - {msg}") | ||
| 55 | self.loginFailed.emit(msg) | ||
| 56 | return False | ||
| 57 | |||
| 58 | self._on_login_success(username) | ||
| 59 | return True | ||
| 60 | |||
| 61 | @Slot() | ||
| 62 | def logout(self) -> None: | ||
| 63 | self._logged_in = False | ||
| 64 | self._current_user = "" | ||
| 65 | self.loggedInChanged.emit() | ||
| 66 | self.currentUserChanged.emit() | ||
| 67 | |||
| 68 | @Slot(result=str) | ||
| 69 | def deviceName(self) -> str: | ||
| 70 | """供 audit 日志和 ImageGenBridge 使用""" | ||
| 71 | try: | ||
| 72 | return socket.gethostname() or platform.node() or "unknown" | ||
| 73 | except Exception: | ||
| 74 | return "unknown" | ||
| 75 | |||
| 76 | # ---- 内部 ----------------------------------------------------------- | ||
| 77 | |||
| 78 | def _on_login_success(self, username: str) -> None: | ||
| 79 | self._current_user = username | ||
| 80 | self._logged_in = True | ||
| 81 | self.currentUserChanged.emit() | ||
| 82 | self.loggedInChanged.emit() | ||
| 83 | self._logger.info(f"登录成功: {username}") | ||
| 84 | |||
| 85 | if self._audit is not None: | ||
| 86 | try: | ||
| 87 | self._audit.log_login( | ||
| 88 | user_name=username, | ||
| 89 | local_ip=self._get_local_ip(), | ||
| 90 | public_ip=None, # public ip 走慢路径,task #13 再加 | ||
| 91 | device_name=self.deviceName(), | ||
| 92 | ) | ||
| 93 | except Exception as e: | ||
| 94 | self._logger.warning(f"audit log_login 失败(不影响登录): {e}") | ||
| 95 | |||
| 96 | @staticmethod | ||
| 97 | def _get_local_ip() -> Optional[str]: | ||
| 98 | try: | ||
| 99 | with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: | ||
| 100 | s.settimeout(0.5) | ||
| 101 | s.connect(("8.8.8.8", 80)) | ||
| 102 | return s.getsockname()[0] | ||
| 103 | except Exception: | ||
| 104 | return None |
bridges/history.py
0 → 100644
| 1 | """HistoryBridge — 历史记录 tab 的列表 + 详情桥。 | ||
| 2 | |||
| 3 | 桥层暴露 HistoryListModel 给 QML ListView 直接用(已经是 QAbstractListModel)。 | ||
| 4 | QML 通过 history.refresh() 主动刷新;新生成图片完成时由 ImageGenBridge 串起来调 | ||
| 5 | itemAdded(task #16 wiring 时再补)。 | ||
| 6 | |||
| 7 | 详情查看走 getItem(timestamp) → dict,避免 QML 直接持有 HistoryItem dataclass。 | ||
| 8 | """ | ||
| 9 | import logging | ||
| 10 | from typing import Any, Dict | ||
| 11 | |||
| 12 | from PySide6.QtCore import Property, QObject, Signal, Slot | ||
| 13 | |||
| 14 | from core.history import HistoryListModel | ||
| 15 | from ._icons import build_placeholder_icon | ||
| 16 | |||
| 17 | |||
| 18 | class HistoryBridge(QObject): | ||
| 19 | countChanged = Signal() | ||
| 20 | itemAdded = Signal(str) # timestamp | ||
| 21 | itemRemoved = Signal(str) # timestamp | ||
| 22 | |||
| 23 | def __init__(self, history_manager, parent=None): | ||
| 24 | super().__init__(parent) | ||
| 25 | self._logger = logging.getLogger(__name__) | ||
| 26 | self._history = history_manager | ||
| 27 | self._model = HistoryListModel( | ||
| 28 | history_manager=history_manager, | ||
| 29 | build_placeholder_icon=build_placeholder_icon, | ||
| 30 | logger=self._logger, | ||
| 31 | parent=self, | ||
| 32 | ) | ||
| 33 | |||
| 34 | # ---- Properties ----------------------------------------------------- | ||
| 35 | |||
| 36 | @Property(QObject, constant=True) | ||
| 37 | def model(self) -> HistoryListModel: | ||
| 38 | """暴露给 QML ListView 的 model(QAbstractListModel)。""" | ||
| 39 | return self._model | ||
| 40 | |||
| 41 | @Property(int, notify=countChanged) | ||
| 42 | def count(self) -> int: | ||
| 43 | return self._model.rowCount() | ||
| 44 | |||
| 45 | # ---- Slots ---------------------------------------------------------- | ||
| 46 | |||
| 47 | @Slot() | ||
| 48 | def refresh(self) -> None: | ||
| 49 | """从磁盘重读 index.json,整体 reset model。""" | ||
| 50 | items = self._history.load_history_index() | ||
| 51 | self._model.reset_timestamps([item.timestamp for item in items]) | ||
| 52 | self.countChanged.emit() | ||
| 53 | self._logger.info(f"history refresh: {len(items)} 条") | ||
| 54 | |||
| 55 | @Slot(str) | ||
| 56 | def deleteItem(self, timestamp: str) -> None: | ||
| 57 | ok = self._history.delete_history_item(timestamp) | ||
| 58 | if ok: | ||
| 59 | self._model.remove_timestamp(timestamp) | ||
| 60 | self.itemRemoved.emit(timestamp) | ||
| 61 | self.countChanged.emit() | ||
| 62 | |||
| 63 | @Slot(str) | ||
| 64 | def addNew(self, timestamp: str) -> None: | ||
| 65 | """新生成完成时由 ImageGenBridge / 主线程调用,把新 timestamp 插到顶部。""" | ||
| 66 | self._model.prepend_timestamp(timestamp) | ||
| 67 | self.itemAdded.emit(timestamp) | ||
| 68 | self.countChanged.emit() | ||
| 69 | |||
| 70 | @Slot(str, result="QVariant") | ||
| 71 | def getItem(self, timestamp: str) -> Dict[str, Any]: | ||
| 72 | """返回单条历史的 QML 友好 dict(路径 → str / Path 不暴露)。""" | ||
| 73 | item = self._history.load_history_item_fast(timestamp) | ||
| 74 | if item is None: | ||
| 75 | return {} | ||
| 76 | return { | ||
| 77 | "timestamp": item.timestamp, | ||
| 78 | "prompt": item.prompt, | ||
| 79 | "generatedImagePath": str(item.generated_image_path), | ||
| 80 | "referenceImagePaths": [str(p) for p in item.reference_image_paths], | ||
| 81 | "aspectRatio": item.aspect_ratio, | ||
| 82 | "imageSize": item.image_size, | ||
| 83 | "model": item.model, | ||
| 84 | "createdAt": item.created_at.strftime("%Y-%m-%d %H:%M:%S"), | ||
| 85 | } |
bridges/imagegen.py
0 → 100644
| 1 | """ImageGenBridge — 图片生成 tab 的输入输出桥。 | ||
| 2 | |||
| 3 | QML 调 imageGen.submitTask(prompt, refs, aspect, size, mode) → 桥层把 mode 中文 | ||
| 4 | 名翻译成 Gemini 模型 ID,调 TaskQueueManager.submit_task。任务完成后桥层 | ||
| 5 | 监听 TaskQueueManager.task_completed 信号,把 image_bytes 落 HistoryManager.save_generation, | ||
| 6 | 然后把 result_path 通过 taskCompleted 信号转出(QML 只拿到文件路径,不传 bytes)。 | ||
| 7 | """ | ||
| 8 | import logging | ||
| 9 | from typing import Optional | ||
| 10 | |||
| 11 | from PySide6.QtCore import Property, QObject, Signal, Slot | ||
| 12 | |||
| 13 | from core.generation import MODEL_BY_MODE, MODEL_PRO | ||
| 14 | |||
| 15 | |||
| 16 | class ImageGenBridge(QObject): | ||
| 17 | apiKeyChanged = Signal() | ||
| 18 | busyChanged = Signal() | ||
| 19 | |||
| 20 | taskSubmitted = Signal(str) # task_id | ||
| 21 | taskCompleted = Signal(str, str, str, str) # task_id, result_path, prompt, model | ||
| 22 | taskFailed = Signal(str, str) # task_id, error_message | ||
| 23 | taskProgress = Signal(str, float, str) # task_id, progress, status_text | ||
| 24 | |||
| 25 | def __init__(self, task_queue_manager, history_manager, auth_bridge, | ||
| 26 | api_key: str = "", parent=None): | ||
| 27 | super().__init__(parent) | ||
| 28 | self._logger = logging.getLogger(__name__) | ||
| 29 | self._tqm = task_queue_manager | ||
| 30 | self._history = history_manager | ||
| 31 | self._auth = auth_bridge | ||
| 32 | self._api_key = api_key | ||
| 33 | |||
| 34 | # 转发 TaskQueueManager 信号 → 桥层 QML 友好信号 | ||
| 35 | self._tqm.task_added.connect(self._on_task_added) | ||
| 36 | self._tqm.task_progress.connect(self._on_progress) | ||
| 37 | self._tqm.task_completed.connect(self._on_completed) | ||
| 38 | self._tqm.task_failed.connect(self._on_failed) | ||
| 39 | |||
| 40 | # ---- Properties ----------------------------------------------------- | ||
| 41 | |||
| 42 | @Property(str, notify=apiKeyChanged) | ||
| 43 | def apiKey(self) -> str: | ||
| 44 | return self._api_key | ||
| 45 | |||
| 46 | @Property(bool, notify=busyChanged) | ||
| 47 | def busy(self) -> bool: | ||
| 48 | return self._tqm.get_running_count() > 0 | ||
| 49 | |||
| 50 | # ---- Slots ---------------------------------------------------------- | ||
| 51 | |||
| 52 | @Slot(str) | ||
| 53 | def setApiKey(self, key: str) -> None: | ||
| 54 | if key != self._api_key: | ||
| 55 | self._api_key = key | ||
| 56 | self.apiKeyChanged.emit() | ||
| 57 | |||
| 58 | @Slot(str, list, str, str, str, result=str) | ||
| 59 | def submitTask(self, prompt: str, reference_images: list, | ||
| 60 | aspect_ratio: str, image_size: str, mode: str) -> str: | ||
| 61 | """提交一条生成任务,返回 task_id。失败抛 RuntimeError。 | ||
| 62 | |||
| 63 | Args: | ||
| 64 | prompt: 中文提示词 | ||
| 65 | reference_images: 参考图本地路径 list[str] | ||
| 66 | aspect_ratio: '1:1' / '2:3' / ... | ||
| 67 | image_size: '1K' / '2K' / '4K' | ||
| 68 | mode: '极速模式' 或 '慢速模式' | ||
| 69 | """ | ||
| 70 | from task_queue import TaskType # 局部 import 避免桥层冷启动加载 Qt UI | ||
| 71 | |||
| 72 | model = MODEL_BY_MODE.get(mode, MODEL_PRO) | ||
| 73 | task_id = self._tqm.submit_task( | ||
| 74 | task_type=TaskType.IMAGE_GENERATION, | ||
| 75 | prompt=prompt, | ||
| 76 | api_key=self._api_key, | ||
| 77 | reference_images=list(reference_images or []), | ||
| 78 | aspect_ratio=aspect_ratio, | ||
| 79 | image_size=image_size, | ||
| 80 | model=model, | ||
| 81 | user_name=self._auth.currentUser if self._auth else "", | ||
| 82 | device_name=self._auth.deviceName() if self._auth else "", | ||
| 83 | ) | ||
| 84 | self._logger.info(f"提交生成任务: {task_id[:8]} - mode={mode}") | ||
| 85 | return task_id | ||
| 86 | |||
| 87 | @Slot(str) | ||
| 88 | def cancelTask(self, task_id: str) -> None: | ||
| 89 | self._tqm.cancel_task(task_id) | ||
| 90 | self.busyChanged.emit() | ||
| 91 | |||
| 92 | # ---- 内部信号转发 ---------------------------------------------------- | ||
| 93 | |||
| 94 | def _on_task_added(self, task) -> None: | ||
| 95 | self.taskSubmitted.emit(task.id) | ||
| 96 | self.busyChanged.emit() | ||
| 97 | |||
| 98 | def _on_progress(self, task_id: str, progress: float, status_text: str) -> None: | ||
| 99 | self.taskProgress.emit(task_id, progress, status_text) | ||
| 100 | |||
| 101 | def _on_completed(self, task_id: str, image_bytes: bytes, prompt: str, | ||
| 102 | reference_images: list, aspect_ratio: str, | ||
| 103 | image_size: str, model: str) -> None: | ||
| 104 | # 落历史记录(QML 只看路径) | ||
| 105 | try: | ||
| 106 | timestamp = self._history.save_generation( | ||
| 107 | image_bytes=image_bytes, | ||
| 108 | prompt=prompt, | ||
| 109 | reference_images=reference_images, | ||
| 110 | aspect_ratio=aspect_ratio, | ||
| 111 | image_size=image_size, | ||
| 112 | model=model, | ||
| 113 | ) | ||
| 114 | result_path = str(self._history.base_path / timestamp / "generated.png") | ||
| 115 | except Exception as e: | ||
| 116 | self._logger.error(f"保存历史失败 {task_id[:8]}: {e}", exc_info=True) | ||
| 117 | self.taskFailed.emit(task_id, f"图片生成成功但保存历史失败: {e}") | ||
| 118 | self.busyChanged.emit() | ||
| 119 | return | ||
| 120 | |||
| 121 | self.taskCompleted.emit(task_id, result_path, prompt, model) | ||
| 122 | self.busyChanged.emit() | ||
| 123 | |||
| 124 | def _on_failed(self, task_id: str, error: str) -> None: | ||
| 125 | self.taskFailed.emit(task_id, error) | ||
| 126 | self.busyChanged.emit() |
bridges/jewelry.py
0 → 100644
| 1 | """JewelryBridge — 款式设计 tab 桥层。 | ||
| 2 | |||
| 3 | QML 需要的能力: | ||
| 4 | - 拿全部 9 个类别名(categories) | ||
| 5 | - 按类别拿候选词(getOptions) | ||
| 6 | - 增删词条 + 重置类别 / 全部 | ||
| 7 | - 用户填完表后预览中文 prompt | ||
| 8 | |||
| 9 | 后端:core.jewelry.JewelryLibraryManager + PromptAssembler。 | ||
| 10 | """ | ||
| 11 | import logging | ||
| 12 | from typing import Any, Dict, List | ||
| 13 | |||
| 14 | from PySide6.QtCore import Property, QObject, Signal, Slot | ||
| 15 | |||
| 16 | from core.jewelry import PromptAssembler | ||
| 17 | |||
| 18 | |||
| 19 | class JewelryBridge(QObject): | ||
| 20 | libraryChanged = Signal(str) # category | ||
| 21 | |||
| 22 | def __init__(self, library_manager, parent=None): | ||
| 23 | super().__init__(parent) | ||
| 24 | self._logger = logging.getLogger(__name__) | ||
| 25 | self._lib = library_manager | ||
| 26 | |||
| 27 | @Property("QVariantList", constant=True) | ||
| 28 | def categories(self) -> List[str]: | ||
| 29 | """9 个类别的中文名(顺序固定,与 QML 表单一致)。""" | ||
| 30 | return list(self._lib.library.keys()) | ||
| 31 | |||
| 32 | @Slot(str, result="QVariantList") | ||
| 33 | def getOptions(self, category: str) -> List[str]: | ||
| 34 | return list(self._lib.library.get(category, [])) | ||
| 35 | |||
| 36 | @Slot(str, str) | ||
| 37 | def addItem(self, category: str, value: str) -> None: | ||
| 38 | self._lib.add_item(category, value) | ||
| 39 | self.libraryChanged.emit(category) | ||
| 40 | |||
| 41 | @Slot(str, str) | ||
| 42 | def removeItem(self, category: str, value: str) -> None: | ||
| 43 | self._lib.remove_item(category, value) | ||
| 44 | self.libraryChanged.emit(category) | ||
| 45 | |||
| 46 | @Slot(str) | ||
| 47 | def resetCategory(self, category: str) -> None: | ||
| 48 | self._lib.reset_category(category) | ||
| 49 | self.libraryChanged.emit(category) | ||
| 50 | |||
| 51 | @Slot() | ||
| 52 | def resetAll(self) -> None: | ||
| 53 | self._lib.reset_all() | ||
| 54 | for c in self._lib.library.keys(): | ||
| 55 | self.libraryChanged.emit(c) | ||
| 56 | |||
| 57 | @Slot("QVariantMap", result=str) | ||
| 58 | def previewPrompt(self, form_data: Dict[str, Any]) -> str: | ||
| 59 | """form_data: { "主石形状": "圆形", "金属": "18K黄金", ... } —— 空字段允许。""" | ||
| 60 | return PromptAssembler.assemble({k: str(v or "") for k, v in form_data.items()}) |
bridges/taskqueue.py
0 → 100644
| 1 | """TaskQueueBridge — 任务队列 sidebar 桥层。 | ||
| 2 | |||
| 3 | QML sidebar ListView 直接用 taskQueue.model(QAbstractListModel), | ||
| 4 | 桥层监听 TaskQueueManager 单例信号,按 task_id 增量更新 model。 | ||
| 5 | |||
| 6 | 只持有最近 N 条(与 TaskQueueManager 自身的 _max_history_size 一致), | ||
| 7 | 更老的任务会随 TaskQueueManager._cleanup_old_tasks 自然消失。 | ||
| 8 | """ | ||
| 9 | import logging | ||
| 10 | from datetime import datetime | ||
| 11 | from typing import Dict, List | ||
| 12 | |||
| 13 | from PySide6.QtCore import ( | ||
| 14 | Property, QAbstractListModel, QModelIndex, QObject, Qt, Signal, Slot, | ||
| 15 | ) | ||
| 16 | |||
| 17 | |||
| 18 | class _TaskListModel(QAbstractListModel): | ||
| 19 | """暴露给 QML ListView 的任务列表模型。 | ||
| 20 | |||
| 21 | Roles:taskId / prompt / status / progress / statusText / elapsed | ||
| 22 | """ | ||
| 23 | TaskIdRole = Qt.UserRole + 1 | ||
| 24 | PromptRole = Qt.UserRole + 2 | ||
| 25 | StatusRole = Qt.UserRole + 3 | ||
| 26 | ProgressRole = Qt.UserRole + 4 | ||
| 27 | StatusTextRole = Qt.UserRole + 5 | ||
| 28 | ElapsedRole = Qt.UserRole + 6 | ||
| 29 | |||
| 30 | def __init__(self, parent=None): | ||
| 31 | super().__init__(parent) | ||
| 32 | self._ids: List[str] = [] | ||
| 33 | self._rows: Dict[str, dict] = {} | ||
| 34 | |||
| 35 | def rowCount(self, parent=QModelIndex()) -> int: | ||
| 36 | if parent.isValid(): | ||
| 37 | return 0 | ||
| 38 | return len(self._ids) | ||
| 39 | |||
| 40 | def roleNames(self): | ||
| 41 | return { | ||
| 42 | _TaskListModel.TaskIdRole: b"taskId", | ||
| 43 | _TaskListModel.PromptRole: b"prompt", | ||
| 44 | _TaskListModel.StatusRole: b"status", | ||
| 45 | _TaskListModel.ProgressRole: b"progress", | ||
| 46 | _TaskListModel.StatusTextRole: b"statusText", | ||
| 47 | _TaskListModel.ElapsedRole: b"elapsed", | ||
| 48 | } | ||
| 49 | |||
| 50 | def data(self, index: QModelIndex, role: int = Qt.DisplayRole): | ||
| 51 | if not index.isValid(): | ||
| 52 | return None | ||
| 53 | row = index.row() | ||
| 54 | if row < 0 or row >= len(self._ids): | ||
| 55 | return None | ||
| 56 | record = self._rows.get(self._ids[row], {}) | ||
| 57 | if role == _TaskListModel.TaskIdRole: | ||
| 58 | return record.get("task_id", "") | ||
| 59 | if role == _TaskListModel.PromptRole: | ||
| 60 | return record.get("prompt", "") | ||
| 61 | if role == _TaskListModel.StatusRole: | ||
| 62 | return record.get("status", "") | ||
| 63 | if role == _TaskListModel.ProgressRole: | ||
| 64 | return float(record.get("progress", 0.0)) | ||
| 65 | if role == _TaskListModel.StatusTextRole: | ||
| 66 | return record.get("status_text", "") | ||
| 67 | if role == _TaskListModel.ElapsedRole: | ||
| 68 | return record.get("elapsed", "") | ||
| 69 | return None | ||
| 70 | |||
| 71 | # ---- 增量操作(桥层调用)--------------------------------------------- | ||
| 72 | |||
| 73 | def upsert(self, task_id: str, **fields) -> None: | ||
| 74 | if task_id in self._rows: | ||
| 75 | self._rows[task_id].update(fields) | ||
| 76 | row = self._ids.index(task_id) | ||
| 77 | top = self.index(row, 0) | ||
| 78 | self.dataChanged.emit(top, top) | ||
| 79 | else: | ||
| 80 | self.beginInsertRows(QModelIndex(), 0, 0) | ||
| 81 | self._ids.insert(0, task_id) | ||
| 82 | self._rows[task_id] = {"task_id": task_id, **fields} | ||
| 83 | self.endInsertRows() | ||
| 84 | |||
| 85 | |||
| 86 | class TaskQueueBridge(QObject): | ||
| 87 | pendingCountChanged = Signal() | ||
| 88 | runningCountChanged = Signal() | ||
| 89 | |||
| 90 | def __init__(self, task_queue_manager, parent=None): | ||
| 91 | super().__init__(parent) | ||
| 92 | self._logger = logging.getLogger(__name__) | ||
| 93 | self._tqm = task_queue_manager | ||
| 94 | self._model = _TaskListModel(self) | ||
| 95 | |||
| 96 | self._tqm.task_added.connect(self._on_task_added) | ||
| 97 | self._tqm.task_started.connect(self._on_task_started) | ||
| 98 | self._tqm.task_completed.connect(self._on_task_completed) | ||
| 99 | self._tqm.task_failed.connect(self._on_task_failed) | ||
| 100 | self._tqm.task_progress.connect(self._on_progress) | ||
| 101 | |||
| 102 | # ---- Properties ----------------------------------------------------- | ||
| 103 | |||
| 104 | @Property(QObject, constant=True) | ||
| 105 | def model(self): | ||
| 106 | return self._model | ||
| 107 | |||
| 108 | @Property(int, notify=pendingCountChanged) | ||
| 109 | def pendingCount(self) -> int: | ||
| 110 | return self._tqm.get_pending_count() | ||
| 111 | |||
| 112 | @Property(int, notify=runningCountChanged) | ||
| 113 | def runningCount(self) -> int: | ||
| 114 | return self._tqm.get_running_count() | ||
| 115 | |||
| 116 | # ---- Slots ---------------------------------------------------------- | ||
| 117 | |||
| 118 | @Slot(str) | ||
| 119 | def cancelTask(self, task_id: str) -> None: | ||
| 120 | self._tqm.cancel_task(task_id) | ||
| 121 | self.pendingCountChanged.emit() | ||
| 122 | self.runningCountChanged.emit() | ||
| 123 | |||
| 124 | # ---- 信号转 model 增量 ----------------------------------------------- | ||
| 125 | |||
| 126 | def _on_task_added(self, task) -> None: | ||
| 127 | self._model.upsert( | ||
| 128 | task.id, | ||
| 129 | prompt=task.prompt, | ||
| 130 | status="pending", | ||
| 131 | progress=0.0, | ||
| 132 | status_text="等待中", | ||
| 133 | elapsed="", | ||
| 134 | ) | ||
| 135 | self.pendingCountChanged.emit() | ||
| 136 | |||
| 137 | def _on_task_started(self, task_id: str) -> None: | ||
| 138 | self._model.upsert(task_id, status="running", status_text="生成中…") | ||
| 139 | self.pendingCountChanged.emit() | ||
| 140 | self.runningCountChanged.emit() | ||
| 141 | |||
| 142 | def _on_progress(self, task_id: str, progress: float, status_text: str) -> None: | ||
| 143 | self._model.upsert(task_id, progress=progress, status_text=status_text) | ||
| 144 | |||
| 145 | def _on_task_completed(self, task_id, *_args) -> None: | ||
| 146 | elapsed = self._format_elapsed(task_id) | ||
| 147 | self._model.upsert(task_id, status="completed", progress=1.0, | ||
| 148 | status_text="已完成", elapsed=elapsed) | ||
| 149 | self.runningCountChanged.emit() | ||
| 150 | |||
| 151 | def _on_task_failed(self, task_id: str, error: str) -> None: | ||
| 152 | elapsed = self._format_elapsed(task_id) | ||
| 153 | self._model.upsert(task_id, status="failed", status_text=error or "失败", | ||
| 154 | elapsed=elapsed) | ||
| 155 | self.pendingCountChanged.emit() | ||
| 156 | self.runningCountChanged.emit() | ||
| 157 | |||
| 158 | def _format_elapsed(self, task_id: str) -> str: | ||
| 159 | task = self._tqm.get_task(task_id) | ||
| 160 | if task and task.started_at and task.completed_at: | ||
| 161 | secs = (task.completed_at - task.started_at).total_seconds() | ||
| 162 | return f"{secs:.1f}s" | ||
| 163 | return "" |
| 1 | """QML PoC 入口 — 验证 QtQuick + Apple 风格自定义视觉。 | 1 | """QML 主入口 — 装载业务桥层 + Apple 风格 QtQuick UI。 |
| 2 | 2 | ||
| 3 | 只做 UI 演示: | 3 | 启动序列: |
| 4 | - 登录页 (LoginScreen.qml) | 4 | 1. 加载 config.json(API key / db_config / 历史最大数) |
| 5 | - 主窗口 (MainWindow.qml) 含 3 tab + 任务队列 sidebar | 5 | 2. 实例化 core 业务(HistoryManager / JewelryLibraryManager / TaskQueueManager) |
| 6 | - 图片生成 tab 的核心 UI 元素 | 6 | 3. 实例化 audit_logger(落盘 + 后台 worker,task #18 全量启用) |
| 7 | 4. 实例化 5 个桥(auth / imageGen / history / taskQueue / jewelry)+ AppState | ||
| 8 | 5. 把它们注入 QQmlApplicationEngine context | ||
| 9 | 6. load Main.qml | ||
| 7 | 10 | ||
| 8 | 不接业务后端(不真的生成图片 / 调 db),让用户先看视觉。 | ||
| 9 | 跑:.venv/Scripts/pythonw.exe qml_poc/main_qml.py | 11 | 跑:.venv/Scripts/pythonw.exe qml_poc/main_qml.py |
| 10 | """ | 12 | """ |
| 13 | import logging | ||
| 14 | import os | ||
| 11 | import sys | 15 | import sys |
| 12 | from pathlib import Path | 16 | from pathlib import Path |
| 13 | 17 | ||
| 14 | from PySide6.QtCore import QObject, QUrl, Property, Signal, Slot | 18 | from PySide6.QtCore import Property, QObject, QUrl, Signal, Slot |
| 15 | from PySide6.QtGui import QGuiApplication | 19 | from PySide6.QtGui import QGuiApplication |
| 16 | from PySide6.QtQml import QQmlApplicationEngine | 20 | from PySide6.QtQml import QQmlApplicationEngine |
| 17 | from PySide6.QtQuickControls2 import QQuickStyle | 21 | from PySide6.QtQuickControls2 import QQuickStyle |
| 18 | 22 | ||
| 23 | # 确保 import 路径包含项目根(qml_poc/ 跑 main_qml.py 时也能 import core/bridges) | ||
| 24 | ROOT = Path(__file__).resolve().parent.parent | ||
| 25 | if str(ROOT) not in sys.path: | ||
| 26 | sys.path.insert(0, str(ROOT)) | ||
| 27 | |||
| 28 | from audit_logger import AuditLogger # noqa: E402 | ||
| 29 | from bridges.auth import AuthBridge # noqa: E402 | ||
| 30 | from bridges.history import HistoryBridge # noqa: E402 | ||
| 31 | from bridges.imagegen import ImageGenBridge # noqa: E402 | ||
| 32 | from bridges.jewelry import JewelryBridge # noqa: E402 | ||
| 33 | from bridges.taskqueue import TaskQueueBridge # noqa: E402 | ||
| 34 | from config_util import get_config_dir, get_config_path, load_config_safe # noqa: E402 | ||
| 35 | from core.history import HistoryManager # noqa: E402 | ||
| 36 | from core.jewelry import JewelryLibraryManager # noqa: E402 | ||
| 37 | from core.paths import get_app_data_path # noqa: E402 | ||
| 38 | from task_queue import TaskQueueManager # noqa: E402 | ||
| 39 | |||
| 40 | logger = logging.getLogger(__name__) | ||
| 41 | |||
| 19 | 42 | ||
| 20 | class AppState(QObject): | 43 | class AppState(QObject): |
| 21 | """暴露给 QML 的全局状态。最小骨架,业务后续再接。""" | 44 | """全局轻量状态。登录态委托给 AuthBridge,本类只管当前 tab 索引等纯 UI 状态。 |
| 45 | |||
| 46 | PoC 调试:QML_AUTO_LOGIN=1 时跳过登录直接进主窗口;task #13 后 QML 改用 | ||
| 47 | auth.loggedIn 替代 appState.loggedIn,本类的 loggedIn 字段也会被删掉。 | ||
| 48 | """ | ||
| 22 | loggedInChanged = Signal() | 49 | loggedInChanged = Signal() |
| 23 | currentTabChanged = Signal() | 50 | currentTabChanged = Signal() |
| 24 | 51 | ||
| 25 | def __init__(self): | 52 | def __init__(self, auth_bridge: AuthBridge): |
| 26 | super().__init__() | 53 | super().__init__() |
| 27 | # PoC 调试:QML_AUTO_LOGIN=1 时直接登录态,免每次重启进程都发回车登录 | 54 | self._auth = auth_bridge |
| 28 | import os | 55 | # 兼容现有 QML:env QML_AUTO_LOGIN=1 时强制 loggedIn=True |
| 29 | self._logged_in = os.environ.get("QML_AUTO_LOGIN", "") == "1" | 56 | self._poc_force_login = os.environ.get("QML_AUTO_LOGIN", "") == "1" |
| 30 | self._current_tab = 0 | 57 | self._current_tab = 0 |
| 58 | self._auth.loggedInChanged.connect(self.loggedInChanged.emit) | ||
| 31 | 59 | ||
| 32 | @Property(bool, notify=loggedInChanged) | 60 | @Property(bool, notify=loggedInChanged) |
| 33 | def loggedIn(self): | 61 | def loggedIn(self) -> bool: |
| 34 | return self._logged_in | 62 | return self._poc_force_login or self._auth.loggedIn |
| 35 | 63 | ||
| 36 | @Property(int, notify=currentTabChanged) | 64 | @Property(int, notify=currentTabChanged) |
| 37 | def currentTab(self): | 65 | def currentTab(self) -> int: |
| 38 | return self._current_tab | 66 | return self._current_tab |
| 39 | 67 | ||
| 40 | @Slot(str, str, result=bool) | 68 | @Slot(str, str, result=bool) |
| 41 | def login(self, username: str, password: str) -> bool: | 69 | def login(self, username: str, password: str) -> bool: |
| 42 | # PoC:随便接受,业务不接 | 70 | """兼容旧 QML PoC:转发到 AuthBridge。task #13 后 QML 直接调 auth.login。""" |
| 43 | if not username or not password: | 71 | return self._auth.login(username, password) |
| 44 | return False | ||
| 45 | self._logged_in = True | ||
| 46 | self.loggedInChanged.emit() | ||
| 47 | return True | ||
| 48 | 72 | ||
| 49 | @Slot() | 73 | @Slot() |
| 50 | def logout(self): | 74 | def logout(self) -> None: |
| 51 | self._logged_in = False | 75 | self._auth.logout() |
| 52 | self.loggedInChanged.emit() | ||
| 53 | 76 | ||
| 54 | @Slot(int) | 77 | @Slot(int) |
| 55 | def setTab(self, idx: int): | 78 | def setTab(self, idx: int) -> None: |
| 56 | self._current_tab = idx | 79 | self._current_tab = idx |
| 57 | self.currentTabChanged.emit() | 80 | self.currentTabChanged.emit() |
| 58 | 81 | ||
| 59 | 82 | ||
| 83 | def _setup_logging(): | ||
| 84 | logging.basicConfig( | ||
| 85 | level=logging.INFO, | ||
| 86 | format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | ||
| 87 | ) | ||
| 88 | |||
| 89 | |||
| 60 | def main(): | 90 | def main(): |
| 61 | QQuickStyle.setStyle("Basic") # 纯净底,不引入 Material/Win11 的视觉污染 | 91 | _setup_logging() |
| 92 | QQuickStyle.setStyle("Basic") | ||
| 93 | |||
| 62 | app = QGuiApplication(sys.argv) | 94 | app = QGuiApplication(sys.argv) |
| 63 | app.setApplicationName("珠宝壹佰图像生成器") | 95 | app.setApplicationName("珠宝壹佰图像生成器") |
| 64 | app.setOrganizationName("ZB100") | 96 | app.setOrganizationName("ZB100") |
| 65 | 97 | ||
| 66 | state = AppState() | 98 | # ---- 配置 + 业务核心 ------------------------------------------------ |
| 99 | config_path = get_config_path() | ||
| 100 | config, err = load_config_safe(config_path) | ||
| 101 | if err: | ||
| 102 | logger.warning(f"config 加载警告: {err}") | ||
| 103 | |||
| 104 | api_key = config.get("api_key", "") or "" | ||
| 105 | db_config = config.get("db_config") # None 时桥层走 PoC 模式 | ||
| 106 | config_dir = get_config_dir() | ||
| 107 | |||
| 108 | history_manager = HistoryManager() | ||
| 109 | jewelry_manager = JewelryLibraryManager(config_dir) | ||
| 110 | task_queue_manager = TaskQueueManager() # 单例 | ||
| 111 | |||
| 112 | audit_logger = None | ||
| 113 | if db_config: | ||
| 114 | try: | ||
| 115 | app_data = get_app_data_path() | ||
| 116 | audit_logger = AuditLogger( | ||
| 117 | db_config=db_config, | ||
| 118 | queue_path=app_data / "audit_queue.ndjson", | ||
| 119 | logs_dir=app_data / "logs", | ||
| 120 | ) | ||
| 121 | audit_logger.start() | ||
| 122 | except Exception as e: | ||
| 123 | logger.warning(f"audit_logger 启动失败(不影响 UI): {e}") | ||
| 124 | audit_logger = None | ||
| 125 | |||
| 126 | # ---- 桥层 ---------------------------------------------------------- | ||
| 127 | auth_bridge = AuthBridge(db_config=db_config, audit_logger=audit_logger) | ||
| 128 | image_gen_bridge = ImageGenBridge( | ||
| 129 | task_queue_manager=task_queue_manager, | ||
| 130 | history_manager=history_manager, | ||
| 131 | auth_bridge=auth_bridge, | ||
| 132 | api_key=api_key, | ||
| 133 | ) | ||
| 134 | history_bridge = HistoryBridge(history_manager=history_manager) | ||
| 135 | task_queue_bridge = TaskQueueBridge(task_queue_manager=task_queue_manager) | ||
| 136 | jewelry_bridge = JewelryBridge(library_manager=jewelry_manager) | ||
| 137 | app_state = AppState(auth_bridge=auth_bridge) | ||
| 138 | |||
| 139 | # 新生成完图后让 history 列表加新行(绕过手动 refresh) | ||
| 140 | image_gen_bridge.taskCompleted.connect( | ||
| 141 | lambda task_id, result_path, prompt, model: | ||
| 142 | history_bridge.addNew(Path(result_path).parent.name) | ||
| 143 | ) | ||
| 144 | |||
| 145 | # 启动期预填一次历史 | ||
| 146 | history_bridge.refresh() | ||
| 147 | |||
| 148 | # ---- QML 装载 ------------------------------------------------------ | ||
| 67 | engine = QQmlApplicationEngine() | 149 | engine = QQmlApplicationEngine() |
| 68 | engine.rootContext().setContextProperty("appState", state) | 150 | ctx = engine.rootContext() |
| 151 | ctx.setContextProperty("appState", app_state) | ||
| 152 | ctx.setContextProperty("auth", auth_bridge) | ||
| 153 | ctx.setContextProperty("imageGen", image_gen_bridge) | ||
| 154 | ctx.setContextProperty("history", history_bridge) | ||
| 155 | ctx.setContextProperty("taskQueue", task_queue_bridge) | ||
| 156 | ctx.setContextProperty("jewelry", jewelry_bridge) | ||
| 69 | 157 | ||
| 70 | qml_dir = Path(__file__).parent / "qml" | 158 | qml_dir = Path(__file__).parent / "qml" |
| 71 | engine.addImportPath(str(qml_dir)) | 159 | engine.addImportPath(str(qml_dir)) |
| ... | @@ -75,7 +163,16 @@ def main(): | ... | @@ -75,7 +163,16 @@ def main(): |
| 75 | print("QML load failed", file=sys.stderr) | 163 | print("QML load failed", file=sys.stderr) |
| 76 | sys.exit(1) | 164 | sys.exit(1) |
| 77 | 165 | ||
| 78 | sys.exit(app.exec()) | 166 | rc = app.exec() |
| 167 | |||
| 168 | # 退出前 flush audit 日志 | ||
| 169 | if audit_logger: | ||
| 170 | try: | ||
| 171 | audit_logger.shutdown(timeout=5.0) | ||
| 172 | except Exception: | ||
| 173 | pass | ||
| 174 | |||
| 175 | sys.exit(rc) | ||
| 79 | 176 | ||
| 80 | 177 | ||
| 81 | if __name__ == "__main__": | 178 | if __name__ == "__main__": | ... | ... |
-
Please register or sign in to post a comment