"""Main GUI window.""" from __future__ import annotations from PySide6.QtCore import QUrl from PySide6.QtGui import QDesktopServices from .. import subscription from ..version import display_name from .tabs.accounts import AccountsTab from .tabs.apply import ApplyTab from .tabs.collect import CollectTab from .tabs.generate import GenerateTab from .tabs.product_suite import ProductSuiteTab from .tabs.settings import SettingsTab from .widgets import * from .workers import SubscriptionCheckWorker PREFERRED_WINDOW_SIZE = (1180, 760) MIN_WINDOW_SIZE = (960, 640) WINDOW_SCREEN_MARGIN = 40 def _screen_available_geometry(): app = QApplication.instance() if app is None: return None screen = app.primaryScreen() if screen is None: return None return screen.availableGeometry() def _bounded_dimension(available_size, preferred_size, minimum_size, margin): if available_size <= 0: return preferred_size if available_size > margin * 2: maximum = available_size - margin * 2 else: maximum = available_size if maximum >= minimum_size: return max(minimum_size, min(preferred_size, maximum)) return max(1, min(preferred_size, maximum)) def _fit_and_center_window( window, available_geometry=None, preferred_size=PREFERRED_WINDOW_SIZE, minimum_size=MIN_WINDOW_SIZE, margin=WINDOW_SCREEN_MARGIN, ): available = available_geometry if available_geometry is not None else _screen_available_geometry() if available is None or available.width() <= 0 or available.height() <= 0: window.resize(*preferred_size) return width = _bounded_dimension( available.width(), preferred_size[0], minimum_size[0], margin, ) height = _bounded_dimension( available.height(), preferred_size[1], minimum_size[1], margin, ) window.resize(width, height) x = available.x() + max(0, (available.width() - width) // 2) y = available.y() + max(0, (available.height() - height) // 2) max_x = available.x() + max(0, available.width() - width) max_y = available.y() + max(0, available.height() - height) window.move(min(max(x, available.x()), max_x), min(max(y, available.y()), max_y)) class MainWindow(QMainWindow): """Main application window with the fixed workflow tabs.""" def __init__( self, db_path=None, config=None, config_path=None, ai_models_path=None, startup_status="", ): super().__init__() self._initial_window_fit_applied_after_show = False self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config self.config_path = ( config_path or self.config.get("config_path") or appconfig.CONFIG_PATH ) self.db_path = _database_path(db_path, self.config) self.ai_models_path = ( ai_models_path or self.config.get("ai_models_path") or appconfig.ai_models_config_path(self.config) ) self.setWindowTitle(display_name()) window_icon = app_icon() if window_icon is not None: self.setWindowIcon(window_icon) _fit_and_center_window(self) self.setStyleSheet(BUTTON_BASE_STYLE) self._settings_tab_index = TAB_TITLES.index("设置") self._last_tab_index = 0 self._reverting_tab_change = False self._subscription_status = subscription.SubscriptionStatus( subscription.STATUS_UNAVAILABLE ) self._subscription_worker = None self._subscription_thread = None self._subscription_request_token = 0 self._subscription_prompt_key = "" self._subscription_closed = False self.tabs = QTabWidget() self.tabs.setObjectName("mainTabs") self.tabs.setStyleSheet(TAB_STYLE) self.tabs.currentChanged.connect(self._on_tab_changed) for title in TAB_TITLES: self.tabs.addTab(self._build_tab(title), title) settings_tab = self._settings_tab() if hasattr(settings_tab, "settingsSaved"): settings_tab.settingsSaved.connect(self._on_settings_saved) self._header_title_label = QLabel(display_name()) self._header_title_label.setObjectName("appHeaderTitle") self._header_title_label.setStyleSheet( "font-size: 15px; font-weight: 600; color: #24292f;" ) self._subscription_label = QLabel("会员:正在验证") self._subscription_label.setObjectName("subscriptionStatusLabel") self._subscription_label.setAccessibleName("会员状态") self._subscription_label.setStyleSheet("color: #0969da;") header = QWidget() header.setObjectName("appHeader") header_layout = QHBoxLayout(header) header_layout.setContentsMargins(18, 8, 18, 4) header_layout.setSpacing(10) header_layout.addWidget(self._header_title_label) header_layout.addStretch(1) header_layout.addWidget(self._subscription_label) central = QWidget() central_layout = QVBoxLayout(central) central_layout.setContentsMargins(0, 0, 0, 0) central_layout.setSpacing(0) central_layout.addWidget(header) central_layout.addWidget(self.tabs, 1) self.setCentralWidget(central) self.show_status("就绪", level="muted") if startup_status: self.show_status(startup_status, level="success") def showEvent(self, event): super().showEvent(event) if not self._initial_window_fit_applied_after_show: _fit_and_center_window(self) self._initial_window_fit_applied_after_show = True def show_status(self, message, level="muted"): color = _status_level_color(level) self.statusBar().setStyleSheet(f"QStatusBar {{ color: {color}; }}") self.statusBar().showMessage(str(message)) def _build_tab(self, title): if title == "① 导入采集": return CollectTab( db_path=self.db_path, config=self.config, status_callback=self.show_status, open_accounts_callback=lambda: self.open_accounts_tab(), refresh_workflow_callback=lambda: self.refresh_task_tabs(), ) if title == "② AI生成": return GenerateTab( db_path=self.db_path, config=self.config, config_path=self.config_path, status_callback=self.show_status, title_prompt_path=appconfig.title_prompt_path(self.config), cover_prompts_dir=appconfig.cover_prompts_dir(self.config), open_accounts_callback=lambda: self.open_accounts_tab(), refresh_workflow_callback=lambda: self.refresh_task_tabs(), subscription_preflight_callback=self.ensure_subscription_for_new_submit, ) if title == "③ 更新蝦皮": return ApplyTab( db_path=self.db_path, config=self.config, status_callback=self.show_status, open_accounts_callback=lambda: self.open_accounts_tab(), open_settings_callback=lambda: self.open_settings_tab(), refresh_workflow_callback=lambda: self.refresh_task_tabs(), ) if title == "账号管理": return AccountsTab( db_path=self.db_path, config=self.config, status_callback=self.show_status, ) if title == "设置": return SettingsTab( config=self.config, config_path=self.config_path, ai_models_path=self.ai_models_path, status_callback=self.show_status, ) if title == "商品套图": return ProductSuiteTab( db_path=self.db_path, config=self.config, config_path=self.config_path, status_callback=self.show_status, subscription_preflight_callback=self.ensure_subscription_for_new_submit, ) raise ValueError(f"未知主界面模块:{title}") def refresh_task_tabs(self): for index in range(self.tabs.count()): widget = self.tabs.widget(index) if hasattr(widget, "refresh_tasks"): widget.refresh_tasks() def _on_settings_saved(self, backend): for index in range(self.tabs.count()): widget = self.tabs.widget(index) if hasattr(widget, "refresh_gateway_state"): widget.refresh_gateway_state() label = "自定义网关" if str(backend) == "direct" else "默认网关" self.show_status("设置已保存,当前使用%s" % label, level="success") self.begin_subscription_check(show_prompt=False) @property def subscription_status(self): return self._subscription_status def begin_subscription_check(self, *, show_prompt=True): """Refresh membership state without blocking the Qt GUI thread.""" self._subscription_request_token += 1 token = self._subscription_request_token if self._subscription_worker is not None: self._subscription_worker.cancel() self._set_product_access(False) self._set_subscription_label("会员:正在验证", "info") worker = SubscriptionCheckWorker( config=self.config, cmhub_config_path=self.config.get("cmhub_config_path"), ) worker.finished.connect( lambda payload, token=token, show_prompt=show_prompt: self._on_subscription_checked( token, payload, show_prompt=show_prompt, ) ) worker.cancelled.connect( lambda payload, token=token, show_prompt=show_prompt: self._on_subscription_checked( token, payload, show_prompt=show_prompt, ) ) thread = run_worker(worker, thread_name="SubscriptionCheckWorker", start=False) thread.finished.connect(lambda: self._forget_subscription_thread(thread)) self._subscription_worker = worker self._subscription_thread = thread thread.start() def ensure_subscription_for_new_submit(self, action_name="生成"): """Return whether a new product request may start from the current UI.""" status = self._subscription_status if status.allows_product_workflows: return True if self._subscription_thread is not None: self.show_status("正在验证会员状态,请稍后再%s" % action_name, level="info") return False self.show_status("%s:%s" % (action_name, status.user_message), level="warning") self._show_subscription_access_prompt(status) return False def _on_subscription_checked(self, token, payload, *, show_prompt=True): if self._subscription_closed or token != self._subscription_request_token: return result = payload.get("subscription") if isinstance(payload, dict) else None if not isinstance(result, subscription.SubscriptionStatus): result = subscription.SubscriptionStatus(subscription.STATUS_UNAVAILABLE) self._subscription_status = result self._apply_subscription_status(result, show_prompt=show_prompt) def _forget_subscription_thread(self, thread): if self._subscription_thread is thread: self._subscription_thread = None self._subscription_worker = None def _apply_subscription_status(self, status, *, show_prompt=True): self._subscription_status = status if status.state == subscription.STATUS_ACTIVE: expiry = subscription.format_expiry(status.expires_at) self._set_subscription_label( "%s · %s · 有效至%s" % (status.account_name, status.plan_name, expiry), "success", ) elif status.state == subscription.STATUS_GRACE: expiry = subscription.format_expiry(status.grace_expires_at) or subscription.format_expiry(status.expires_at) self._set_subscription_label( "%s · %s · 宽限至%s" % (status.account_name, status.plan_name, expiry), "warning", ) else: self._set_subscription_label("会员:%s" % status.user_message, self._subscription_level(status)) self._set_product_access(status.allows_product_workflows) if status.allows_product_workflows: if status.state == subscription.STATUS_LEGACY: self.show_status(status.user_message, level="muted") else: self.show_status("会员状态已验证", level="success") self._show_subscription_notice_once(status) return self.open_settings_tab() self.show_status(status.user_message, level=self._subscription_level(status)) if show_prompt: self._show_subscription_access_prompt(status) @staticmethod def _subscription_level(status): if status.state in {subscription.STATUS_UNAVAILABLE, subscription.STATUS_KEY_INVALID}: return "warning" return "danger" def _set_subscription_label(self, text, level): color = _status_level_color(level) self._subscription_label.setText(str(text)) self._subscription_label.setStyleSheet("color: %s;" % color) self._subscription_label.setToolTip(str(text)) def _set_product_access(self, enabled): for index, title in enumerate(TAB_TITLES): self.tabs.setTabEnabled(index, bool(enabled) or title == "设置") def _show_subscription_access_prompt(self, status): if self._subscription_closed: return if status.state in {subscription.STATUS_ACTIVE, subscription.STATUS_GRACE, subscription.STATUS_LEGACY}: return prompt_key = "|".join((status.state, status.notice_id, status.manage_url)) if prompt_key and prompt_key == self._subscription_prompt_key: return self._subscription_prompt_key = prompt_key box = QMessageBox(self) box.setIcon(QMessageBox.Warning) box.setWindowTitle("需要配置会员账号") box.setText(status.user_message) box.setInformativeText("请配置有效的 cmhub API Key,或前往线上会员中心开通、续费后重试。") settings_button = box.addButton("打开设置", QMessageBox.AcceptRole) manage_button = None if status.manage_url: manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole) exit_button = box.addButton("退出", QMessageBox.RejectRole) box.setDefaultButton(settings_button) box.setEscapeButton(exit_button) box.exec() if box.clickedButton() is settings_button: self.open_settings_tab() elif manage_button is not None and box.clickedButton() is manage_button: QDesktopServices.openUrl(QUrl(status.manage_url)) elif box.clickedButton() is exit_button: self.close() def _show_subscription_notice_once(self, status): notice_id = str(status.notice_id or "").strip() if not notice_id or notice_id == appconfig.subscription_notice_id(self.config): return box = QMessageBox(self) box.setIcon(QMessageBox.Information) box.setWindowTitle("已启用会员订阅") expiry = subscription.format_expiry( status.grace_expires_at if status.state == subscription.STATUS_GRACE else status.expires_at ) text = "已启用蝦皮圈会员订阅服务。当前账号:%s;套餐:%s。" % ( status.account_name, status.plan_name, ) if expiry: text += "当前有效至%s。" % expiry text += "请在到期前前往线上会员中心选择或续费套餐。" box.setText(text) manage_button = None if status.manage_url: manage_button = box.addButton("前往会员中心", QMessageBox.ActionRole) close_button = box.addButton("关闭", QMessageBox.AcceptRole) box.setDefaultButton(close_button) box.exec() if manage_button is not None and box.clickedButton() is manage_button: QDesktopServices.openUrl(QUrl(status.manage_url)) try: saved = appconfig.save_subscription_notice_id( notice_id, path=self.config_path, ) except Exception: return self.config.clear() self.config.update(saved) def _on_tab_changed(self, index): if self._reverting_tab_change: self._last_tab_index = index self.show_status(f"当前:{self.tabs.tabText(index)}", level="muted") return previous = self._last_tab_index if previous == self._settings_tab_index and index != previous: if not self._confirm_leave_settings_tab(): self._reverting_tab_change = True try: self.tabs.setCurrentIndex(previous) finally: self._reverting_tab_change = False self._last_tab_index = previous self.show_status(f"当前:{self.tabs.tabText(previous)}", level="muted") return self._last_tab_index = index self.show_status(f"当前:{self.tabs.tabText(index)}", level="muted") def _settings_tab(self): return self.tabs.widget(self._settings_tab_index) def _confirm_leave_settings_tab(self): settings_tab = self._settings_tab() if not hasattr(settings_tab, "is_dirty") or not settings_tab.is_dirty(): return True box = QMessageBox(self) box.setWindowTitle("未保存更改") box.setText("设置有未保存更改。要先保存再离开吗?") save_button = box.addButton("保存", QMessageBox.AcceptRole) discard_button = box.addButton("放弃", QMessageBox.DestructiveRole) box.addButton("取消", QMessageBox.RejectRole) box.setDefaultButton(save_button) box.exec() clicked = box.clickedButton() if clicked is save_button: return bool(settings_tab.save_app_settings()) if clicked is discard_button: return bool(settings_tab.discard_unsaved_changes()) return False def closeEvent(self, event): if self._confirm_leave_settings_tab(): self._subscription_closed = True if self._subscription_worker is not None: self._subscription_worker.cancel() event.accept() return event.ignore() def open_accounts_tab(self): self.tabs.setCurrentIndex(TAB_TITLES.index("账号管理")) def open_settings_tab(self): self.tabs.setCurrentIndex(TAB_TITLES.index("设置"))