新增GenerateTab和GenerateTaskTableModel,将② AI生成从占位页替换为左右布局:左侧标题/封面提示词多行输入,右侧批次、店铺、状态筛选和任务列表。 任务列表展示店铺、商品ID、旧标题、新标题、状态;筛选只读SQLite,不调用AI、不写库,生成执行留给T-303,提示词管理留给T-302p。 补充GUI测试覆盖Tab②挂载、提示词输入区、任务列表展示与批次/店铺/状态筛选;同步任务看板、API、routes、current-state和progress。
1515 lines
60 KiB
Python
1515 lines
60 KiB
Python
"""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,
|
||
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
|
||
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 _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):
|
||
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_edit = QPlainTextEdit()
|
||
self.title_prompt_edit.setObjectName("titlePromptEdit")
|
||
self.title_prompt_edit.setPlaceholderText("标题提示词")
|
||
self.cover_prompt_edit = QPlainTextEdit()
|
||
self.cover_prompt_edit.setObjectName("coverPromptEdit")
|
||
self.cover_prompt_edit.setPlaceholderText("封面提示词")
|
||
|
||
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(QLabel("封面提示词"))
|
||
left_layout.addWidget(self.cover_prompt_edit, 2)
|
||
|
||
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.refresh_tasks()
|
||
|
||
def _set_status(self, message):
|
||
if self.status_callback is not None:
|
||
self.status_callback(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()
|