main_qml.py
12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
"""QML 主入口 — 装载业务桥层 + Apple 风格 QtQuick UI。
启动序列(与旧 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, QIcon
from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
# 确保 import 路径包含项目根(qml_poc/ 跑 main_qml.py 时也能 import core/bridges)
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
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 ( # 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.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__)
class AppState(QObject):
"""全局轻量状态。登录态委托给 AuthBridge,本类只管当前 tab 索引等纯 UI 状态。
PoC 调试:
QML_AUTO_LOGIN=1 跳过登录
QML_DEBUG_TAB=0|1|2 控制启动 tab
"""
loggedInChanged = Signal()
currentTabChanged = Signal()
def __init__(self, auth_bridge: AuthBridge):
super().__init__()
self._auth = auth_bridge
self._poc_force_login = os.environ.get("QML_AUTO_LOGIN", "") == "1"
try:
self._current_tab = int(os.environ.get("QML_DEBUG_TAB", "0"))
except ValueError:
self._current_tab = 0
self._auth.loggedInChanged.connect(self.loggedInChanged.emit)
@Property(bool, notify=loggedInChanged)
def loggedIn(self) -> bool:
return self._poc_force_login or self._auth.loggedIn
@Property(int, notify=currentTabChanged)
def currentTab(self) -> int:
return self._current_tab
@Slot(str, str, result=bool)
def login(self, username: str, password: str) -> bool:
return self._auth.login(username, password)
@Slot()
def logout(self) -> None:
self._auth.logout()
@Slot(int)
def setTab(self, idx: int) -> None:
self._current_tab = idx
self.currentTabChanged.emit()
# -------------------- 启动期 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():
# 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")
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()
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() # 单例
# Phase 5.5: audit_logger 单例
audit_logger_inst = None
if db_config:
try:
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,
audit_logger=audit_logger_inst,
last_user=last_user,
saved_password_hash=saved_password_hash,
config_path=config_path,
)
image_gen_bridge = ImageGenBridge(
task_queue_manager=task_queue_manager,
history_manager=history_manager,
auth_bridge=auth_bridge,
api_key=api_key,
saved_prompts=saved_prompts,
config_path=config_path,
)
history_bridge = HistoryBridge(history_manager=history_manager)
task_queue_bridge = TaskQueueBridge(task_queue_manager=task_queue_manager)
jewelry_bridge = JewelryBridge(library_manager=jewelry_manager)
app_state = AppState(auth_bridge=auth_bridge)
# 新生成完图自动入历史 tab 列表
image_gen_bridge.taskCompleted.connect(
lambda task_id, result_path, prompt, model:
history_bridge.addNew(Path(result_path).parent.name)
)
# 启动期预填一次历史
history_bridge.refresh()
# Phase 7: QML 装载
logger.info("[BOOT] Phase 7: 装载 QML…")
engine = QQmlApplicationEngine()
ctx = engine.rootContext()
ctx.setContextProperty("appState", app_state)
ctx.setContextProperty("auth", auth_bridge)
ctx.setContextProperty("imageGen", image_gen_bridge)
ctx.setContextProperty("history", history_bridge)
ctx.setContextProperty("taskQueue", task_queue_bridge)
ctx.setContextProperty("jewelry", jewelry_bridge)
qml_dir = Path(__file__).parent / "qml"
engine.addImportPath(str(qml_dir))
engine.load(QUrl.fromLocalFile(str(qml_dir / "Main.qml")))
if not engine.rootObjects():
logger.error("QML load failed")
sys.exit(1)
# Phase 8: 退出 hook
def _flush_audit_on_quit():
if audit_logger_inst is None:
return
try:
audit_logger_inst.shutdown(timeout=5.0)
logger.info("[QUIT] audit logger 已 flush")
except Exception:
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)
if __name__ == "__main__":
main()