Files
cmshoppe/app/gui.py
T
chengma fe1e0a7d32 feat: 完成T-302p提示词管理
新增app/prompts.py,支持标题提示词读写、封面模板列表/读取/保存/重命名/删除,以及旧标题、新标题、商品id、店铺变量替换。

Tab②接入标题提示词启动回显和保存,封面模板下拉、新建、保存、另存为、重命名、删除、插入{新标题}与变量预览;本任务仍不调用AI、不写SQLite。

新增tests/test_prompts.py并扩展GUI测试覆盖提示词文件管理和预览;同步任务看板、API、routes、current-state和progress,下一个任务更新为T-303。
2026-06-27 16:10:50 +08:00

1745 lines
70 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PySide6 GUI entry point."""
from __future__ import annotations
import os
import sys
try:
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSplitter,
QTableView,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
QT_IMPORT_ERROR = None
except ModuleNotFoundError as exc:
QApplication = None
QMainWindow = object
QTabWidget = None
QVBoxLayout = None
QWidget = object
QT_IMPORT_ERROR = exc
TAB_TITLES = [
"① 导入采集",
"② AI生成",
"③ 更新shopee",
"④ 账号管理",
"⑤ 设置",
]
TAB_STYLE = """
QTabWidget::pane {
border-top: 1px solid #c9d1d9;
}
QTabBar::tab {
min-width: 128px;
min-height: 34px;
padding: 8px 18px;
margin-right: 8px;
border: 1px solid #c9d1d9;
border-bottom-color: #b8c0ca;
background: #f4f6f8;
color: #24292f;
}
QTabBar::tab:selected {
background: #ffffff;
border-color: #687785;
border-bottom-color: #ffffff;
font-weight: 600;
}
QTabBar::tab:hover:!selected {
background: #eaf2ff;
}
"""
if QT_IMPORT_ERROR is None:
from . import accounts, appconfig, chrome, db, editor, excel, prompts
from . import config as account_config
def _database_path(db_path=None, config=None) -> str:
return db_path or appconfig.db_path(config)
class TaskTableModel(QAbstractTableModel):
"""Table model for task rows shared by workflow tabs."""
HEADERS = ["账号", "别名", "商品ID", "阶段"]
STAGE_TEXT = {
"imported": "待采集",
"collected": "已采集",
"generated": "已生成",
"applied": "已更新",
}
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
}
def __init__(self, parent=None):
super().__init__(parent)
self.tasks = []
self.all_tasks = []
self.account_by_alias = {}
self.filter_mode = "all"
def set_tasks(self, tasks, accounts):
self.beginResetModel()
self.all_tasks = list(tasks)
self.account_by_alias = {
str(account.alias).strip(): account
for account in accounts
if str(account.alias).strip()
}
self.tasks = self._filtered_tasks()
self.endResetModel()
def set_filter_mode(self, mode):
self.beginResetModel()
self.filter_mode = mode if mode in {"all", "unmatched"} else "all"
self.tasks = self._filtered_tasks()
self.endResetModel()
def _filtered_tasks(self):
if self.filter_mode == "unmatched":
return [task for task in self.all_tasks if self.is_unmatched(task)]
return list(self.all_tasks)
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.tasks)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.HEADERS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal and 0 <= section < len(self.HEADERS):
return self.HEADERS[section]
return section + 1 if orientation == Qt.Vertical else None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
task = self.tasks[index.row()]
if role == Qt.DisplayRole:
return self._display_value(task, index.column())
if role == Qt.ToolTipRole and self.is_unmatched(task):
return "别名未匹配账号,采集时将略过"
return None
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def is_unmatched(self, task) -> bool:
return str(task.alias).strip() not in self.account_by_alias
def unmatched_count(self) -> int:
return sum(1 for task in self.all_tasks if self.is_unmatched(task))
def _account_name(self, task) -> str:
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
return account.account_name
return task.account_name or ""
def _stage_text(self, task) -> str:
if self.is_unmatched(task):
return "略过"
if task.status in self.STATUS_TEXT and task.status != "pending":
return self.STATUS_TEXT[task.status]
return self.STAGE_TEXT.get(task.stage, task.stage)
def _display_value(self, task, column):
values = [
self._account_name(task),
task.alias,
task.item_id,
self._stage_text(task),
]
return values[column] if 0 <= column < len(values) else None
class GenerateTaskTableModel(QAbstractTableModel):
"""Table model for Tab 2 generation candidates."""
HEADERS = ["店铺", "商品ID", "旧标题", "新标题", "状态"]
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
}
STAGE_TEXT = {
"imported": "未采集",
"collected": "待生成",
"generated": "已生成",
"applied": "已更新",
}
def __init__(self, parent=None):
super().__init__(parent)
self.tasks = []
self.account_by_alias = {}
def set_tasks(self, tasks, accounts):
self.beginResetModel()
self.tasks = list(tasks)
self.account_by_alias = {
str(account.alias).strip(): account
for account in accounts
if str(account.alias).strip()
}
self.endResetModel()
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.tasks)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.HEADERS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal and 0 <= section < len(self.HEADERS):
return self.HEADERS[section]
return section + 1 if orientation == Qt.Vertical else None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
task = self.tasks[index.row()]
if role == Qt.DisplayRole:
return self._display_value(task, index.column())
if role == Qt.ToolTipRole and task.last_error:
return task.last_error
return None
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def account_name_for(self, task):
return self._account_name(task)
def _account_name(self, task):
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
return account.account_name
return task.account_name or task.alias or ""
def _status_text(self, task):
if task.status in self.STATUS_TEXT and task.status != "pending":
return self.STATUS_TEXT[task.status]
return self.STAGE_TEXT.get(task.stage, task.stage)
def _display_value(self, task, column):
values = [
self._account_name(task),
task.item_id,
task.old_title or "",
task.new_title or "",
self._status_text(task),
]
return values[column] if 0 <= column < len(values) else None
class GenerateTab(QWidget):
"""Tab 2: prompt area plus generation task filters/list."""
STATUS_FILTERS = [
("全部状态", "all"),
("待生成", "to_generate"),
("已生成", "generated"),
("失败", "failed"),
("略过", "skipped"),
("已更新", "applied"),
]
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
title_prompt_path=None,
cover_prompts_dir=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.title_prompt_path = title_prompt_path or prompts.TITLE_PROMPT_PATH
self.cover_prompts_dir = cover_prompts_dir or prompts.COVER_PROMPTS_DIR
self.current_cover_template = None
self.title_prompt_edit = QPlainTextEdit()
self.title_prompt_edit.setObjectName("titlePromptEdit")
self.title_prompt_edit.setPlaceholderText("标题提示词")
self.title_prompt_edit.setPlainText(
prompts.load_title_prompt(self.title_prompt_path)
)
self.save_title_button = QPushButton("保存标题提示词")
self.cover_prompt_edit = QPlainTextEdit()
self.cover_prompt_edit.setObjectName("coverPromptEdit")
self.cover_prompt_edit.setPlaceholderText("封面提示词")
self.cover_template_combo = QComboBox()
self.cover_template_combo.setObjectName("coverTemplateCombo")
self.new_cover_template_button = QPushButton("新建")
self.save_cover_template_button = QPushButton("保存")
self.save_cover_template_as_button = QPushButton("另存为")
self.rename_cover_template_button = QPushButton("重命名")
self.delete_cover_template_button = QPushButton("删除")
self.insert_title_button = QPushButton("插入标题")
self.preview_prompt_button = QPushButton("预览")
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(0, 0, 12, 0)
left_layout.addWidget(QLabel("标题提示词"))
left_layout.addWidget(self.title_prompt_edit, 1)
left_layout.addWidget(self.save_title_button)
left_layout.addWidget(QLabel("封面提示词"))
left_layout.addWidget(self.cover_template_combo)
cover_template_layout = QHBoxLayout()
cover_template_layout.addWidget(self.new_cover_template_button)
cover_template_layout.addWidget(self.save_cover_template_button)
cover_template_layout.addWidget(self.save_cover_template_as_button)
cover_template_layout.addWidget(self.rename_cover_template_button)
cover_template_layout.addWidget(self.delete_cover_template_button)
left_layout.addLayout(cover_template_layout)
left_layout.addWidget(self.cover_prompt_edit, 2)
cover_action_layout = QHBoxLayout()
cover_action_layout.addWidget(self.insert_title_button)
cover_action_layout.addWidget(self.preview_prompt_button)
left_layout.addLayout(cover_action_layout)
self.batch_filter = QComboBox()
self.batch_filter.setObjectName("batchFilter")
self.shop_filter = QComboBox()
self.shop_filter.setObjectName("shopFilter")
self.status_filter = QComboBox()
self.status_filter.setObjectName("statusFilter")
for label, value in self.STATUS_FILTERS:
self.status_filter.addItem(label, value)
self.refresh_button = QPushButton("刷新")
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("批次"))
filter_layout.addWidget(self.batch_filter, 2)
filter_layout.addWidget(QLabel("店铺"))
filter_layout.addWidget(self.shop_filter, 1)
filter_layout.addWidget(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
self.task_table = QTableView()
self.model = GenerateTaskTableModel(self.task_table)
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.task_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False)
right_panel = QWidget()
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(12, 0, 0, 0)
right_layout.addLayout(filter_layout)
right_layout.addWidget(self.summary_label)
right_layout.addWidget(self.task_table, 1)
self.splitter = QSplitter(Qt.Horizontal)
self.splitter.addWidget(left_panel)
self.splitter.addWidget(right_panel)
self.splitter.setStretchFactor(0, 1)
self.splitter.setStretchFactor(1, 3)
self.splitter.setSizes([280, 860])
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addWidget(self.splitter, 1)
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.save_title_button.clicked.connect(self.save_title_prompt)
self.cover_template_combo.currentIndexChanged.connect(self.load_selected_cover_template)
self.new_cover_template_button.clicked.connect(self.new_cover_template)
self.save_cover_template_button.clicked.connect(self.save_cover_template)
self.save_cover_template_as_button.clicked.connect(self.save_cover_template_as)
self.rename_cover_template_button.clicked.connect(self.rename_cover_template)
self.delete_cover_template_button.clicked.connect(self.delete_cover_template)
self.insert_title_button.clicked.connect(self.insert_title_placeholder)
self.preview_prompt_button.clicked.connect(self.preview_cover_prompt)
self.refresh_cover_templates()
self.refresh_tasks()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def save_title_prompt(self, checked=False):
try:
prompts.save_title_prompt(
self.title_prompt_edit.toPlainText(),
self.title_prompt_path,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self._set_status("标题提示词已保存")
def refresh_cover_templates(self, selected=None):
try:
template_names = prompts.list_cover_templates(self.cover_prompts_dir)
except Exception as exc:
template_names = []
self._show_prompt_error(exc)
current = selected if selected is not None else self.current_cover_template
self.cover_template_combo.blockSignals(True)
self.cover_template_combo.clear()
if template_names:
for name in template_names:
self.cover_template_combo.addItem(name, name)
index = self.cover_template_combo.findData(current)
self.cover_template_combo.setCurrentIndex(index if index >= 0 else 0)
else:
self.cover_template_combo.addItem("默认", None)
self.cover_template_combo.setCurrentIndex(0)
self.cover_template_combo.blockSignals(False)
self.load_selected_cover_template()
def load_selected_cover_template(self, index=None):
name = self.cover_template_combo.currentData()
self.current_cover_template = name
if name is None:
self.cover_prompt_edit.setPlainText("")
return
try:
self.cover_prompt_edit.setPlainText(
prompts.load_cover_template(name, self.cover_prompts_dir)
)
except Exception as exc:
self.cover_prompt_edit.setPlainText("")
self._show_prompt_error(exc)
def new_cover_template(self, checked=False):
name = self._ask_template_name("新建封面提示词模板")
if not name:
return
try:
prompts.save_cover_template(name, "", self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=name)
self._set_status(f"封面提示词模板已新建:{name}")
def save_cover_template(self, checked=False):
name = self.current_cover_template
if name is None:
self.save_cover_template_as()
return
try:
prompts.save_cover_template(
name,
self.cover_prompt_edit.toPlainText(),
self.cover_prompts_dir,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self._set_status(f"封面提示词模板已保存:{name}")
def save_cover_template_as(self, checked=False):
name = self._ask_template_name("另存封面提示词模板")
if not name:
return
try:
prompts.save_cover_template(
name,
self.cover_prompt_edit.toPlainText(),
self.cover_prompts_dir,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=name)
self._set_status(f"封面提示词模板已另存为:{name}")
def rename_cover_template(self, checked=False):
old_name = self.current_cover_template
if old_name is None:
self._set_status("没有可重命名的封面提示词模板")
return
new_name = self._ask_template_name("重命名封面提示词模板", text=old_name)
if not new_name or new_name == old_name:
return
try:
prompts.rename_cover_template(old_name, new_name, self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=new_name)
self._set_status(f"封面提示词模板已重命名:{new_name}")
def delete_cover_template(self, checked=False):
name = self.current_cover_template
if name is None:
self._set_status("没有可删除的封面提示词模板")
return
choice = QMessageBox.question(
self,
"删除封面提示词模板",
f"确定删除「{name}」吗?",
)
if choice != QMessageBox.Yes:
return
try:
prompts.delete_cover_template(name, self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates()
self._set_status(f"封面提示词模板已删除:{name}")
def insert_title_placeholder(self, checked=False):
self.cover_prompt_edit.insertPlainText("{新标题}")
def preview_cover_prompt(self, checked=False):
task = self._selected_task()
if task is None:
self._set_status("没有可预览的任务")
return
rendered = prompts.render_prompt(
self.cover_prompt_edit.toPlainText(),
self._prompt_context(task),
)
QMessageBox.information(self, "封面提示词预览", rendered)
self._set_status("封面提示词预览已生成")
def _selected_task(self):
index = self.task_table.currentIndex()
if index.isValid():
return self.model.task_at(index.row())
if self.model.rowCount() > 0:
return self.model.task_at(0)
return None
def _prompt_context(self, task):
return {
"old_title": task.old_title,
"new_title": task.new_title,
"item_id": task.item_id,
"account_name": self.model.account_name_for(task),
"alias": task.alias,
}
def _ask_template_name(self, title, text=""):
value, ok = QInputDialog.getText(
self,
title,
"模板名",
QLineEdit.Normal,
text,
)
if not ok:
return None
return str(value).strip()
def _show_prompt_error(self, error):
message = str(error)
QMessageBox.warning(self, "提示词管理", message)
self._set_status(message)
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
batches = db.list_batches(path=self.db_path)
accounts_rows = accounts.list_accounts(path=self.db_path, config=self.config)
selected_batch = self.batch_filter.currentData()
selected_shop = self.shop_filter.currentData()
selected_status = self.status_filter.currentData() or "all"
self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData()
batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path)
self._populate_shop_filter(batch_tasks, accounts_rows, selected_shop)
selected_shop = self.shop_filter.currentData()
filtered_tasks = [
task for task in batch_tasks
if self._matches_shop(task, selected_shop)
and self._matches_status(task, selected_status)
]
except Exception as exc:
self.model.set_tasks([], [])
self.summary_label.setText("任务读取失败")
self._set_status(f"AI 生成任务读取失败:{exc}")
return
self.model.set_tasks(filtered_tasks, accounts_rows)
self.summary_label.setText(
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
)
def _populate_batch_filter(self, batches, selected_batch):
previous = selected_batch if selected_batch in {batch.id for batch in batches} else None
self.batch_filter.blockSignals(True)
self.batch_filter.clear()
self.batch_filter.addItem("全部批次", None)
for batch in batches:
self.batch_filter.addItem(self._batch_label(batch), batch.id)
index = self.batch_filter.findData(previous)
self.batch_filter.setCurrentIndex(index if index >= 0 else 0)
self.batch_filter.blockSignals(False)
def _populate_shop_filter(self, tasks, account_rows, selected_shop):
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
aliases = []
for task in tasks:
alias = str(task.alias).strip()
if alias and alias not in aliases:
aliases.append(alias)
previous = selected_shop if selected_shop in aliases else None
self.shop_filter.blockSignals(True)
self.shop_filter.clear()
self.shop_filter.addItem("全部店铺", None)
for alias in sorted(aliases, key=lambda value: self._shop_label(value, account_by_alias)):
self.shop_filter.addItem(self._shop_label(alias, account_by_alias), alias)
index = self.shop_filter.findData(previous)
self.shop_filter.setCurrentIndex(index if index >= 0 else 0)
self.shop_filter.blockSignals(False)
def _batch_label(self, batch):
source_files = batch.source_files
first_file = os.path.basename(source_files[0]) if source_files else batch.id
return f"{batch.created_at} · {first_file}"
def _shop_label(self, alias, account_by_alias):
account = account_by_alias.get(alias)
if account is not None:
return f"{account.account_name} ({alias})"
return alias
def _matches_shop(self, task, selected_shop):
return selected_shop is None or str(task.alias).strip() == selected_shop
def _matches_status(self, task, selected_status):
if selected_status in (None, "all"):
return True
if selected_status == "to_generate":
return task.stage == "collected" and task.status in {"success", "pending"}
if selected_status == "generated":
return task.stage == "generated"
if selected_status == "applied":
return task.stage == "applied"
if selected_status == "failed":
return task.status == "failed"
if selected_status == "skipped":
return task.status == "skipped"
return True
class CollectTab(QWidget):
"""Tab 1: import Excel files and list imported tasks."""
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
open_accounts_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.current_batch_id = None
self.has_import_result = False
self.last_import_stats = None
self.collect_worker = None
self.collect_thread = None
self.write_back_worker = None
self.write_back_thread = None
self.import_button = QPushButton("导入 Excel...")
self.refresh_button = QPushButton("刷新")
self.collect_button = QPushButton("采集旧标题/旧封面")
self.stop_collect_button = QPushButton("停止")
self.write_back_button = QPushButton("回写旧数据到 Excel")
self.stop_collect_button.setEnabled(False)
toolbar = QHBoxLayout()
toolbar.addWidget(self.import_button)
toolbar.addWidget(self.refresh_button)
toolbar.addWidget(self.collect_button)
toolbar.addWidget(self.stop_collect_button)
toolbar.addWidget(self.write_back_button)
toolbar.addStretch(1)
self.summary_label = QLabel("未导入任务")
self.match_detail_label = QLabel("")
self.show_all_button = QPushButton("全部")
self.show_unmatched_button = QPushButton("未匹配(0)")
summary_layout = QHBoxLayout()
summary_layout.addWidget(self.summary_label)
summary_layout.addStretch(1)
summary_layout.addWidget(self.show_all_button)
summary_layout.addWidget(self.show_unmatched_button)
self.table = QTableView()
self.model = TaskTableModel(self.table)
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.empty_label = QLabel("")
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(toolbar)
layout.addLayout(summary_layout)
layout.addWidget(self.match_detail_label)
layout.addWidget(self.table, 1)
layout.addWidget(self.empty_label)
self.import_button.clicked.connect(self.import_excel)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.collect_button.clicked.connect(self.collect_old_data)
self.stop_collect_button.clicked.connect(self.stop_collect)
self.write_back_button.clicked.connect(self.write_back_old_data)
self.show_all_button.clicked.connect(self.show_all_tasks)
self.show_unmatched_button.clicked.connect(self.show_unmatched_tasks)
self.refresh_tasks()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def _show_error(self, message):
QMessageBox.warning(self, "导入采集", str(message))
self._set_status(str(message))
def _show_account_guide(self, message):
full_message = (
f"{message}\n\n"
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。"
)
QMessageBox.warning(self, "账号未就绪", full_message)
self._set_status(full_message.replace("\n", " "))
if self.open_accounts_callback is not None:
self.open_accounts_callback()
def _choose_excel_files(self):
files, _selected_filter = QFileDialog.getOpenFileNames(
self,
"选择 Excel 文件",
"",
"Excel 文件 (*.xlsx *.xlsm)",
)
return files
def import_excel(self, checked=False):
file_paths = self._choose_excel_files()
if not file_paths:
return
try:
result = excel.import_tasks(file_paths, path=self.db_path)
except Exception as exc:
self._show_error(exc)
return
self.has_import_result = True
self.last_import_stats = result.get("stats") or {}
self.current_batch_id = result.get("batch_id")
self.refresh_tasks()
self._set_status(
"导入完成:有效{valid},无效{invalid},入库{inserted},未匹配{unmatched}".format(
valid=self.last_import_stats.get("valid", 0),
invalid=self.last_import_stats.get("invalid", 0),
inserted=self.last_import_stats.get("inserted", 0),
unmatched=self.model.unmatched_count(),
)
)
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
if self.current_batch_id is None and self.has_import_result:
task_rows = []
else:
task_rows = db.list_tasks(batch_id=self.current_batch_id, path=self.db_path)
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
except Exception as exc:
self.model.set_tasks([], [])
self.empty_label.setText("任务读取失败")
self._set_status(f"任务读取失败:{exc}")
return
self.model.set_tasks(task_rows, account_rows)
self._update_summary(task_rows, account_rows)
self._update_empty_label(len(task_rows))
def collect_old_data(self, checked=False):
tasks = list(self.model.all_tasks)
if not tasks:
self._set_status("没有可采集任务")
return
worker = CollectWorker(tasks, db_path=self.db_path, config=self.config)
worker.progress.connect(self._on_collect_progress)
worker.row_updated.connect(self._on_collect_row_updated)
worker.log.connect(self._set_status)
worker.failed.connect(self._on_collect_failed)
worker.finished.connect(self._on_collect_finished)
worker.cancelled.connect(self._on_collect_cancelled)
thread = run_worker(worker, thread_name="CollectWorker", start=False)
thread.finished.connect(lambda: self._forget_collect_thread(thread))
self.collect_worker = worker
self.collect_thread = thread
self._set_collect_running(True)
thread.start()
def stop_collect(self, checked=False):
if self.collect_worker is not None:
self.collect_worker.cancel()
self._set_status("正在停止采集...")
def write_back_old_data(self, checked=False):
batch_id = self._active_batch_id()
if not batch_id:
self._set_status("没有可回写批次")
return
self._start_write_back(batch_id)
def _start_write_back(self, batch_id, auto=False):
if self.write_back_thread is not None:
self._set_status("Excel 回写正在进行...")
return False
worker = WriteBackWorker(batch_id, db_path=self.db_path)
worker.failed.connect(
lambda task_id, error, auto=auto: self._on_write_back_failed(
task_id,
error,
auto=auto,
)
)
worker.finished.connect(
lambda payload, auto=auto: self._on_write_back_finished(
payload,
auto=auto,
)
)
thread = run_worker(worker, thread_name="WriteBackWorker", start=False)
thread.finished.connect(lambda: self._forget_write_back_thread(thread))
self.write_back_worker = worker
self.write_back_thread = thread
self._set_write_back_running(True)
self._set_status("正在自动回写旧数据到 Excel..." if auto else "正在回写旧数据到 Excel...")
thread.start()
return True
def _active_batch_id(self):
if self.current_batch_id:
return self.current_batch_id
batch_ids = {
task.batch_id
for task in self.model.all_tasks
if getattr(task, "batch_id", None)
}
if len(batch_ids) == 1:
return next(iter(batch_ids))
return None
def _set_collect_running(self, running):
self.import_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.collect_button.setEnabled(not running)
self.write_back_button.setEnabled(not running)
self.stop_collect_button.setEnabled(running)
def _set_write_back_running(self, running):
self.import_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.collect_button.setEnabled(not running)
self.write_back_button.setEnabled(not running)
def _forget_collect_thread(self, thread):
if self.collect_thread is thread:
self.collect_thread = None
self.collect_worker = None
def _forget_write_back_thread(self, thread):
if self.write_back_thread is thread:
self.write_back_thread = None
self.write_back_worker = None
def _on_collect_progress(self, payload):
self._set_status(
"采集进度:{done}/{total},成功{collected},略过{skipped},失败{failed}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
collected=payload.get("collected", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
)
def _on_collect_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_collect_failed(self, task_id, error):
self._set_status(f"任务 {task_id} 采集失败:{error}")
def _on_collect_finished(self, payload):
self._set_collect_running(False)
self.refresh_tasks()
if payload.get("blocked"):
self._show_collect_blocked(payload)
return
message = "采集完成:成功{collected},略过{skipped},失败{failed}".format(
collected=payload.get("collected", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
if payload.get("collected", 0) > 0:
batch_id = self._active_batch_id()
if batch_id and self._start_write_back(batch_id, auto=True):
self._set_status(f"{message},正在自动回写 Excel...")
return
if not batch_id:
self._set_status(f"{message},但没有可回写批次")
return
self._set_status(f"{message},Excel 回写已在进行")
return
self._set_status(message)
def _show_collect_blocked(self, payload):
lines = ["采集前检查未通过。"]
if payload.get("no_accounts"):
lines.append("当前没有配置账号。")
not_running = payload.get("not_running") or []
if not_running:
lines.append(
"以下账号 Chrome 未启动或调试端口不可访问:"
+ "、".join(self._account_label(item) for item in not_running)
)
logged_out = payload.get("logged_out") or []
if logged_out:
lines.append(
"以下账号未登录 Shopee:"
+ "、".join(self._account_label(item) for item in logged_out)
)
self._show_account_guide("\n".join(lines))
def _account_label(self, item):
if isinstance(item, dict):
name = item.get("account_name") or item.get("alias") or ""
alias = item.get("alias") or ""
reason = item.get("reason")
else:
name = getattr(item, "account_name", "") or getattr(item, "alias", "")
alias = getattr(item, "alias", "")
reason = getattr(item, "reason", None)
label = f"{name}({alias})" if alias and name != alias else (name or alias)
return f"{label}: {reason}" if reason else label
def _on_collect_cancelled(self, payload):
self._set_collect_running(False)
self.refresh_tasks()
self._set_status(
"采集已停止:完成{done}/{total}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
)
)
def _on_write_back_failed(self, task_id, error, auto=False):
message = f"Excel {'自动' if auto else ''}回写失败:{error}"
if "被占用" in str(error):
if auto:
message += "\n请关闭原 Excel 后点击「回写旧数据到 Excel」手动重试;SQLite 已保留采集结果,也可另存副本。"
else:
message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。"
QMessageBox.warning(self, "回写旧数据", message)
self._set_status(message.replace("\n", " "))
def _on_write_back_finished(self, payload, auto=False):
self._set_write_back_running(False)
if payload.get("ok") is False:
error = payload.get("error") or "未知错误"
retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else ""
self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}")
return
self.refresh_tasks()
self._set_status(
"Excel {prefix}回写完成:文件{files},行{rows}".format(
prefix="自动" if auto else "",
files=payload.get("files", 0),
rows=payload.get("rows", 0),
)
)
def show_all_tasks(self, checked=False):
self.model.set_filter_mode("all")
self._update_empty_label(len(self.model.all_tasks))
def show_unmatched_tasks(self, checked=False):
self.model.set_filter_mode("unmatched")
self._update_empty_label(len(self.model.all_tasks))
def _update_summary(self, task_rows, account_rows):
stats = self.last_import_stats or {}
unmatched = self._unmatched_count(task_rows, account_rows)
matched = len(task_rows) - unmatched
files = stats.get("files", 0 if not task_rows else 1)
total = stats.get("total", len(task_rows))
valid = stats.get("valid", len(task_rows))
invalid = stats.get("invalid", 0)
self.summary_label.setText(
f"{files} 文件 · {total} 行 · 有效{valid}/无效{invalid} · 匹配{matched} · 未匹配{unmatched}"
)
self.match_detail_label.setText(self._match_detail(task_rows, account_rows))
self.show_unmatched_button.setText(f"未匹配({unmatched})")
self.show_unmatched_button.setEnabled(unmatched > 0)
if unmatched == 0 and self.model.filter_mode == "unmatched":
self.model.set_filter_mode("all")
def _match_detail(self, task_rows, account_rows):
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
counts = {}
for task in task_rows:
account = account_by_alias.get(str(task.alias).strip())
if account is None:
continue
name = account.account_name or account.alias
counts[name] = counts.get(name, 0) + 1
if not counts:
return "匹配明细:无"
parts = [f"{name}{count}" for name, count in sorted(counts.items())]
return "匹配明细:" + " · ".join(parts)
def _unmatched_count(self, task_rows, account_rows):
aliases = {
str(account.alias).strip()
for account in account_rows
if str(account.alias).strip()
}
return sum(1 for task in task_rows if str(task.alias).strip() not in aliases)
def _update_empty_label(self, total_rows):
if total_rows == 0:
self.empty_label.setText("暂无任务")
return
if self.model.rowCount() == 0 and self.model.filter_mode == "unmatched":
self.empty_label.setText("没有未匹配任务")
return
unmatched = self.model.unmatched_count()
self.empty_label.setText(
"" if unmatched == 0 else f"{unmatched} 条任务别名未匹配账号,阶段显示为“略过”"
)
class AccountDialog(QDialog):
"""Dialog for adding or editing one account."""
def __init__(self, parent=None, account=None, default_port=9222, config=None):
super().__init__(parent)
self._account = account
self._config = config
self.setWindowTitle("编辑账号" if account else "新增账号")
self.account_name_edit = QLineEdit()
self.alias_edit = QLineEdit()
self.region_host_edit = QLineEdit(accounts.DEFAULT_REGION_HOST)
self.debug_port_spin = QSpinBox()
self.debug_port_spin.setRange(1, 65535)
self.debug_port_spin.setValue(int(default_port))
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.Password)
self.note_edit = QPlainTextEdit()
self.note_edit.setMaximumHeight(76)
self.slug_edit = QLineEdit()
self.slug_edit.setReadOnly(True)
self.user_data_dir_edit = QLineEdit()
self.user_data_dir_edit.setReadOnly(True)
if account is not None:
self.account_name_edit.setText(account.account_name)
self.alias_edit.setText(account.alias)
self.region_host_edit.setText(account.region_host)
self.debug_port_spin.setValue(int(account.debug_port))
self.password_edit.setText(account.password or "")
self.note_edit.setPlainText(account.note or "")
self.slug_edit.setText(account.slug)
self.user_data_dir_edit.setText(account.user_data_dir)
form = QFormLayout()
form.addRow("账号名", self.account_name_edit)
form.addRow("别名", self.alias_edit)
form.addRow("地区", self.region_host_edit)
form.addRow("调试端口", self.debug_port_spin)
form.addRow("密码", self.password_edit)
form.addRow("备注", self.note_edit)
form.addRow("slug", self.slug_edit)
form.addRow("数据目录", self.user_data_dir_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(buttons)
self.alias_edit.textChanged.connect(self._update_path_preview)
self._update_path_preview()
def _update_path_preview(self):
alias = self.alias_edit.text().strip()
if not alias:
self.slug_edit.clear()
self.user_data_dir_edit.clear()
return
try:
slug = account_config.make_slug(alias)
self.slug_edit.setText(slug)
if self._account is not None and alias == self._account.alias:
self.user_data_dir_edit.setText(self._account.user_data_dir)
else:
self.user_data_dir_edit.setText(
accounts.preview_user_data_dir(alias, config=self._config)
)
except Exception:
self.slug_edit.clear()
self.user_data_dir_edit.clear()
def values(self):
return {
"account_name": self.account_name_edit.text().strip(),
"alias": self.alias_edit.text().strip(),
"region_host": self.region_host_edit.text().strip(),
"debug_port": self.debug_port_spin.value(),
"password": self.password_edit.text(),
"note": self.note_edit.toPlainText().strip(),
}
from .workers import BaseWorker, run_worker
class CollectWorker(BaseWorker):
"""Collect old title and cover for imported tasks."""
def __init__(self, tasks, db_path=None, config=None, preflight=True):
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
eligible = [
task for task in self.tasks
if getattr(task, "stage", None) == "imported"
]
total = len(eligible)
collected = 0
skipped = 0
failed = 0
done = 0
if self.preflight:
blocked = self._preflight_block(eligible, account_rows, account_by_alias)
if blocked:
blocked.update(
{
"ok": False,
"blocked": True,
"total": total,
"done": 0,
"collected": 0,
"skipped": 0,
"failed": 0,
}
)
return blocked
for task in eligible:
if self.should_cancel():
break
account = account_by_alias.get(str(task.alias).strip())
if account is None:
skipped += 1
done += 1
reason = "别名未匹配账号"
db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
self._emit_progress(done, total, collected, skipped, failed)
continue
status = self._login_status(account)
if not status.get("logged_in"):
skipped += 1
done += 1
reason = self._login_skip_reason(status)
db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
self._emit_progress(done, total, collected, skipped, failed)
continue
try:
db.mark_running(task.id, "collect", path=self.db_path)
self.row_updated.emit(task.id, {"status": "running"})
result = editor.collect(
account,
{
"item_id": task.item_id,
"old_cover_path": self._old_cover_path(account, task),
},
)
db.set_collected(
task.id,
result.get("old_title", ""),
result.get("old_cover_path", ""),
path=self.db_path,
)
collected += 1
self.row_updated.emit(
task.id,
{
"stage": "collected",
"status": "success",
"old_title": result.get("old_title", ""),
"old_cover_path": result.get("old_cover_path", ""),
},
)
except Exception as exc:
failed += 1
error = str(exc) or exc.__class__.__name__
db.mark_failed(task.id, "collect", error, path=self.db_path)
self.failed.emit(task.id, error)
self.row_updated.emit(task.id, {"status": "failed", "last_error": error})
finally:
done += 1
self._emit_progress(done, total, collected, skipped, failed)
return {
"ok": failed == 0,
"total": total,
"done": done,
"collected": collected,
"skipped": skipped,
"failed": failed,
}
def _preflight_block(self, eligible, account_rows, account_by_alias):
if not account_rows:
return {
"reason": "NO_ACCOUNTS",
"no_accounts": True,
}
required_accounts = []
seen_aliases = set()
for task in eligible:
alias = str(task.alias).strip()
account = account_by_alias.get(alias)
if account is not None and alias not in seen_aliases:
required_accounts.append(account)
seen_aliases.add(alias)
not_running = []
logged_out = []
for account in required_accounts:
if not chrome.is_running(account.debug_port):
not_running.append(self._account_payload(account, "CDP 端口未响应"))
continue
status = self._login_status(account)
if not status.get("logged_in"):
logged_out.append(
self._account_payload(account, self._login_skip_reason(status))
)
if not_running or logged_out:
return {
"reason": "ACCOUNT_NOT_READY",
"not_running": not_running,
"logged_out": logged_out,
}
return None
def _account_payload(self, account, reason=None):
payload = {
"account_name": account.account_name,
"alias": account.alias,
"debug_port": account.debug_port,
}
if reason:
payload["reason"] = reason
return payload
def _emit_progress(self, done, total, collected, skipped, failed):
self.progress.emit(
{
"done": done,
"total": total,
"collected": collected,
"skipped": skipped,
"failed": failed,
}
)
def _login_status(self, account):
try:
return accounts.detect_login(account, path=self.db_path, config=self.config)
except Exception as exc:
return {
"logged_in": False,
"reason": f"LOGIN_CHECK_FAILED: {exc}",
}
def _login_skip_reason(self, status):
reason = status.get("reason")
return f"账号未登录: {reason}" if reason else "账号未登录"
def _old_cover_path(self, account, task):
image_root = appconfig.image_dir(self.config)
return os.path.abspath(
os.path.join(
image_root,
account.slug,
f"{task.item_id}_old.jpg",
)
)
class WriteBackWorker(BaseWorker):
"""Write collected old fields back to Excel in a background thread."""
def __init__(self, batch_id, db_path=None, excel_path=None):
super().__init__()
self.batch_id = batch_id
self.db_path = db_path
self.excel_path = excel_path
def execute(self):
result = excel.write_back(
self.batch_id,
excel_path=self.excel_path,
path=self.db_path,
)
self.progress.emit(
{
"done": result.get("rows", 0),
"total": result.get("rows", 0),
"files": result.get("files", 0),
}
)
return result
class AccountLoginCheckWorker(BaseWorker):
def __init__(self, account, db_path=None, config=None, timeout=8):
super().__init__()
self.account = account
self.db_path = db_path
self.config = config
self.timeout = timeout
def execute(self):
status = accounts.detect_login(
self.account,
timeout=self.timeout,
path=self.db_path,
config=self.config,
)
self.row_updated.emit(self.account.id, status)
return {"alias": self.account.alias, "status": status}
class AccountsTab(QWidget):
COLUMNS = ["账号名", "别名", "地区", "端口", "登录状态", "备注"]
def __init__(self, parent=None, db_path=None, config=None, status_callback=None):
super().__init__(parent)
self.db_path = db_path
self.config = appconfig.load_config() if config is None else config
self.status_callback = status_callback
self.account_rows = []
self.login_statuses = {}
self.threads = []
self.table = QTableWidget(0, len(self.COLUMNS))
self.table.setHorizontalHeaderLabels(self.COLUMNS)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.add_button = QPushButton("新增")
self.edit_button = QPushButton("编辑")
self.delete_button = QPushButton("删除")
self.launch_button = QPushButton("启动登录")
self.check_button = QPushButton("检测登录")
self.shortcut_button = QPushButton("快捷方式")
toolbar = QHBoxLayout()
for button in (
self.add_button,
self.edit_button,
self.delete_button,
self.launch_button,
self.check_button,
self.shortcut_button,
):
toolbar.addWidget(button)
toolbar.addStretch(1)
self.empty_label = QLabel("")
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(toolbar)
layout.addWidget(self.table, 1)
layout.addWidget(self.empty_label)
self.add_button.clicked.connect(self.add_account)
self.edit_button.clicked.connect(self.edit_account)
self.delete_button.clicked.connect(self.delete_account)
self.launch_button.clicked.connect(self.launch_login)
self.check_button.clicked.connect(self.check_login)
self.shortcut_button.clicked.connect(self.create_shortcut)
self.table.itemSelectionChanged.connect(self._update_button_state)
self.table.doubleClicked.connect(self.edit_account)
self.refresh_accounts()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def _selected_account(self):
selected = self.table.selectionModel().selectedRows()
if not selected:
return None
row = selected[0].row()
if row < 0 or row >= len(self.account_rows):
return None
return self.account_rows[row]
def _update_button_state(self):
has_selection = self._selected_account() is not None
for button in (
self.edit_button,
self.delete_button,
self.launch_button,
self.check_button,
self.shortcut_button,
):
button.setEnabled(has_selection)
def refresh_accounts(self):
try:
self.account_rows = accounts.list_accounts(
path=self.db_path,
config=self.config,
)
except Exception as exc:
self.account_rows = []
self._set_status(f"账号读取失败:{exc}")
self.table.setRowCount(len(self.account_rows))
for row, account in enumerate(self.account_rows):
status = self.login_statuses.get(account.alias, "未知")
values = [
account.account_name,
account.alias,
account.region_host,
str(account.debug_port),
status,
account.note or "",
]
for column, value in enumerate(values):
item = QTableWidgetItem(value)
self.table.setItem(row, column, item)
self.empty_label.setText("" if self.account_rows else "暂无账号")
self._update_button_state()
def _show_error(self, message):
QMessageBox.warning(self, "账号管理", str(message))
self._set_status(str(message))
def add_account(self, checked=False):
try:
default_port = accounts.next_debug_port(
path=self.db_path,
config=self.config,
)
except Exception:
default_port = appconfig.default_debug_port(self.config)
dialog = AccountDialog(self, default_port=default_port, config=self.config)
if dialog.exec() != QDialog.Accepted:
return
try:
accounts.create_account(
path=self.db_path,
config=self.config,
**dialog.values(),
)
except Exception as exc:
self._show_error(exc)
return
self.refresh_accounts()
self._set_status("账号已新增")
def edit_account(self, checked=False):
account = self._selected_account()
if account is None:
return
dialog = AccountDialog(
self,
account=account,
default_port=account.debug_port,
config=self.config,
)
if dialog.exec() != QDialog.Accepted:
return
try:
updated = accounts.update_account(
account.alias,
path=self.db_path,
config=self.config,
**dialog.values(),
)
except Exception as exc:
self._show_error(exc)
return
if updated.alias != account.alias:
self.login_statuses.pop(account.alias, None)
self.refresh_accounts()
self._set_status("账号已保存")
def delete_account(self, checked=False):
account = self._selected_account()
if account is None:
return
answer = QMessageBox.question(
self,
"删除账号",
f"确认删除账号「{account.alias}」?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
return
try:
accounts.delete_account(account.alias, path=self.db_path, config=self.config)
except Exception as exc:
self._show_error(exc)
return
self.login_statuses.pop(account.alias, None)
self.refresh_accounts()
self._set_status("账号已删除")
def launch_login(self, checked=False):
account = self._selected_account()
if account is None:
return
try:
accounts.launch_for_login(account, config=self.config)
except Exception as exc:
self._show_error(exc)
return
self.login_statuses[account.alias] = "已启动"
self.refresh_accounts()
self._set_status("Chrome 已启动,请人工登录")
def check_login(self, checked=False):
account = self._selected_account()
if account is None:
return
self.login_statuses[account.alias] = "检测中"
self.refresh_accounts()
worker = AccountLoginCheckWorker(
account,
db_path=self.db_path,
config=self.config,
)
worker.finished.connect(self._on_login_check_finished)
worker.failed.connect(
lambda _task_id, error, alias=account.alias: self._on_login_check_failed(
alias,
error,
)
)
thread = run_worker(worker, start=False)
thread.finished.connect(lambda: self._forget_thread(thread))
self.threads.append(thread)
thread.start()
def _forget_thread(self, thread):
if thread in self.threads:
self.threads.remove(thread)
def _on_login_check_finished(self, payload):
if payload.get("ok") is False and not payload.get("alias"):
return
alias = payload.get("alias")
status = payload.get("status") or {}
if alias:
self.login_statuses[alias] = accounts.login_status_text(status)
self.refresh_accounts()
self._set_status("登录状态已刷新")
def _on_login_check_failed(self, alias, error):
self.login_statuses[alias] = "检测失败"
self.refresh_accounts()
self._set_status(f"登录检测失败:{error}")
def create_shortcut(self, checked=False):
account = self._selected_account()
if account is None:
return
try:
shortcut_path = accounts.create_shortcut(account, config=self.config)
except Exception as exc:
self._show_error(exc)
return
self._set_status(f"快捷方式已生成:{shortcut_path}")
QMessageBox.information(self, "账号管理", f"快捷方式已生成:\n{shortcut_path}")
class MainWindow(QMainWindow):
"""Main application window with the fixed five-tab workflow."""
def __init__(self, db_path=None, config=None):
super().__init__()
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.setWindowTitle("cmshopee")
self.resize(1180, 760)
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)
self.setCentralWidget(self.tabs)
self.statusBar().showMessage("就绪")
def _build_tab(self, title):
if title == "① 导入采集":
return CollectTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
open_accounts_callback=lambda: self.open_accounts_tab(),
)
if title == "② AI生成":
return GenerateTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
)
if title == "④ 账号管理":
return AccountsTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
)
widget = QWidget()
widget.setObjectName(title)
layout = QVBoxLayout(widget)
layout.setContentsMargins(18, 18, 18, 18)
layout.addStretch(1)
return widget
def _on_tab_changed(self, index):
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
def open_accounts_tab(self):
self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理"))
else:
class MainWindow(QMainWindow):
def __init__(self):
raise RuntimeError("PySide6 未安装,无法启动 GUI")
def _ensure_offscreen_for_headless_tests():
if "PYTEST_CURRENT_TEST" in os.environ and "QT_QPA_PLATFORM" not in os.environ:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
def main() -> int:
if QT_IMPORT_ERROR is not None:
print("cmshopee GUI 无法启动:当前 Python 环境未安装 PySide6。")
return 1
_ensure_offscreen_for_headless_tests()
app = QApplication.instance() or QApplication(sys.argv)
window = MainWindow()
window.show()
return app.exec()