4908db24 by 柴进

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>
1 parent 12d6934e
1 """QML 桥层。每个 Bridge 是一个 QObject,通过 setContextProperty 注入 QML。
2
3 桥层职责:
4 - 把 core/* 的 Python 业务通过 Property/Signal/Slot 暴露给 QML
5 - 数据类型转换(Path → str / bytes 走文件而非传值 / 中文字段名透传)
6 - 不写业务逻辑:所有真业务在 core/* + task_queue.py + audit_logger.py
7
8 依赖注入:业务 Manager 在 main_qml.py 实例化一次,作为参数传给桥构造函数。
9 """
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)
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
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 }
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()
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()})
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__":
......