Files
cmshoppe/app/gui/main_window.py
T

467 lines
18 KiB
Python
Raw Normal View History

2026-07-02 16:47:37 +08:00
"""Main GUI window."""
from __future__ import annotations
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from .. import subscription
2026-07-07 14:21:30 +08:00
from ..version import display_name
2026-07-02 16:47:37 +08:00
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
2026-07-02 16:47:37 +08:00
from .tabs.settings import SettingsTab
from .widgets import *
from .workers import SubscriptionCheckWorker
2026-07-07 14:21:30 +08:00
PREFERRED_WINDOW_SIZE = (1180, 760)
MIN_WINDOW_SIZE = (960, 640)
WINDOW_SCREEN_MARGIN = 40
2026-07-23 14:37:00 +08:00
# 观察期真实查询会员状态,但不在客户端限制存量用户工作流。
SUBSCRIPTION_CHECK_ENABLED = True
SUBSCRIPTION_ENFORCEMENT_ENABLED = False
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))
2026-07-07 14:21:30 +08:00
2026-07-02 16:47:37 +08:00
class MainWindow(QMainWindow):
2026-07-11 14:16:11 +08:00
"""Main application window with the fixed workflow tabs."""
2026-07-02 16:47:37 +08:00
2026-07-10 16:19:01 +08:00
def __init__(
self,
db_path=None,
config=None,
config_path=None,
ai_models_path=None,
startup_status="",
):
2026-07-02 16:47:37 +08:00
super().__init__()
self._initial_window_fit_applied_after_show = False
2026-07-02 16:47:37 +08:00
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)
2026-07-02 16:47:37 +08:00
)
2026-07-07 14:21:30 +08:00
self.setWindowTitle(display_name())
window_icon = app_icon()
if window_icon is not None:
self.setWindowIcon(window_icon)
_fit_and_center_window(self)
2026-07-07 09:21:19 +08:00
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_closed = False
2026-07-02 16:47:37 +08:00
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)
subscription_label_text = (
"会员:正在验证"
if SUBSCRIPTION_CHECK_ENABLED
else "会员:订阅检测已暂停"
)
self._subscription_label = QLabel(subscription_label_text)
self._subscription_label.setObjectName("subscriptionStatusLabel")
self._subscription_label.setAccessibleName("会员状态")
self._subscription_label.setStyleSheet("color: #0969da;")
header = QWidget()
header.setObjectName("appHeader")
header.setAccessibleName("会员状态栏")
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(18, 4, 18, 2)
header_layout.setSpacing(0)
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)
2026-07-07 15:42:19 +08:00
self.show_status("就绪", level="muted")
2026-07-10 16:19:01 +08:00
if startup_status:
self.show_status(startup_status, level="success")
2026-07-02 16:47:37 +08:00
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
2026-07-07 15:42:19 +08:00
def show_status(self, message, level="muted"):
color = _status_level_color(level)
self.statusBar().setStyleSheet(f"QStatusBar {{ color: {color}; }}")
self.statusBar().showMessage(str(message))
2026-07-02 16:47:37 +08:00
def _build_tab(self, title):
if title == "① 导入采集":
return CollectTab(
db_path=self.db_path,
config=self.config,
2026-07-07 15:42:19 +08:00
status_callback=self.show_status,
2026-07-02 16:47:37 +08:00
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,
2026-07-07 15:42:19 +08:00
status_callback=self.show_status,
title_prompt_path=appconfig.title_prompt_path(self.config),
cover_prompts_dir=appconfig.cover_prompts_dir(self.config),
2026-07-02 16:47:37 +08:00
open_accounts_callback=lambda: self.open_accounts_tab(),
2026-07-20 11:12:01 +08:00
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
subscription_preflight_callback=self.ensure_subscription_for_new_submit,
2026-07-02 16:47:37 +08:00
)
2026-07-07 14:58:54 +08:00
if title == "③ 更新蝦皮":
2026-07-02 16:47:37 +08:00
return ApplyTab(
db_path=self.db_path,
config=self.config,
2026-07-07 15:42:19 +08:00
status_callback=self.show_status,
2026-07-02 16:47:37 +08:00
open_accounts_callback=lambda: self.open_accounts_tab(),
open_settings_callback=lambda: self.open_settings_tab(),
2026-07-20 11:12:01 +08:00
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
2026-07-02 16:47:37 +08:00
)
if title == "账号管理":
2026-07-02 16:47:37 +08:00
return AccountsTab(
db_path=self.db_path,
config=self.config,
2026-07-07 15:42:19 +08:00
status_callback=self.show_status,
2026-07-02 16:47:37 +08:00
)
if title == "设置":
2026-07-11 14:16:11 +08:00
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(
2026-07-13 11:47:00 +08:00
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,
2026-07-13 11:47:00 +08:00
)
raise ValueError(f"未知主界面模块:{title}")
2026-07-02 16:47:37 +08:00
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")
2026-07-23 14:37:00 +08:00
self.begin_subscription_check()
@property
def subscription_status(self):
return self._subscription_status
def begin_subscription_check(self):
"""Refresh membership state without blocking the Qt GUI thread."""
if not SUBSCRIPTION_CHECK_ENABLED:
self._set_product_access(True)
self._set_subscription_label("会员:订阅检测已暂停", "muted")
return
self._subscription_request_token += 1
token = self._subscription_request_token
if self._subscription_worker is not None:
self._subscription_worker.cancel()
2026-07-23 14:37:00 +08:00
self._set_product_access(not SUBSCRIPTION_ENFORCEMENT_ENABLED)
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: self._on_subscription_checked(
token,
payload,
)
)
worker.cancelled.connect(
lambda payload, token=token: self._on_subscription_checked(
token,
payload,
)
)
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."""
2026-07-23 14:37:00 +08:00
if not SUBSCRIPTION_CHECK_ENABLED or not SUBSCRIPTION_ENFORCEMENT_ENABLED:
return True
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.open_settings_tab()
return False
def _on_subscription_checked(self, token, payload):
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)
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):
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",
)
2026-07-23 14:37:00 +08:00
elif SUBSCRIPTION_ENFORCEMENT_ENABLED:
self._set_subscription_label(
"会员:%s" % status.user_message,
self._subscription_level(status),
)
else:
2026-07-23 14:37:00 +08:00
self._set_subscription_label(
"会员观察:%s(当前不影响使用)" % status.user_message,
self._subscription_level(status),
)
2026-07-23 14:37:00 +08:00
access_allowed = (
status.allows_product_workflows
or not SUBSCRIPTION_ENFORCEMENT_ENABLED
)
self._set_product_access(access_allowed)
if status.allows_product_workflows:
if status.state == subscription.STATUS_LEGACY:
self.show_status(status.user_message, level="muted")
else:
2026-07-23 14:37:00 +08:00
message = (
"会员状态已验证"
if SUBSCRIPTION_ENFORCEMENT_ENABLED
else "会员状态已验证,当前处于观察模式"
)
self.show_status(message, level="success")
if SUBSCRIPTION_ENFORCEMENT_ENABLED:
self._show_subscription_notice_once(status)
return
if not SUBSCRIPTION_ENFORCEMENT_ENABLED:
self.show_status(
"订阅观察:%s,当前不影响使用" % status.user_message,
level=self._subscription_level(status),
)
return
self.open_settings_tab()
self.show_status(status.user_message, level=self._subscription_level(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_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)
2026-07-02 16:47:37 +08:00
def _on_tab_changed(self, index):
if self._reverting_tab_change:
self._last_tab_index = index
2026-07-07 15:42:19 +08:00
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
2026-07-07 15:42:19 +08:00
self.show_status(f"当前:{self.tabs.tabText(previous)}", level="muted")
return
self._last_tab_index = index
2026-07-07 15:42:19 +08:00
self.show_status(f"当前:{self.tabs.tabText(index)}", level="muted")
2026-07-02 16:47:37 +08:00
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)
2026-07-07 15:11:40 +08:00
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()
2026-07-02 16:47:37 +08:00
def open_accounts_tab(self):
self.tabs.setCurrentIndex(TAB_TITLES.index("账号管理"))
2026-07-02 16:47:37 +08:00
def open_settings_tab(self):
self.tabs.setCurrentIndex(TAB_TITLES.index("设置"))