feat(onboarding): add first-run membership activation
This commit is contained in:
+46
-3
@@ -50,6 +50,9 @@ SHOPEE_PARALLEL_ACCOUNTS_MIN = 1
|
||||
SHOPEE_PARALLEL_ACCOUNTS_MAX = 5
|
||||
CMHUB_CONNECT_TIMEOUT_DEFAULT = 66
|
||||
CMHUB_CONNECT_TIMEOUT_OLD_DEFAULT = 10
|
||||
DEFAULT_CMHUB_BASE_URL = "https://cm.833729.com"
|
||||
DEFAULT_MEMBER_CENTER_URL = "https://cm.833729.com"
|
||||
FIRST_USE_GUIDE_STATES = {"", "pending", "completed", "dismissed"}
|
||||
RUNTIME_CONFIG_KEYS = {
|
||||
"config_path",
|
||||
"ai_models_path",
|
||||
@@ -85,7 +88,7 @@ DEFAULT_CONFIG = {
|
||||
"generate_mode": "",
|
||||
"backend": "cmhub",
|
||||
"cmhub": {
|
||||
"base_url": "",
|
||||
"base_url": DEFAULT_CMHUB_BASE_URL,
|
||||
"title_alias": "",
|
||||
"image_alias": "",
|
||||
"vision_alias": "vision-standard",
|
||||
@@ -125,6 +128,9 @@ DEFAULT_CONFIG = {
|
||||
"subscription": {
|
||||
"last_notice_id": "",
|
||||
},
|
||||
"onboarding": {
|
||||
"first_use_guide_state": "",
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_AI_MODELS_CONFIG = {
|
||||
@@ -409,7 +415,10 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
|
||||
)
|
||||
cmhub = ai.get("cmhub")
|
||||
if isinstance(cmhub, dict):
|
||||
cmhub["base_url"] = normalize_cmhub_base_url(cmhub.get("base_url", ""))
|
||||
cmhub["base_url"] = (
|
||||
normalize_cmhub_base_url(cmhub.get("base_url", ""))
|
||||
or DEFAULT_CMHUB_BASE_URL
|
||||
)
|
||||
cmhub["title_alias"] = str(cmhub.get("title_alias", "") or "").strip()
|
||||
cmhub["image_alias"] = str(cmhub.get("image_alias", "") or "").strip()
|
||||
cmhub["vision_alias"] = str(cmhub.get("vision_alias", "") or "").strip()
|
||||
@@ -456,6 +465,14 @@ def _normalize_config_values(config, migrate_old_cmhub_connect_timeout=False):
|
||||
suite["last_settings"] = _normalize_product_suite_last_settings(
|
||||
suite.get("last_settings")
|
||||
)
|
||||
onboarding = config.get("onboarding")
|
||||
if not isinstance(onboarding, dict):
|
||||
onboarding = {}
|
||||
config["onboarding"] = onboarding
|
||||
guide_state = str(onboarding.get("first_use_guide_state") or "").strip().lower()
|
||||
onboarding["first_use_guide_state"] = (
|
||||
guide_state if guide_state in FIRST_USE_GUIDE_STATES else ""
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
@@ -728,6 +745,29 @@ def save_subscription_notice_id(notice_id, path=CONFIG_PATH) -> dict:
|
||||
return save_config(config, path=path)
|
||||
|
||||
|
||||
def first_use_guide_state(config=None) -> str:
|
||||
section = _config_or_load(config).get("onboarding", {})
|
||||
if not isinstance(section, dict):
|
||||
return ""
|
||||
state = str(section.get("first_use_guide_state") or "").strip().lower()
|
||||
return state if state in FIRST_USE_GUIDE_STATES else ""
|
||||
|
||||
|
||||
def save_first_use_guide_state(state, path=CONFIG_PATH) -> dict:
|
||||
"""Persist the local first-use guide state without storing credentials."""
|
||||
|
||||
normalized = str(state or "").strip().lower()
|
||||
if normalized not in FIRST_USE_GUIDE_STATES:
|
||||
raise ConfigError("首次使用引导状态无效")
|
||||
config = load_config(path)
|
||||
section = config.get("onboarding", {})
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
section["first_use_guide_state"] = normalized
|
||||
config["onboarding"] = section
|
||||
return save_config(config, path=path)
|
||||
|
||||
|
||||
def ai_config(config=None) -> dict:
|
||||
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
|
||||
|
||||
@@ -830,7 +870,10 @@ def cmhub_config(config=None) -> dict:
|
||||
if not isinstance(value, dict):
|
||||
raise ConfigError("ai.cmhub 必须是对象")
|
||||
merged = _deep_merge(DEFAULT_CONFIG["ai"]["cmhub"], value)
|
||||
merged["base_url"] = normalize_cmhub_base_url(merged.get("base_url", ""))
|
||||
merged["base_url"] = (
|
||||
normalize_cmhub_base_url(merged.get("base_url", ""))
|
||||
or DEFAULT_CMHUB_BASE_URL
|
||||
)
|
||||
merged["title_alias"] = str(merged.get("title_alias", "") or "").strip()
|
||||
merged["image_alias"] = str(merged.get("image_alias", "") or "").strip()
|
||||
merged["vision_alias"] = str(merged.get("vision_alias", "") or "").strip()
|
||||
|
||||
@@ -16,6 +16,7 @@ QMessageBox = _widgets._RawQMessageBox
|
||||
run_worker = _widgets._raw_run_worker if QT_IMPORT_ERROR is None else _widgets.run_worker
|
||||
|
||||
if QT_IMPORT_ERROR is None:
|
||||
from .activation_dialog import MembershipActivationDialog
|
||||
from .models import ApplyTaskTableModel, GenerateTaskTableModel, TaskTableModel
|
||||
from .workers import (
|
||||
AccountLoginCheckWorker,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""First-run membership activation dialog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from .widgets import (
|
||||
BUTTON_BASE_STYLE,
|
||||
COLOR_DANGER,
|
||||
COLOR_INFO,
|
||||
COLOR_MUTED,
|
||||
COLOR_SUCCESS,
|
||||
_primary_button_style,
|
||||
)
|
||||
|
||||
|
||||
class MembershipActivationDialog(QDialog):
|
||||
"""Collect one membership API key and report asynchronous validation state."""
|
||||
|
||||
validationRequested = Signal(str)
|
||||
memberCenterRequested = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._validating = False
|
||||
self.setObjectName("membershipActivationDialog")
|
||||
self.setWindowTitle("激活蝦皮圈优化助手")
|
||||
self.setModal(True)
|
||||
self.setWindowModality(Qt.WindowModal)
|
||||
self.setMinimumWidth(520)
|
||||
self.setStyleSheet(BUTTON_BASE_STYLE)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 22, 24, 22)
|
||||
layout.setSpacing(12)
|
||||
|
||||
heading = QLabel("首次使用需要绑定会员账号")
|
||||
heading.setObjectName("membershipActivationHeading")
|
||||
heading.setStyleSheet("font-size: 16px; font-weight: 600; color: #24292f;")
|
||||
layout.addWidget(heading)
|
||||
|
||||
description = QLabel(
|
||||
"请从线上会员中心复制会员 API Key。程序会先验证账号和套餐,"
|
||||
"验证通过后再开放业务功能。"
|
||||
)
|
||||
description.setObjectName("membershipActivationDescription")
|
||||
description.setWordWrap(True)
|
||||
description.setStyleSheet(f"color: {COLOR_MUTED};")
|
||||
layout.addWidget(description)
|
||||
|
||||
key_label = QLabel("会员 API Key")
|
||||
key_label.setObjectName("membershipApiKeyLabel")
|
||||
layout.addWidget(key_label)
|
||||
|
||||
self.api_key_edit = QLineEdit()
|
||||
self.api_key_edit.setObjectName("membershipApiKeyEdit")
|
||||
self.api_key_edit.setEchoMode(QLineEdit.Password)
|
||||
self.api_key_edit.setPlaceholderText("粘贴会员 API Key")
|
||||
self.api_key_edit.setClearButtonEnabled(True)
|
||||
self.api_key_edit.returnPressed.connect(self._request_validation)
|
||||
key_label.setBuddy(self.api_key_edit)
|
||||
layout.addWidget(self.api_key_edit)
|
||||
|
||||
self.show_key_checkbox = QCheckBox("显示 API Key")
|
||||
self.show_key_checkbox.setObjectName("showMembershipApiKeyCheckbox")
|
||||
self.show_key_checkbox.toggled.connect(self._toggle_key_visibility)
|
||||
layout.addWidget(self.show_key_checkbox)
|
||||
|
||||
storage_hint = QLabel(
|
||||
"API Key 在输入框和设置页中打码显示,但会以本机明文配置保存,"
|
||||
"仅用于会员验证和默认网关请求。"
|
||||
)
|
||||
storage_hint.setObjectName("membershipApiKeyStorageHint")
|
||||
storage_hint.setWordWrap(True)
|
||||
storage_hint.setStyleSheet(f"color: {COLOR_MUTED};")
|
||||
layout.addWidget(storage_hint)
|
||||
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setObjectName("membershipActivationStatusLabel")
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setMinimumHeight(36)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(8)
|
||||
self.member_center_button = QPushButton("前往会员中心获取 API Key")
|
||||
self.member_center_button.setObjectName("membershipCenterButton")
|
||||
self.member_center_button.clicked.connect(self.memberCenterRequested.emit)
|
||||
button_layout.addWidget(self.member_center_button)
|
||||
button_layout.addStretch(1)
|
||||
|
||||
self.exit_button = QPushButton("退出程序")
|
||||
self.exit_button.setObjectName("membershipActivationExitButton")
|
||||
self.exit_button.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.exit_button)
|
||||
|
||||
self.validate_button = QPushButton("验证并开始使用")
|
||||
self.validate_button.setObjectName("membershipActivationValidateButton")
|
||||
self.validate_button.setDefault(True)
|
||||
self.validate_button.setStyleSheet(
|
||||
_primary_button_style("membershipActivationValidateButton")
|
||||
)
|
||||
self.validate_button.clicked.connect(self._request_validation)
|
||||
button_layout.addWidget(self.validate_button)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.api_key_edit.setFocus()
|
||||
|
||||
def api_key(self) -> str:
|
||||
return self.api_key_edit.text().strip()
|
||||
|
||||
def set_validating(self, validating: bool) -> None:
|
||||
self._validating = bool(validating)
|
||||
self.api_key_edit.setEnabled(not self._validating)
|
||||
self.show_key_checkbox.setEnabled(not self._validating)
|
||||
self.validate_button.setEnabled(not self._validating)
|
||||
self.validate_button.setText(
|
||||
"正在验证会员账号..." if self._validating else "验证并开始使用"
|
||||
)
|
||||
if self._validating:
|
||||
self.set_status("正在验证会员账号,请稍候。", level="info")
|
||||
|
||||
def set_status(self, message: str, *, level: str = "muted") -> None:
|
||||
color = {
|
||||
"danger": COLOR_DANGER,
|
||||
"info": COLOR_INFO,
|
||||
"success": COLOR_SUCCESS,
|
||||
}.get(str(level), COLOR_MUTED)
|
||||
self.status_label.setStyleSheet(f"color: {color};")
|
||||
self.status_label.setText(str(message or ""))
|
||||
|
||||
def focus_api_key(self) -> None:
|
||||
self.api_key_edit.setFocus()
|
||||
self.api_key_edit.selectAll()
|
||||
|
||||
def accept_activation(self) -> None:
|
||||
self.set_validating(False)
|
||||
self.accept()
|
||||
|
||||
def _request_validation(self) -> None:
|
||||
if self._validating:
|
||||
return
|
||||
key = self.api_key()
|
||||
if not key:
|
||||
self.set_status("请输入会员 API Key。", level="danger")
|
||||
self.focus_api_key()
|
||||
return
|
||||
self.validationRequested.emit(key)
|
||||
|
||||
def _toggle_key_visibility(self, checked: bool) -> None:
|
||||
mode = QLineEdit.Normal if checked else QLineEdit.Password
|
||||
self.api_key_edit.setEchoMode(mode)
|
||||
+229
-12
@@ -7,6 +7,7 @@ from PySide6.QtGui import QDesktopServices
|
||||
|
||||
from .. import subscription
|
||||
from ..version import display_name
|
||||
from .activation_dialog import MembershipActivationDialog
|
||||
from .tabs.accounts import AccountsTab
|
||||
from .tabs.apply import ApplyTab
|
||||
from .tabs.collect import CollectTab
|
||||
@@ -122,6 +123,8 @@ class MainWindow(QMainWindow):
|
||||
self._subscription_request_token = 0
|
||||
self._subscription_closed = False
|
||||
self._expired_subscription_notice_shown = False
|
||||
self._activation_dialog = None
|
||||
self._first_use_guide_shown = False
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setObjectName("mainTabs")
|
||||
self.tabs.setStyleSheet(TAB_STYLE)
|
||||
@@ -296,7 +299,13 @@ class MainWindow(QMainWindow):
|
||||
self._subscription_worker = None
|
||||
self._set_subscription_check_running(False)
|
||||
|
||||
def _apply_subscription_status(self, status):
|
||||
def _apply_subscription_status(
|
||||
self,
|
||||
status,
|
||||
*,
|
||||
show_notice=True,
|
||||
show_first_use_guide=True,
|
||||
):
|
||||
self._subscription_status = status
|
||||
if status.allows_product_workflows:
|
||||
self._expired_subscription_notice_shown = False
|
||||
@@ -334,8 +343,10 @@ class MainWindow(QMainWindow):
|
||||
else "会员状态已验证,当前处于观察模式"
|
||||
)
|
||||
self.show_status(message, level="success")
|
||||
if SUBSCRIPTION_ENFORCEMENT_ENABLED:
|
||||
if SUBSCRIPTION_ENFORCEMENT_ENABLED and show_notice:
|
||||
self._show_subscription_notice_once(status)
|
||||
if show_first_use_guide:
|
||||
QTimer.singleShot(0, self._show_first_use_guide_if_pending)
|
||||
return
|
||||
if not SUBSCRIPTION_ENFORCEMENT_ENABLED:
|
||||
self.show_status(
|
||||
@@ -343,11 +354,225 @@ class MainWindow(QMainWindow):
|
||||
level=self._subscription_level(status),
|
||||
)
|
||||
return
|
||||
self.open_settings_tab()
|
||||
self.show_status(status.user_message, level=self._subscription_level(status))
|
||||
if status.state == subscription.STATUS_NOT_CONFIGURED:
|
||||
self.show_status("请先绑定会员账号", level="warning")
|
||||
self._show_membership_activation()
|
||||
return
|
||||
self.open_settings_tab()
|
||||
if status.state == subscription.STATUS_EXPIRED:
|
||||
self._show_expired_subscription_notice_once(status)
|
||||
|
||||
def _show_membership_activation(self):
|
||||
if self._activation_dialog is not None:
|
||||
self._activation_dialog.raise_()
|
||||
self._activation_dialog.activateWindow()
|
||||
return
|
||||
dialog = MembershipActivationDialog(self)
|
||||
self._activation_dialog = dialog
|
||||
dialog.validationRequested.connect(self._begin_activation_validation)
|
||||
dialog.memberCenterRequested.connect(self._open_activation_member_center)
|
||||
dialog.finished.connect(
|
||||
lambda result, current=dialog: self._on_activation_dialog_finished(
|
||||
current,
|
||||
result,
|
||||
)
|
||||
)
|
||||
dialog.open()
|
||||
|
||||
def _begin_activation_validation(self, api_key):
|
||||
dialog = self._activation_dialog
|
||||
key = str(api_key or "").strip()
|
||||
if dialog is None or not key:
|
||||
return
|
||||
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_check_running(True)
|
||||
dialog.set_validating(True)
|
||||
self.show_status("正在验证会员账号", level="info")
|
||||
worker = SubscriptionCheckWorker(
|
||||
config=self.config,
|
||||
cmhub_config_path=self.config.get("cmhub_config_path"),
|
||||
base_url=appconfig.cmhub_config(self.config).get("base_url"),
|
||||
api_key=key,
|
||||
)
|
||||
worker.finished.connect(
|
||||
lambda payload, token=token, key=key: self._on_activation_checked(
|
||||
token,
|
||||
key,
|
||||
payload,
|
||||
)
|
||||
)
|
||||
worker.cancelled.connect(
|
||||
lambda payload, token=token, key=key: self._on_activation_checked(
|
||||
token,
|
||||
key,
|
||||
payload,
|
||||
)
|
||||
)
|
||||
thread = run_worker(worker, thread_name="MembershipActivationWorker", start=False)
|
||||
thread.finished.connect(lambda: self._forget_subscription_thread(thread))
|
||||
self._subscription_worker = worker
|
||||
self._subscription_thread = thread
|
||||
thread.start()
|
||||
|
||||
def _on_activation_checked(self, token, api_key, payload):
|
||||
if self._subscription_closed or token != self._subscription_request_token:
|
||||
return
|
||||
dialog = self._activation_dialog
|
||||
if dialog is None:
|
||||
return
|
||||
dialog.set_validating(False)
|
||||
status = payload.get("subscription") if isinstance(payload, dict) else None
|
||||
if not isinstance(status, subscription.SubscriptionStatus):
|
||||
status = subscription.SubscriptionStatus(subscription.STATUS_UNAVAILABLE)
|
||||
self._subscription_status = status
|
||||
|
||||
if not status.credentials_accepted:
|
||||
self._set_product_access(False)
|
||||
self._set_membership_window_title()
|
||||
if status.state == subscription.STATUS_KEY_INVALID:
|
||||
message = "会员 API Key 无效,请检查后重新输入。"
|
||||
dialog.focus_api_key()
|
||||
elif status.state == subscription.STATUS_NOT_CONFIGURED:
|
||||
message = "请输入有效的会员 API Key。"
|
||||
dialog.focus_api_key()
|
||||
else:
|
||||
message = "暂时无法验证会员账号,请检查网络后重试。"
|
||||
dialog.set_status(message, level="danger")
|
||||
self.show_status(message, level="warning")
|
||||
return
|
||||
|
||||
try:
|
||||
appconfig.save_cmhub_config(
|
||||
{"api_key": str(api_key or "").strip()},
|
||||
path=self.config.get("cmhub_config_path")
|
||||
or appconfig.cmhub_config_file_path(self.config),
|
||||
)
|
||||
except (OSError, appconfig.ConfigError):
|
||||
message = "会员账号已验证,但 API Key 保存失败,请检查数据目录后重试。"
|
||||
dialog.set_status(message, level="danger")
|
||||
self.show_status(message, level="danger")
|
||||
return
|
||||
|
||||
settings_tab = self._settings_tab()
|
||||
if hasattr(settings_tab, "refresh_default_gateway_credentials"):
|
||||
settings_tab.refresh_default_gateway_credentials()
|
||||
|
||||
if not status.allows_product_workflows:
|
||||
self._set_product_access(False)
|
||||
self._set_membership_window_title()
|
||||
message = "%s。请前往会员中心处理后重新验证。" % status.user_message
|
||||
dialog.set_status(message, level="danger")
|
||||
self.show_status(status.user_message, level=self._subscription_level(status))
|
||||
return
|
||||
|
||||
self._save_first_use_state("pending")
|
||||
if status.notice_id:
|
||||
self._save_subscription_notice_id(status.notice_id)
|
||||
self._apply_subscription_status(
|
||||
status,
|
||||
show_notice=False,
|
||||
show_first_use_guide=False,
|
||||
)
|
||||
dialog.set_status("会员账号验证成功,正在进入工作区。", level="success")
|
||||
dialog.accept_activation()
|
||||
QTimer.singleShot(0, self._show_first_use_guide_if_pending)
|
||||
|
||||
def _on_activation_dialog_finished(self, dialog, result):
|
||||
if self._activation_dialog is dialog:
|
||||
self._activation_dialog = None
|
||||
dialog.deleteLater()
|
||||
if (
|
||||
result != QDialog.Accepted
|
||||
and not self._subscription_closed
|
||||
and not self._subscription_status.allows_product_workflows
|
||||
):
|
||||
self.close()
|
||||
|
||||
def _open_activation_member_center(self):
|
||||
manage_url = str(self._subscription_status.manage_url or "").strip()
|
||||
if not manage_url:
|
||||
manage_url = subscription.safe_manage_url(
|
||||
appconfig.DEFAULT_MEMBER_CENTER_URL,
|
||||
appconfig.DEFAULT_CMHUB_BASE_URL,
|
||||
)
|
||||
if not manage_url:
|
||||
message = "会员中心地址当前不可用,请稍后重试。"
|
||||
if self._activation_dialog is not None:
|
||||
self._activation_dialog.set_status(message, level="danger")
|
||||
self.show_status(message, level="warning")
|
||||
return
|
||||
opened = QDesktopServices.openUrl(QUrl(manage_url))
|
||||
if opened is False:
|
||||
message = "无法打开会员中心,请检查系统默认浏览器后重试。"
|
||||
if self._activation_dialog is not None:
|
||||
self._activation_dialog.set_status(message, level="danger")
|
||||
self.show_status(message, level="warning")
|
||||
|
||||
def _show_first_use_guide_if_pending(self):
|
||||
if self._first_use_guide_shown:
|
||||
return
|
||||
if appconfig.first_use_guide_state(self.config) != "pending":
|
||||
return
|
||||
if not self._subscription_status.allows_product_workflows:
|
||||
return
|
||||
self._first_use_guide_shown = True
|
||||
|
||||
box = QMessageBox(self)
|
||||
box.setIcon(QMessageBox.Information)
|
||||
box.setWindowTitle("首次使用指引")
|
||||
box.setText(
|
||||
"接下来按以下顺序开始使用:\n\n"
|
||||
"1. 添加蝦皮店铺账号\n"
|
||||
"2. 启动 Chrome 并人工完成登录\n"
|
||||
"3. 导入商品 Excel\n"
|
||||
"4. 开始采集、AI 生成和更新蝦皮"
|
||||
)
|
||||
start_button = box.addButton("开始配置店铺", QMessageBox.AcceptRole)
|
||||
later_button = box.addButton("稍后提醒", QMessageBox.RejectRole)
|
||||
dismiss_button = box.addButton("不再提示", QMessageBox.ActionRole)
|
||||
box.setDefaultButton(start_button)
|
||||
box.exec()
|
||||
clicked = box.clickedButton()
|
||||
if clicked is start_button:
|
||||
self._save_first_use_state("completed")
|
||||
self.open_accounts_tab()
|
||||
elif clicked is dismiss_button:
|
||||
self._save_first_use_state("dismissed")
|
||||
elif clicked is later_button:
|
||||
self.show_status("可稍后从账号管理开始配置店铺", level="muted")
|
||||
|
||||
def _save_first_use_state(self, state):
|
||||
try:
|
||||
saved = appconfig.save_first_use_guide_state(
|
||||
state,
|
||||
path=self.config_path,
|
||||
)
|
||||
except (OSError, appconfig.ConfigError):
|
||||
self.show_status("首次使用引导状态保存失败", level="warning")
|
||||
return False
|
||||
self._replace_runtime_config(saved)
|
||||
return True
|
||||
|
||||
def _save_subscription_notice_id(self, notice_id):
|
||||
try:
|
||||
saved = appconfig.save_subscription_notice_id(
|
||||
notice_id,
|
||||
path=self.config_path,
|
||||
)
|
||||
except (OSError, appconfig.ConfigError):
|
||||
return False
|
||||
self._replace_runtime_config(saved)
|
||||
return True
|
||||
|
||||
def _replace_runtime_config(self, saved):
|
||||
self.config.clear()
|
||||
self.config.update(saved)
|
||||
|
||||
@staticmethod
|
||||
def _subscription_level(status):
|
||||
if status.state in {subscription.STATUS_UNAVAILABLE, subscription.STATUS_KEY_INVALID}:
|
||||
@@ -441,15 +666,7 @@ class MainWindow(QMainWindow):
|
||||
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)
|
||||
self._save_subscription_notice_id(notice_id)
|
||||
|
||||
def _on_tab_changed(self, index):
|
||||
if self._reverting_tab_change:
|
||||
|
||||
@@ -566,6 +566,19 @@ class SettingsTab(QWidget):
|
||||
"正在检测会员状态..." if running else "重新检测会员状态"
|
||||
)
|
||||
|
||||
def refresh_default_gateway_credentials(self):
|
||||
"""Refresh default gateway fields after first-run membership activation."""
|
||||
|
||||
with self._dirty_tracking_suspended():
|
||||
cmhub_cfg = appconfig.cmhub_config(self.config)
|
||||
self.cmhub_base_url_edit.setText(
|
||||
appconfig.normalize_cmhub_base_url(cmhub_cfg.get("base_url", ""))
|
||||
)
|
||||
self._loaded_cmhub_api_key = appconfig.get_cmhub_api_key(
|
||||
path=self.cmhub_config_path
|
||||
)
|
||||
self.cmhub_api_key_edit.setText(self._loaded_cmhub_api_key)
|
||||
|
||||
@contextmanager
|
||||
def _dirty_tracking_suspended(self):
|
||||
self._suspend_dirty += 1
|
||||
|
||||
+12
-1
@@ -38,15 +38,26 @@ _USER_LOG_PATH_RE = re.compile(
|
||||
class SubscriptionCheckWorker(BaseWorker):
|
||||
"""Fetch the current cmshopee account subscription away from the GUI thread."""
|
||||
|
||||
def __init__(self, *, config=None, cmhub_config_path=None):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config=None,
|
||||
cmhub_config_path=None,
|
||||
base_url=None,
|
||||
api_key=None,
|
||||
):
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.cmhub_config_path = cmhub_config_path
|
||||
self.base_url = base_url
|
||||
self.api_key = api_key
|
||||
|
||||
def execute(self):
|
||||
result = subscription.check_status(
|
||||
self.config,
|
||||
cmhub_config_path=self.cmhub_config_path,
|
||||
base_url=self.base_url,
|
||||
api_key=self.api_key,
|
||||
)
|
||||
return {"subscription": result}
|
||||
|
||||
|
||||
+28
-8
@@ -60,6 +60,14 @@ class SubscriptionStatus:
|
||||
def allows_product_workflows(self) -> bool:
|
||||
return self.state in {STATUS_ACTIVE, STATUS_GRACE, STATUS_LEGACY}
|
||||
|
||||
@property
|
||||
def credentials_accepted(self) -> bool:
|
||||
return self.state not in {
|
||||
STATUS_NOT_CONFIGURED,
|
||||
STATUS_KEY_INVALID,
|
||||
STATUS_UNAVAILABLE,
|
||||
}
|
||||
|
||||
@property
|
||||
def interface_available(self) -> bool:
|
||||
return self.state != STATUS_LEGACY
|
||||
@@ -84,6 +92,8 @@ def check_status(
|
||||
config=None,
|
||||
*,
|
||||
cmhub_config_path=None,
|
||||
base_url=None,
|
||||
api_key=None,
|
||||
request_json=None,
|
||||
) -> SubscriptionStatus:
|
||||
"""Look up the current API-key account subscription without leaking secrets.
|
||||
@@ -95,25 +105,32 @@ def check_status(
|
||||
cfg = appconfig.load_config() if config is None else config
|
||||
try:
|
||||
cmhub = appconfig.cmhub_config(cfg)
|
||||
base_url = appconfig.normalize_cmhub_base_url(cmhub.get("base_url"))
|
||||
configured_base_url = (
|
||||
cmhub.get("base_url") if base_url is None else base_url
|
||||
)
|
||||
resolved_base_url = appconfig.normalize_cmhub_base_url(configured_base_url)
|
||||
key_path = (
|
||||
cmhub_config_path
|
||||
or cfg.get("cmhub_config_path")
|
||||
or appconfig.cmhub_config_file_path(cfg)
|
||||
)
|
||||
api_key = appconfig.get_cmhub_api_key(path=key_path)
|
||||
resolved_api_key = (
|
||||
appconfig.get_cmhub_api_key(path=key_path)
|
||||
if api_key is None
|
||||
else str(api_key or "").strip()
|
||||
)
|
||||
except Exception:
|
||||
return SubscriptionStatus(STATUS_NOT_CONFIGURED)
|
||||
if not base_url or not api_key:
|
||||
if not resolved_base_url or not resolved_api_key:
|
||||
return SubscriptionStatus(STATUS_NOT_CONFIGURED)
|
||||
|
||||
requester = request_json or ai.request_cmhub_json
|
||||
try:
|
||||
data = requester(
|
||||
"GET",
|
||||
base_url,
|
||||
resolved_base_url,
|
||||
"/api/v1/cmshopee/subscription/status",
|
||||
api_key,
|
||||
resolved_api_key,
|
||||
connect_timeout=cmhub.get(
|
||||
"connect_timeout",
|
||||
appconfig.CMHUB_CONNECT_TIMEOUT_DEFAULT,
|
||||
@@ -125,7 +142,7 @@ def check_status(
|
||||
return _status_from_cmhub_error(exc)
|
||||
except Exception:
|
||||
return SubscriptionStatus(STATUS_UNAVAILABLE)
|
||||
return _parse_status_response(data, base_url)
|
||||
return _parse_status_response(data, resolved_base_url)
|
||||
|
||||
|
||||
def format_expiry(value: str) -> str:
|
||||
@@ -173,7 +190,7 @@ def _parse_status_response(data, base_url: str) -> SubscriptionStatus:
|
||||
plan_name = str(plan.get("display_name") or "").strip()
|
||||
expires_at = str(data.get("expires_at") or "").strip()
|
||||
grace_expires_at = str(data.get("grace_expires_at") or "").strip()
|
||||
manage_url = _safe_manage_url(data.get("manage_url"), base_url)
|
||||
manage_url = safe_manage_url(data.get("manage_url"), base_url)
|
||||
notice_id = str(data.get("notice_id") or "").strip()
|
||||
|
||||
if normalized in {STATUS_ACTIVE, STATUS_GRACE}:
|
||||
@@ -213,7 +230,7 @@ def _normalize_remote_state(value) -> str:
|
||||
}.get(normalized, "")
|
||||
|
||||
|
||||
def _safe_manage_url(value, base_url: str) -> str:
|
||||
def safe_manage_url(value, base_url: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
@@ -229,3 +246,6 @@ def _safe_manage_url(value, base_url: str) -> str:
|
||||
):
|
||||
return ""
|
||||
return text
|
||||
|
||||
|
||||
_safe_manage_url = safe_manage_url
|
||||
|
||||
Reference in New Issue
Block a user