Files
cmshoppe/app/gui.py
T

2552 lines
104 KiB
Python
Raw Normal View History

2026-06-27 09:56:53 +08:00
"""PySide6 GUI entry point."""
2026-06-26 17:24:23 +08:00
2026-06-27 09:56:53 +08:00
from __future__ import annotations
import os
import sys
try:
2026-06-27 11:29:48 +08:00
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
2026-06-27 16:26:08 +08:00
from PySide6.QtGui import QPixmap
2026-06-27 09:56:53 +08:00
from PySide6.QtWidgets import (
2026-06-27 10:30:45 +08:00
QAbstractItemView,
2026-06-27 09:56:53 +08:00
QApplication,
2026-06-27 15:56:55 +08:00
QComboBox,
2026-06-27 10:30:45 +08:00
QDialog,
QDialogButtonBox,
2026-06-27 11:29:48 +08:00
QFileDialog,
2026-06-27 10:30:45 +08:00
QFormLayout,
QHBoxLayout,
QHeaderView,
2026-06-27 16:10:50 +08:00
QInputDialog,
2026-06-27 10:30:45 +08:00
QLabel,
QLineEdit,
2026-06-27 09:56:53 +08:00
QMainWindow,
2026-06-27 10:30:45 +08:00
QMessageBox,
QPlainTextEdit,
QPushButton,
2026-06-27 15:56:55 +08:00
QSplitter,
2026-06-27 11:29:48 +08:00
QTableView,
2026-06-27 10:30:45 +08:00
QSpinBox,
QTableWidget,
QTableWidgetItem,
2026-06-27 09:56:53 +08:00
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",
"④ 账号管理",
"⑤ 设置",
]
2026-06-27 10:30:45 +08:00
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;
}
"""
2026-06-27 09:56:53 +08:00
if QT_IMPORT_ERROR is None:
2026-06-27 16:26:08 +08:00
from . import accounts, ai, appconfig, chrome, db, editor, excel, prompts
2026-06-27 10:30:45 +08:00
from . import config as account_config
2026-06-27 11:29:48 +08:00
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 = []
2026-06-27 11:40:51 +08:00
self.all_tasks = []
2026-06-27 11:29:48 +08:00
self.account_by_alias = {}
2026-06-27 11:40:51 +08:00
self.filter_mode = "all"
2026-06-27 11:29:48 +08:00
def set_tasks(self, tasks, accounts):
self.beginResetModel()
2026-06-27 11:40:51 +08:00
self.all_tasks = list(tasks)
2026-06-27 11:29:48 +08:00
self.account_by_alias = {
str(account.alias).strip(): account
for account in accounts
if str(account.alias).strip()
}
2026-06-27 11:40:51 +08:00
self.tasks = self._filtered_tasks()
2026-06-27 11:29:48 +08:00
self.endResetModel()
2026-06-27 11:40:51 +08:00
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)
2026-06-27 11:29:48 +08:00
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:
2026-06-27 11:40:51 +08:00
return sum(1 for task in self.all_tasks if self.is_unmatched(task))
2026-06-27 11:29:48 +08:00
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
2026-06-27 15:56:55 +08:00
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
2026-06-27 16:10:50 +08:00
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)
2026-06-27 15:56:55 +08:00
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
2026-06-27 16:59:46 +08:00
class ApplyTaskTableModel(QAbstractTableModel):
"""Table model for Tab 3 update candidates."""
HEADERS = ["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"]
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
"pending": "待更新",
"success": "成功",
}
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):
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 _display_value(self, task, column):
values = [
self.account_name_for(task),
task.item_id,
task.new_title or "",
os.path.basename(task.new_cover_path or ""),
self._stage_text(task),
self._result_text(task),
]
return values[column] if 0 <= column < len(values) else None
def _stage_text(self, task):
if task.stage == "generated":
return "待更新"
if task.stage == "applied":
return "已更新"
return task.stage
def _result_text(self, task):
if task.status == "success" and task.stage == "generated":
return "待更新"
return self.STATUS_TEXT.get(task.status, task.status)
2026-06-27 15:56:55 +08:00
class GenerateTab(QWidget):
"""Tab 2: prompt area plus generation task filters/list."""
STATUS_FILTERS = [
("全部状态", "all"),
("待生成", "to_generate"),
("已生成", "generated"),
("失败", "failed"),
("略过", "skipped"),
("已更新", "applied"),
]
2026-06-27 16:10:50 +08:00
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
title_prompt_path=None,
cover_prompts_dir=None,
):
2026-06-27 15:56:55 +08:00
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
2026-06-27 16:10:50 +08:00
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
2026-06-27 16:26:08 +08:00
self.generate_worker = None
self.generate_thread = None
2026-06-27 15:56:55 +08:00
self.title_prompt_edit = QPlainTextEdit()
self.title_prompt_edit.setObjectName("titlePromptEdit")
self.title_prompt_edit.setPlaceholderText("标题提示词")
2026-06-27 16:10:50 +08:00
self.title_prompt_edit.setPlainText(
prompts.load_title_prompt(self.title_prompt_path)
)
self.save_title_button = QPushButton("保存标题提示词")
2026-06-27 15:56:55 +08:00
self.cover_prompt_edit = QPlainTextEdit()
self.cover_prompt_edit.setObjectName("coverPromptEdit")
self.cover_prompt_edit.setPlaceholderText("封面提示词")
2026-06-27 16:10:50 +08:00
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("预览")
2026-06-27 16:26:08 +08:00
self.generate_button = QPushButton("开始生成")
self.stop_generate_button = QPushButton("停止")
self.stop_generate_button.setEnabled(False)
self.progress_label = QLabel("进度:标题0/0 · 封面0/0 · 失败0")
2026-06-27 15:56:55 +08:00
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)
2026-06-27 16:10:50 +08:00
left_layout.addWidget(self.save_title_button)
2026-06-27 15:56:55 +08:00
left_layout.addWidget(QLabel("封面提示词"))
2026-06-27 16:10:50 +08:00
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)
2026-06-27 15:56:55 +08:00
left_layout.addWidget(self.cover_prompt_edit, 2)
2026-06-27 16:10:50 +08:00
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)
2026-06-27 15:56:55 +08:00
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])
2026-06-27 16:26:08 +08:00
bottom_layout = QHBoxLayout()
bottom_layout.addWidget(self.progress_label)
bottom_layout.addStretch(1)
bottom_layout.addWidget(self.generate_button)
bottom_layout.addWidget(self.stop_generate_button)
2026-06-27 15:56:55 +08:00
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addWidget(self.splitter, 1)
2026-06-27 16:26:08 +08:00
layout.addLayout(bottom_layout)
2026-06-27 15:56:55 +08:00
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)
2026-06-27 16:10:50 +08:00
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)
2026-06-27 16:26:08 +08:00
self.generate_button.clicked.connect(self.start_generate)
self.stop_generate_button.clicked.connect(self.stop_generate)
self.task_table.doubleClicked.connect(self.show_task_images)
2026-06-27 15:56:55 +08:00
2026-06-27 16:10:50 +08:00
self.refresh_cover_templates()
2026-06-27 15:56:55 +08:00
self.refresh_tasks()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
2026-06-27 16:10:50 +08:00
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("封面提示词预览已生成")
2026-06-27 16:26:08 +08:00
def start_generate(self, checked=False):
if self.generate_thread is not None:
self._set_status("AI 生成正在进行...")
return
tasks = [
task for task in self.model.tasks
if getattr(task, "stage", None) == "collected"
]
if not tasks:
self._set_status("当前筛选结果没有可生成任务")
return
prompt_values = {
"title": self.title_prompt_edit.toPlainText(),
"cover": self.cover_prompt_edit.toPlainText(),
}
worker = GenerateWorker(
tasks,
prompt_values,
db_path=self.db_path,
config=self.config,
)
worker.progress.connect(self._on_generate_progress)
worker.row_updated.connect(self._on_generate_row_updated)
worker.log.connect(self._set_status)
worker.failed.connect(self._on_generate_failed)
worker.finished.connect(self._on_generate_finished)
worker.cancelled.connect(self._on_generate_cancelled)
thread = run_worker(worker, thread_name="GenerateWorker", start=False)
thread.finished.connect(lambda: self._forget_generate_thread(thread))
self.generate_worker = worker
self.generate_thread = thread
self._set_generate_running(True)
self._update_generate_progress(
{"total": len(tasks), "title_done": 0, "cover_done": 0, "failed": 0}
)
self._set_status(f"开始 AI 生成:{len(tasks)} 条")
thread.start()
def stop_generate(self, checked=False):
if self.generate_worker is not None:
self.generate_worker.cancel()
self._set_status("正在停止 AI 生成...")
def show_task_images(self, index):
task = self.model.task_at(index.row()) if index.isValid() else self._selected_task()
if task is None:
self._set_status("没有可预览的任务")
return
dialog = QDialog(self)
dialog.setWindowTitle(f"封面对照:{task.item_id}")
layout = QVBoxLayout(dialog)
images_layout = QHBoxLayout()
images_layout.addWidget(self._image_panel("旧封面", task.old_cover_path))
images_layout.addWidget(self._image_panel("新封面", task.new_cover_path))
layout.addLayout(images_layout)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
dialog.resize(720, 420)
dialog.exec()
def _image_panel(self, title, path):
panel = QWidget()
layout = QVBoxLayout(panel)
layout.addWidget(QLabel(title))
image_label = QLabel()
image_label.setAlignment(Qt.AlignCenter)
image_label.setMinimumSize(260, 260)
image_label.setWordWrap(True)
if path and os.path.exists(str(path)):
pixmap = QPixmap(str(path))
if not pixmap.isNull():
image_label.setPixmap(
pixmap.scaled(
260,
260,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
)
else:
image_label.setText(f"图片无法读取\n{path}")
else:
image_label.setText(f"无图片\n{path or ''}".strip())
layout.addWidget(image_label, 1)
return panel
def _set_generate_running(self, running):
self.generate_button.setEnabled(not running)
self.stop_generate_button.setEnabled(running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.save_title_button.setEnabled(not running)
self.save_cover_template_button.setEnabled(not running)
self.save_cover_template_as_button.setEnabled(not running)
self.rename_cover_template_button.setEnabled(not running)
self.delete_cover_template_button.setEnabled(not running)
def _forget_generate_thread(self, thread):
if self.generate_thread is thread:
self.generate_thread = None
self.generate_worker = None
def _on_generate_progress(self, payload):
self._update_generate_progress(payload)
self._set_status("生成进度:" + self._generate_progress_text(payload))
def _on_generate_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_generate_failed(self, task_id, error):
self._set_status(f"AI 生成失败:{error}")
def _on_generate_finished(self, payload):
self._set_generate_running(False)
self.refresh_tasks()
self._update_generate_progress(payload)
if payload.get("error"):
self._set_status(f"AI 生成失败:{payload.get('error')}")
return
self._set_status("AI 生成完成:" + self._generate_progress_text(payload))
def _on_generate_cancelled(self, payload):
self._set_generate_running(False)
self.refresh_tasks()
self._update_generate_progress(payload)
self._set_status("AI 生成已停止:" + self._generate_progress_text(payload))
def _update_generate_progress(self, payload):
self.progress_label.setText("进度:" + self._generate_progress_text(payload))
def _generate_progress_text(self, payload):
return "标题{title}/{total} · 封面{cover}/{total} · 失败{failed}".format(
title=payload.get("title_done", 0),
cover=payload.get("cover_done", 0),
total=payload.get("total", 0),
failed=payload.get("failed", 0),
)
2026-06-27 16:10:50 +08:00
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)
2026-06-27 15:56:55 +08:00
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
2026-06-27 16:59:46 +08:00
class ApplyTab(QWidget):
"""Tab 3: list generated tasks and confirm the update scope."""
STATUS_FILTERS = [
("已生成", "generated"),
("失败", "failed"),
("已更新", "applied"),
("略过", "skipped"),
("全部状态", "all"),
]
2026-06-27 17:13:18 +08:00
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
open_accounts_callback=None,
):
2026-06-27 16:59:46 +08:00
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
2026-06-27 17:13:18 +08:00
self.open_accounts_callback = open_accounts_callback
self.apply_worker = None
self.apply_thread = None
2026-06-27 16:59:46 +08:00
self.batch_filter = QComboBox()
self.batch_filter.setObjectName("applyBatchFilter")
self.shop_filter = QComboBox()
self.shop_filter.setObjectName("applyShopFilter")
self.status_filter = QComboBox()
self.status_filter.setObjectName("applyStatusFilter")
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.risk_label = QLabel("点击「开始更新」后会先确认当前筛选范围;确认后才允许后续任务提交线上。")
self.task_table = QTableView()
self.model = ApplyTaskTableModel(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)
self.start_update_button = QPushButton("开始更新")
self.stop_update_button = QPushButton("停止")
self.write_back_button = QPushButton("回写结果到 Excel")
self.stop_update_button.setEnabled(False)
self.write_back_button.setEnabled(False)
action_layout = QHBoxLayout()
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
action_layout.addStretch(1)
action_layout.addWidget(self.write_back_button)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(filter_layout)
layout.addWidget(self.risk_label)
layout.addWidget(self.summary_label)
layout.addWidget(self.task_table, 1)
layout.addLayout(action_layout)
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.start_update_button.clicked.connect(self.start_update)
2026-06-27 17:13:18 +08:00
self.stop_update_button.clicked.connect(self.stop_update)
2026-06-27 16:59:46 +08:00
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)
account_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 "generated"
self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData()
batch_tasks = [
task for task in db.list_tasks(batch_id=selected_batch, path=self.db_path)
if self._is_update_task(task)
]
self._populate_shop_filter(batch_tasks, account_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"更新任务读取失败:{exc}")
return
self.model.set_tasks(filtered_tasks, account_rows)
self.summary_label.setText(
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
)
def start_update(self, checked=False):
2026-06-27 17:13:18 +08:00
if self.apply_thread is not None:
self._set_status("更新正在进行...")
return
tasks = [
task for task in self.model.tasks
if self._is_actionable_task(task)
]
2026-06-27 16:59:46 +08:00
if not tasks:
self._set_status("当前筛选结果没有可更新任务")
return
answer = QMessageBox.question(
self,
"确认开始更新",
self._confirmation_message(tasks),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消开始更新")
return
2026-06-27 17:13:18 +08:00
worker = ApplyWorker(tasks, db_path=self.db_path, config=self.config)
worker.progress.connect(self._on_apply_progress)
worker.row_updated.connect(self._on_apply_row_updated)
worker.log.connect(self._set_status)
worker.failed.connect(self._on_apply_failed)
worker.finished.connect(self._on_apply_finished)
worker.cancelled.connect(self._on_apply_cancelled)
thread = run_worker(worker, thread_name="ApplyWorker", start=False)
thread.finished.connect(lambda: self._forget_apply_thread(thread))
self.apply_worker = worker
self.apply_thread = thread
self._set_apply_running(True)
self._set_status(f"开始更新:{len(tasks)} 条")
thread.start()
def stop_update(self, checked=False):
if self.apply_worker is not None:
self.apply_worker.cancel()
self._set_status("正在停止更新...")
def _is_actionable_task(self, task):
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) in {"success", "pending", "failed"}
and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None))
2026-06-27 16:59:46 +08:00
)
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 _status_label(self):
return self.status_filter.currentText() or "已生成"
def _batch_filter_label(self):
return self.batch_filter.currentText() or "全部批次"
def _shop_filter_label(self):
return self.shop_filter.currentText() or "全部店铺"
def _is_update_task(self, task):
if task.stage in {"generated", "applied"}:
return True
return bool((task.new_title or task.new_cover_path) and task.status in {"failed", "skipped"})
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 == "generated":
return task.stage == "generated" and task.status in {"success", "pending"}
if selected_status == "failed":
return task.status == "failed"
if selected_status == "applied":
return task.stage == "applied"
if selected_status == "skipped":
return task.status == "skipped"
return True
def _confirmation_message(self, tasks):
return (
"即将按当前筛选结果开始更新 Shopee 线上商品。\n\n"
f"批次:{self._batch_filter_label()}\n"
f"店铺:{self._shop_filter_label()}\n"
f"状态:{self._status_label()}\n"
f"任务数:{len(tasks)}\n\n"
"确认后后续执行会打开商品编辑页、替换标题和封面,并点击「更新」提交线上。"
)
2026-06-27 17:13:18 +08:00
def _set_apply_running(self, running):
self.start_update_button.setEnabled(not running)
self.stop_update_button.setEnabled(running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.write_back_button.setEnabled(False)
def _forget_apply_thread(self, thread):
if self.apply_thread is thread:
self.apply_thread = None
self.apply_worker = None
def _on_apply_progress(self, payload):
self._set_status("更新进度:" + self._apply_progress_text(payload))
def _on_apply_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_apply_failed(self, task_id, error):
self._set_status(f"任务 {task_id} 更新失败:{error}")
def _on_apply_finished(self, payload):
self._set_apply_running(False)
self.refresh_tasks()
if payload.get("blocked"):
self._show_apply_blocked(payload)
return
self._set_status("更新完成:" + self._apply_progress_text(payload))
def _on_apply_cancelled(self, payload):
self._set_apply_running(False)
self.refresh_tasks()
self._set_status("更新已停止:" + self._apply_progress_text(payload))
def _apply_progress_text(self, payload):
return "完成{done}/{total},成功{applied},略过{skipped},失败{failed}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
applied=payload.get("applied", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
def _show_apply_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 _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 _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
2026-06-27 16:59:46 +08:00
2026-06-27 11:29:48 +08:00
class CollectTab(QWidget):
"""Tab 1: import Excel files and list imported tasks."""
2026-06-27 15:00:15 +08:00
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
open_accounts_callback=None,
):
2026-06-27 11:29:48 +08:00
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
2026-06-27 15:00:15 +08:00
self.open_accounts_callback = open_accounts_callback
2026-06-27 11:29:48 +08:00
self.current_batch_id = None
2026-06-27 11:40:51 +08:00
self.has_import_result = False
self.last_import_stats = None
2026-06-27 11:51:42 +08:00
self.collect_worker = None
self.collect_thread = None
2026-06-27 14:37:58 +08:00
self.write_back_worker = None
self.write_back_thread = None
2026-06-27 11:29:48 +08:00
self.import_button = QPushButton("导入 Excel...")
self.refresh_button = QPushButton("刷新")
2026-06-27 11:51:42 +08:00
self.collect_button = QPushButton("采集旧标题/旧封面")
self.stop_collect_button = QPushButton("停止")
2026-06-27 14:37:58 +08:00
self.write_back_button = QPushButton("回写旧数据到 Excel")
2026-06-27 11:51:42 +08:00
self.stop_collect_button.setEnabled(False)
2026-06-27 11:29:48 +08:00
toolbar = QHBoxLayout()
toolbar.addWidget(self.import_button)
toolbar.addWidget(self.refresh_button)
2026-06-27 11:51:42 +08:00
toolbar.addWidget(self.collect_button)
toolbar.addWidget(self.stop_collect_button)
2026-06-27 14:37:58 +08:00
toolbar.addWidget(self.write_back_button)
2026-06-27 11:29:48 +08:00
toolbar.addStretch(1)
2026-06-27 11:40:51 +08:00
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)
2026-06-27 11:29:48 +08:00
self.table = QTableView()
2026-06-27 15:00:15 +08:00
self.model = TaskTableModel(self.table)
2026-06-27 11:29:48 +08:00
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)
2026-06-27 11:40:51 +08:00
layout.addLayout(summary_layout)
layout.addWidget(self.match_detail_label)
2026-06-27 11:29:48 +08:00
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)
2026-06-27 11:51:42 +08:00
self.collect_button.clicked.connect(self.collect_old_data)
self.stop_collect_button.clicked.connect(self.stop_collect)
2026-06-27 14:37:58 +08:00
self.write_back_button.clicked.connect(self.write_back_old_data)
2026-06-27 11:40:51 +08:00
self.show_all_button.clicked.connect(self.show_all_tasks)
self.show_unmatched_button.clicked.connect(self.show_unmatched_tasks)
2026-06-27 11:29:48 +08:00
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))
2026-06-27 15:00:15 +08:00
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()
2026-06-27 11:29:48 +08:00
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
2026-06-27 11:40:51 +08:00
self.has_import_result = True
self.last_import_stats = result.get("stats") or {}
self.current_batch_id = result.get("batch_id")
2026-06-27 11:29:48 +08:00
self.refresh_tasks()
self._set_status(
"导入完成:有效{valid},无效{invalid},入库{inserted},未匹配{unmatched}".format(
2026-06-27 11:40:51 +08:00
valid=self.last_import_stats.get("valid", 0),
invalid=self.last_import_stats.get("invalid", 0),
inserted=self.last_import_stats.get("inserted", 0),
2026-06-27 11:29:48 +08:00
unmatched=self.model.unmatched_count(),
)
)
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
2026-06-27 11:40:51 +08:00
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)
2026-06-27 11:29:48 +08:00
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)
2026-06-27 11:40:51 +08:00
self._update_summary(task_rows, account_rows)
self._update_empty_label(len(task_rows))
2026-06-27 11:51:42 +08:00
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("正在停止采集...")
2026-06-27 14:37:58 +08:00
def write_back_old_data(self, checked=False):
batch_id = self._active_batch_id()
if not batch_id:
self._set_status("没有可回写批次")
return
2026-06-27 14:47:08 +08:00
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
2026-06-27 14:37:58 +08:00
worker = WriteBackWorker(batch_id, db_path=self.db_path)
2026-06-27 14:47:08 +08:00
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,
)
)
2026-06-27 14:37:58 +08:00
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)
2026-06-27 14:47:08 +08:00
self._set_status("正在自动回写旧数据到 Excel..." if auto else "正在回写旧数据到 Excel...")
2026-06-27 14:37:58 +08:00
thread.start()
2026-06-27 14:47:08 +08:00
return True
2026-06-27 14:37:58 +08:00
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
2026-06-27 11:51:42 +08:00
def _set_collect_running(self, running):
self.import_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.collect_button.setEnabled(not running)
2026-06-27 14:37:58 +08:00
self.write_back_button.setEnabled(not running)
2026-06-27 11:51:42 +08:00
self.stop_collect_button.setEnabled(running)
2026-06-27 14:37:58 +08:00
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)
2026-06-27 11:51:42 +08:00
def _forget_collect_thread(self, thread):
if self.collect_thread is thread:
self.collect_thread = None
self.collect_worker = None
2026-06-27 14:37:58 +08:00
def _forget_write_back_thread(self, thread):
if self.write_back_thread is thread:
self.write_back_thread = None
self.write_back_worker = None
2026-06-27 11:51:42 +08:00
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()
2026-06-27 15:00:15 +08:00
if payload.get("blocked"):
self._show_collect_blocked(payload)
return
2026-06-27 14:47:08 +08:00
message = "采集完成:成功{collected},略过{skipped},失败{failed}".format(
collected=payload.get("collected", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
2026-06-27 11:51:42 +08:00
)
2026-06-27 14:47:08 +08:00
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)
2026-06-27 11:51:42 +08:00
2026-06-27 15:00:15 +08:00
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
2026-06-27 11:51:42 +08:00
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),
)
)
2026-06-27 14:47:08 +08:00
def _on_write_back_failed(self, task_id, error, auto=False):
message = f"Excel {'自动' if auto else ''}回写失败:{error}"
2026-06-27 14:37:58 +08:00
if "被占用" in str(error):
2026-06-27 14:47:08 +08:00
if auto:
message += "\n请关闭原 Excel 后点击「回写旧数据到 Excel」手动重试;SQLite 已保留采集结果,也可另存副本。"
else:
message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。"
2026-06-27 14:37:58 +08:00
QMessageBox.warning(self, "回写旧数据", message)
self._set_status(message.replace("\n", " "))
2026-06-27 14:47:08 +08:00
def _on_write_back_finished(self, payload, auto=False):
2026-06-27 14:37:58 +08:00
self._set_write_back_running(False)
if payload.get("ok") is False:
error = payload.get("error") or "未知错误"
2026-06-27 14:47:08 +08:00
retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else ""
self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}")
2026-06-27 14:37:58 +08:00
return
self.refresh_tasks()
self._set_status(
2026-06-27 14:47:08 +08:00
"Excel {prefix}回写完成:文件{files},行{rows}".format(
prefix="自动" if auto else "",
2026-06-27 14:37:58 +08:00
files=payload.get("files", 0),
rows=payload.get("rows", 0),
)
)
2026-06-27 11:40:51 +08:00
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:
2026-06-27 11:29:48 +08:00
self.empty_label.setText("暂无任务")
2026-06-27 11:40:51 +08:00
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} 条任务别名未匹配账号,阶段显示为“略过”"
)
2026-06-27 11:29:48 +08:00
2026-06-27 10:30:45 +08:00
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
2026-06-27 16:26:08 +08:00
class GenerateWorker(BaseWorker):
"""Generate titles and covers for collected tasks."""
def __init__(self, tasks, prompt_values, db_path=None, config=None):
super().__init__()
self.tasks = list(tasks)
self.prompt_values = dict(prompt_values or {})
self.db_path = db_path
self.config = config
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()
}
return ai.generate_batch(
self.tasks,
self.prompt_values,
ai_cfg={
"config": self.config,
"db_path": self.db_path,
"image_dir": appconfig.image_dir(self.config),
"account_by_alias": account_by_alias,
"on_task_update": self._emit_row_update,
},
on_progress=self.progress.emit,
should_stop=self.should_cancel,
)
def _emit_row_update(self, task_id, fields):
self.row_updated.emit(int(task_id), dict(fields or {}))
2026-06-27 17:13:18 +08:00
class ApplyWorker(BaseWorker):
"""Apply generated title/cover changes to Shopee one task at a time."""
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 self._is_actionable_task(task)]
total = len(eligible)
applied = 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,
"applied": 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, applied, skipped, failed)
continue
try:
db.mark_running(task.id, "apply", path=self.db_path)
self.row_updated.emit(task.id, {"status": "running", "last_error": None})
result = editor.apply_task(account, task)
committed = bool(result.get("committed")) and not result.get("error")
error = result.get("error")
if committed:
db.set_applied(task.id, True, path=self.db_path)
applied += 1
self.row_updated.emit(
task.id,
{
"stage": "applied",
"status": "success",
"committed": 1,
"last_error": None,
},
)
else:
failed += 1
error = error or "更新未提交"
db.set_applied(task.id, False, error, path=self.db_path)
self.failed.emit(task.id, str(error))
self.row_updated.emit(
task.id,
{"status": "failed", "last_error": str(error), "committed": 0},
)
except Exception as exc:
failed += 1
error = str(exc) or exc.__class__.__name__
db.set_applied(task.id, False, error, path=self.db_path)
self.failed.emit(task.id, error)
self.row_updated.emit(
task.id,
{"status": "failed", "last_error": error, "committed": 0},
)
finally:
done += 1
self._emit_progress(done, total, applied, skipped, failed)
return {
"ok": failed == 0,
"total": total,
"done": done,
"applied": applied,
"skipped": skipped,
"failed": failed,
}
def _is_actionable_task(self, task):
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) in {"success", "pending", "failed"}
and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None))
)
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, applied, skipped, failed):
self.progress.emit(
{
"done": done,
"total": total,
"applied": applied,
"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 "账号未登录"
2026-06-27 11:51:42 +08:00
class CollectWorker(BaseWorker):
"""Collect old title and cover for imported tasks."""
2026-06-27 15:00:15 +08:00
def __init__(self, tasks, db_path=None, config=None, preflight=True):
2026-06-27 11:51:42 +08:00
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
2026-06-27 15:00:15 +08:00
self.preflight = preflight
2026-06-27 11:51:42 +08:00
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
2026-06-27 15:00:15 +08:00
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
2026-06-27 11:51:42 +08:00
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,
}
2026-06-27 15:00:15 +08:00
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
2026-06-27 11:51:42 +08:00
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",
)
)
2026-06-27 14:37:58 +08:00
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
2026-06-27 10:30:45 +08:00
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("检测登录")
2026-06-27 10:38:21 +08:00
self.shortcut_button = QPushButton("快捷方式")
2026-06-27 10:30:45 +08:00
toolbar = QHBoxLayout()
for button in (
self.add_button,
self.edit_button,
self.delete_button,
self.launch_button,
self.check_button,
2026-06-27 10:38:21 +08:00
self.shortcut_button,
2026-06-27 10:30:45 +08:00
):
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)
2026-06-27 10:38:21 +08:00
self.shortcut_button.clicked.connect(self.create_shortcut)
2026-06-27 10:30:45 +08:00
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,
2026-06-27 10:38:21 +08:00
self.shortcut_button,
2026-06-27 10:30:45 +08:00
):
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}")
2026-06-27 10:38:21 +08:00
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}")
2026-06-27 10:30:45 +08:00
2026-06-27 09:56:53 +08:00
class MainWindow(QMainWindow):
"""Main application window with the fixed five-tab workflow."""
2026-06-27 10:30:45 +08:00
def __init__(self, db_path=None, config=None):
2026-06-27 09:56:53 +08:00
super().__init__()
2026-06-27 10:30:45 +08:00
self.config = appconfig.load_config() if config is None else config
2026-06-27 11:29:48 +08:00
self.db_path = _database_path(db_path, self.config)
2026-06-27 09:56:53 +08:00
self.setWindowTitle("cmshopee")
self.resize(1180, 760)
self.tabs = QTabWidget()
self.tabs.setObjectName("mainTabs")
2026-06-27 10:30:45 +08:00
self.tabs.setStyleSheet(TAB_STYLE)
2026-06-27 09:56:53 +08:00
self.tabs.currentChanged.connect(self._on_tab_changed)
for title in TAB_TITLES:
2026-06-27 10:30:45 +08:00
self.tabs.addTab(self._build_tab(title), title)
2026-06-27 09:56:53 +08:00
self.setCentralWidget(self.tabs)
self.statusBar().showMessage("就绪")
2026-06-27 10:30:45 +08:00
def _build_tab(self, title):
2026-06-27 11:29:48 +08:00
if title == "① 导入采集":
return CollectTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
2026-06-27 15:00:15 +08:00
open_accounts_callback=lambda: self.open_accounts_tab(),
2026-06-27 11:29:48 +08:00
)
2026-06-27 15:56:55 +08:00
if title == "② AI生成":
return GenerateTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
)
2026-06-27 16:59:46 +08:00
if title == "③ 更新shopee":
return ApplyTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
2026-06-27 17:13:18 +08:00
open_accounts_callback=lambda: self.open_accounts_tab(),
2026-06-27 16:59:46 +08:00
)
2026-06-27 10:30:45 +08:00
if title == "④ 账号管理":
return AccountsTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
)
2026-06-27 09:56:53 +08:00
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)}")
2026-06-27 15:00:15 +08:00
def open_accounts_tab(self):
self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理"))
2026-06-27 09:56:53 +08:00
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"
2026-06-26 17:24:23 +08:00
def main() -> int:
2026-06-27 09:56:53 +08:00
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()