Files
cmshoppe/app/gui.py
T

4684 lines
196 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
2026-06-29 10:25:09 +08:00
import threading
import time
2026-06-29 10:25:09 +08:00
from concurrent.futures import ThreadPoolExecutor, as_completed
2026-06-27 09:56:53 +08:00
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-29 08:45:04 +08:00
QCheckBox,
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,
2026-06-29 11:45:10 +08:00
QGridLayout,
2026-06-27 10:30:45 +08:00
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,
QScrollArea,
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-07-01 08:35:46 +08:00
from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, image_paths, prompts
2026-06-27 10:30:45 +08:00
from . import config as account_config
PLAINTEXT_SECRET_TITLE = "本地明文保存提示"
PLAINTEXT_API_KEY_WARNING = (
"API Key 会以本地明文保存到 config/ai_models.json,仅供本机调用 AI 使用。"
"该文件已 gitignore,UI 打码显示,日志/导出不记录明文。"
)
PLAINTEXT_PASSWORD_WARNING = (
"密码会以本地明文保存到本地 SQLite,仅供人工参考,不会自动登录/自动填。"
"数据库文件已 gitignore,请勿提交或分享。"
)
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)
2026-06-30 11:50:49 +08:00
def _write_reset_run_log(db_path, task, action, message):
run_id = db.create_run_log(
"reset",
total=1,
options={
"action": action,
"task_id": getattr(task, "id", None),
"alias": getattr(task, "alias", None),
"item_id": getattr(task, "item_id", None),
},
path=db_path,
)
db.add_run_log_event(
run_id,
message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
path=db_path,
)
db.finish_run_log(
run_id,
status="done",
done=1,
success_count=1,
failed_count=0,
summary_json={
"action": action,
"task_id": getattr(task, "id", None),
"alias": getattr(task, "alias", None),
"item_id": getattr(task, "item_id", None),
},
path=db_path,
)
return run_id
2026-06-27 11:29:48 +08:00
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("停止")
2026-06-30 11:50:49 +08:00
self.reset_generate_button = QPushButton("重置生成结果")
self.reset_generate_button.setObjectName("resetGenerateButton")
2026-06-27 16:26:08 +08:00
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.item_filter = QLineEdit()
self.item_filter.setObjectName("generateItemFilter")
self.item_filter.setPlaceholderText("商品ID")
2026-06-27 15:56:55 +08:00
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("商品ID"))
filter_layout.addWidget(self.item_filter, 1)
2026-06-27 15:56:55 +08:00
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)
self.run_log_view = QPlainTextEdit()
self.run_log_view.setObjectName("generateRunLogView")
self.run_log_view.setReadOnly(True)
self.run_log_view.setMaximumHeight(128)
self.run_log_view.setPlaceholderText("AI生成运行日志")
2026-06-27 15:56:55 +08:00
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)
right_layout.addWidget(QLabel("AI生成运行日志"))
right_layout.addWidget(self.run_log_view)
2026-06-27 15:56:55 +08:00
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-30 11:50:49 +08:00
bottom_layout.addWidget(self.reset_generate_button)
2026-06-27 16:26:08 +08:00
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.item_filter.textChanged.connect(self.refresh_tasks)
2026-06-27 15:56:55 +08:00
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)
2026-06-30 11:50:49 +08:00
self.reset_generate_button.clicked.connect(self.reset_generated_result)
2026-06-27 16:26:08 +08:00
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()
self._load_latest_generate_run_log()
2026-06-27 15:56:55 +08:00
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def _on_generate_log(self, message):
self._append_generate_log(message)
self._set_status(message)
def _append_generate_log(self, message):
self.run_log_view.appendPlainText(str(message))
def _load_latest_generate_run_log(self):
try:
logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path)
if not logs:
return
events = db.list_run_log_events(logs[0].id, limit=40, path=self.db_path)
except Exception:
return
lines = [
f"{event.created_at} [{event.level}] {event.message}"
for event in events
]
self.run_log_view.setPlainText("\n".join(lines))
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,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
2026-06-27 16:26:08 +08:00
)
worker.progress.connect(self._on_generate_progress)
worker.row_updated.connect(self._on_generate_row_updated)
worker.log.connect(self._on_generate_log)
2026-06-27 16:26:08 +08:00
worker.failed.connect(self._on_generate_failed)
worker.finished.connect(self._on_generate_finished)
worker.cancelled.connect(self._on_generate_cancelled)
self.run_log_view.clear()
2026-06-27 16:26:08 +08:00
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 生成...")
2026-06-30 11:50:49 +08:00
def reset_generated_result(self, checked=False):
if self.generate_thread is not None:
self._set_status("AI 生成正在进行,不能重置")
return
task = self._selected_task()
if task is None:
self._set_status("请选择要重置生成结果的任务")
return
has_ai_result = bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None))
if not has_ai_result and getattr(task, "stage", None) not in {"generated", "applied"}:
self._set_status("选中任务没有可重置的生成结果")
return
lines = [
"确定重置当前选中任务的本地生成结果吗?",
"",
f"商品ID:{task.item_id}",
f"店铺:{self.model.account_name_for(task)}",
"",
"将清空新标题、新封面路径和错误信息,并退回到已采集状态。",
"默认不删除本地新封面文件,不触碰 Shopee,也不会自动回写 Excel。",
]
if getattr(task, "new_cover_path", None):
lines.append(f"本地新封面文件保留:{task.new_cover_path}")
if getattr(task, "committed", 0):
lines.extend([
"",
"注意:该记录曾经提交过线上。本地重置不会回滚 Shopee,后续重新生成/更新可能再次提交线上。",
])
answer = QMessageBox.question(
self,
"重置生成结果",
"\n".join(lines),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消重置生成结果")
return
try:
db.reset_generated(task.id, path=self.db_path)
message = (
"action=reset_generated step=db_write result=success "
f"detail=清空AI生成结果 task_id={task.id}"
)
run_id = _write_reset_run_log(self.db_path, task, "reset_generated", message)
except Exception as exc:
QMessageBox.warning(self, "重置生成结果", str(exc))
self._set_status(f"重置生成结果失败:{exc}")
return
self.refresh_tasks()
self._append_generate_log(message)
self._set_status(
f"已重置生成结果:商品 {task.item_id},run_id={run_id}"
)
2026-06-27 16:26:08 +08:00
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)
2026-06-30 11:50:49 +08:00
self.reset_generate_button.setEnabled(not running)
2026-06-27 16:26:08 +08:00
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
2026-06-27 16:26:08 +08:00
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._load_latest_generate_run_log()
2026-06-27 16:26:08 +08:00
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._load_latest_generate_run_log()
2026-06-27 16:26:08 +08:00
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"
item_query = self.item_filter.text().strip()
2026-06-27 15:56:55 +08:00
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_item(task, item_query)
2026-06-27 15:56:55 +08:00
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_item(self, task, item_query):
if not item_query:
return True
return item_query in str(getattr(task, "item_id", ""))
2026-06-27 15:56:55 +08:00
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 17:28:33 +08:00
self.result_write_back_worker = None
self.result_write_back_thread = None
self.last_apply_summary = 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.item_filter = QLineEdit()
self.item_filter.setObjectName("applyItemFilter")
self.item_filter.setPlaceholderText("商品ID")
2026-06-27 16:59:46 +08:00
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("商品ID"))
filter_layout.addWidget(self.item_filter, 1)
2026-06-27 16:59:46 +08:00
filter_layout.addWidget(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
2026-07-01 08:35:46 +08:00
self.risk_label = QLabel("可先点击「预览本轮更新」检查当前筛选范围;点击「开始更新」后会再次确认并按批提交线上。")
2026-06-27 16:59:46 +08:00
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)
2026-06-29 10:25:09 +08:00
self.run_log_view = QPlainTextEdit()
self.run_log_view.setObjectName("applyRunLogView")
self.run_log_view.setReadOnly(True)
self.run_log_view.setMaximumHeight(128)
self.run_log_view.setPlaceholderText("运行日志")
2026-06-27 16:59:46 +08:00
2026-07-01 08:35:46 +08:00
self.preview_update_button = QPushButton("预览本轮更新")
self.preview_update_button.setObjectName("previewUpdateButton")
2026-06-27 16:59:46 +08:00
self.start_update_button = QPushButton("开始更新")
self.stop_update_button = QPushButton("停止")
2026-06-30 11:50:49 +08:00
self.reset_update_button = QPushButton("重置更新状态")
self.reset_update_button.setObjectName("resetUpdateButton")
2026-06-27 16:59:46 +08:00
self.write_back_button = QPushButton("回写结果到 Excel")
self.stop_update_button.setEnabled(False)
self.write_back_button.setEnabled(False)
action_layout = QHBoxLayout()
2026-07-01 08:35:46 +08:00
action_layout.addWidget(self.preview_update_button)
2026-06-27 16:59:46 +08:00
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
2026-06-30 11:50:49 +08:00
action_layout.addWidget(self.reset_update_button)
2026-06-27 16:59:46 +08:00
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)
2026-06-29 10:25:09 +08:00
layout.addWidget(QLabel("运行日志"))
layout.addWidget(self.run_log_view)
2026-06-27 16:59:46 +08:00
layout.addLayout(action_layout)
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
self.item_filter.textChanged.connect(self.refresh_tasks)
2026-06-27 16:59:46 +08:00
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
2026-07-01 08:35:46 +08:00
self.preview_update_button.clicked.connect(self.preview_update)
2026-06-27 16:59:46 +08:00
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-30 11:50:49 +08:00
self.reset_update_button.clicked.connect(self.reset_apply_status)
2026-06-27 17:28:33 +08:00
self.write_back_button.clicked.connect(self.write_back_results)
2026-06-27 16:59:46 +08:00
self.refresh_tasks()
2026-06-29 10:25:09 +08:00
self._load_latest_run_log()
2026-06-27 16:59:46 +08:00
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"
item_query = self.item_filter.text().strip()
2026-06-27 16:59:46 +08:00
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_item(task, item_query)
2026-06-27 16:59:46 +08:00
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)} 条"
)
2026-06-27 17:28:33 +08:00
self._update_write_back_button()
2026-06-27 16:59:46 +08:00
def start_update(self, checked=False):
2026-07-01 08:35:46 +08:00
self._start_update(dry_run=False)
def preview_update(self, checked=False):
self._start_update(dry_run=True)
def _start_update(self, dry_run=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
2026-06-29 10:25:09 +08:00
update_cfg = self._shopee_update_config()
2026-07-01 08:35:46 +08:00
dry_run = bool(dry_run)
2026-06-29 10:25:09 +08:00
safety_error = self._update_safety_error(tasks, dry_run=dry_run)
2026-06-29 09:18:03 +08:00
if safety_error:
QMessageBox.warning(self, "更新安全开关", safety_error)
self._set_status(safety_error.replace("\n", " "))
return
2026-06-27 16:59:46 +08:00
answer = QMessageBox.question(
self,
2026-07-01 08:35:46 +08:00
"确认预览本轮更新" if dry_run else "确认开始更新",
self._confirmation_message(tasks, dry_run=dry_run),
2026-06-27 16:59:46 +08:00
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
2026-07-01 08:35:46 +08:00
self._set_status("已取消预览本轮更新" if dry_run else "已取消开始更新")
2026-06-27 16:59:46 +08:00
return
2026-07-01 08:35:46 +08:00
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
2026-06-29 09:18:03 +08:00
worker = ApplyWorker(
tasks,
db_path=self.db_path,
config=self.config,
close_success_tab=bool(update_cfg.get("close_success_tab", False)),
2026-06-29 10:25:09 +08:00
dry_run=dry_run,
parallel_accounts=bool(update_cfg.get("parallel_accounts", False)),
max_parallel_accounts=max(
1,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
),
2026-07-01 08:35:46 +08:00
batch_size=batch_size,
2026-06-29 09:18:03 +08:00
)
2026-06-27 17:13:18 +08:00
worker.progress.connect(self._on_apply_progress)
worker.row_updated.connect(self._on_apply_row_updated)
2026-06-29 10:25:09 +08:00
worker.log.connect(self._on_apply_log)
2026-06-27 17:13:18 +08:00
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)
2026-06-29 10:25:09 +08:00
self.run_log_view.clear()
if dry_run:
2026-07-01 08:35:46 +08:00
self._set_status(f"开始预览本轮更新:{len(tasks)} 条")
2026-06-29 10:25:09 +08:00
else:
2026-07-01 08:35:46 +08:00
self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行")
2026-06-27 17:13:18 +08:00
thread.start()
def stop_update(self, checked=False):
if self.apply_worker is not None:
self.apply_worker.cancel()
self._set_status("正在停止更新...")
2026-06-27 17:28:33 +08:00
def write_back_results(self, checked=False):
batch_ids = self._active_batch_ids()
if not batch_ids:
self._set_status("没有可回写结果的批次")
return
self._start_result_write_back(batch_ids, auto=False)
2026-06-30 11:50:49 +08:00
def reset_apply_status(self, checked=False):
if self.apply_thread is not None or self.result_write_back_thread is not None:
self._set_status("更新或回写正在进行,不能重置")
return
task = self._selected_task()
if task is None:
self._set_status("请选择要重置更新状态的任务")
return
if not (getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)):
self._set_status("选中任务没有新标题或新封面,不能重置为可更新")
return
lines = [
"确定重置当前选中任务的本地更新状态吗?",
"",
f"商品ID:{task.item_id}",
f"店铺:{self.model.account_name_for(task)}",
"",
"将保留新标题和新封面路径,只把本地状态退回可更新。",
"不会触碰 Shopee,也不会自动回写 Excel。",
]
if getattr(task, "committed", 0):
lines.extend([
"",
"注意:该记录已经提交过线上。本地重置不会回滚 Shopee,重复更新会再次提交线上。",
])
answer = QMessageBox.question(
self,
"重置更新状态",
"\n".join(lines),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消重置更新状态")
return
try:
db.reset_apply_status(task.id, path=self.db_path)
message = (
"action=reset_apply_status step=db_write result=success "
f"detail=退回可更新 task_id={task.id}"
)
run_id = _write_reset_run_log(self.db_path, task, "reset_apply_status", message)
except Exception as exc:
QMessageBox.warning(self, "重置更新状态", str(exc))
self._set_status(f"重置更新状态失败:{exc}")
return
self.refresh_tasks()
self._append_run_log(message)
self._set_status(
f"已重置更新状态:商品 {task.item_id},run_id={run_id}"
)
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
2026-06-27 17:13:18 +08:00
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 _item_filter_label(self):
return self.item_filter.text().strip() or "全部商品"
2026-06-27 16:59:46 +08:00
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_item(self, task, item_query):
if not item_query:
return True
return item_query in str(getattr(task, "item_id", ""))
2026-06-27 16:59:46 +08:00
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
2026-07-01 08:35:46 +08:00
def _confirmation_message(self, tasks, dry_run=False):
2026-06-29 09:18:03 +08:00
update_cfg = self._shopee_update_config()
cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许"
close_text = "是" if update_cfg.get("close_success_tab") else "否"
2026-07-01 08:35:46 +08:00
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0
2026-06-29 10:25:09 +08:00
parallel_text = (
f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号"
if update_cfg.get("parallel_accounts")
else "关闭"
)
2026-07-01 08:35:46 +08:00
intro = (
"即将预览当前筛选结果。\n\n"
if dry_run
else "即将按当前筛选结果分批更新 Shopee 线上商品。\n\n"
)
2026-06-27 16:59:46 +08:00
return (
2026-07-01 08:35:46 +08:00
intro
+ f"批次:{self._batch_filter_label()}\n"
+ f"店铺:{self._shop_filter_label()}\n"
+ f"商品ID:{self._item_filter_label()}\n"
+ f"状态:{self._status_label()}\n"
+ f"任务数:{len(tasks)}\n"
+ f"预计批次:{batch_count}\n\n"
+ "安全设置:"
+ f"封面更新={cover_text},"
+ f"每批最大更新条数={batch_size},"
+ f"成功后关闭新页={close_text},"
+ f"多账号并行={parallel_text}\n\n"
2026-06-29 10:25:09 +08:00
+ (
2026-07-01 08:35:46 +08:00
"预览只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态。"
if dry_run
else f"确认后会打开商品编辑页、替换标题/允许时替换封面,并按每批最多 {batch_size} 条点击「更新」提交线上;点击停止后不再开始下一条或下一批。"
2026-06-29 10:25:09 +08:00
)
2026-06-27 16:59:46 +08:00
)
2026-06-29 10:25:09 +08:00
def _update_safety_error(self, tasks, dry_run=False):
2026-06-29 09:18:03 +08:00
update_cfg = self._shopee_update_config()
2026-06-29 10:25:09 +08:00
if dry_run:
return None
2026-06-29 09:18:03 +08:00
if not update_cfg.get("allow_real_submit", False):
return "设置未开启「允许真实提交线上商品」,已阻止本次更新。"
if not update_cfg.get("allow_cover_update", False):
cover_tasks = [
str(getattr(task, "item_id", ""))
for task in tasks
if getattr(task, "new_cover_path", None)
]
if cover_tasks:
return "设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。"
return None
def _shopee_update_config(self):
defaults = appconfig.default_config().get("shopee_update", {})
loaded = self.config.get("shopee_update", {})
if not isinstance(loaded, dict):
loaded = {}
merged = dict(defaults)
merged.update(loaded)
return merged
2026-06-27 17:13:18 +08:00
def _set_apply_running(self, running):
2026-07-01 08:35:46 +08:00
self.preview_update_button.setEnabled(not running)
2026-06-27 17:13:18 +08:00
self.start_update_button.setEnabled(not running)
self.stop_update_button.setEnabled(running)
2026-06-30 11:50:49 +08:00
self.reset_update_button.setEnabled(not running)
2026-06-27 17:13:18 +08:00
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
2026-06-27 17:13:18 +08:00
self.status_filter.setEnabled(not running)
2026-06-27 17:28:33 +08:00
self._update_write_back_button()
def _set_result_write_back_running(self, running):
2026-07-01 08:35:46 +08:00
self.preview_update_button.setEnabled(not running)
2026-06-27 17:28:33 +08:00
self.start_update_button.setEnabled(not running)
2026-06-30 11:50:49 +08:00
self.reset_update_button.setEnabled(not running)
2026-06-27 17:28:33 +08:00
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
2026-06-27 17:28:33 +08:00
self.status_filter.setEnabled(not running)
self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids()))
2026-06-27 17:13:18 +08:00
def _forget_apply_thread(self, thread):
if self.apply_thread is thread:
self.apply_thread = None
self.apply_worker = None
2026-06-27 17:28:33 +08:00
def _forget_result_write_back_thread(self, thread):
if self.result_write_back_thread is thread:
self.result_write_back_thread = None
self.result_write_back_worker = None
self._update_write_back_button()
2026-06-27 17:13:18 +08:00
def _on_apply_progress(self, payload):
self._set_status("更新进度:" + self._apply_progress_text(payload))
2026-06-29 10:25:09 +08:00
def _on_apply_log(self, message):
self._append_run_log(message)
self._set_status(message)
2026-06-27 17:13:18 +08:00
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
2026-06-27 17:28:33 +08:00
self.last_apply_summary = dict(payload)
2026-07-01 08:35:46 +08:00
prefix = "预览本轮更新完成:" if payload.get("dry_run") else "更新完成:"
2026-06-29 10:25:09 +08:00
message = prefix + self._apply_progress_text(payload)
2026-06-27 17:28:33 +08:00
batch_ids = payload.get("batch_ids") or self._active_batch_ids()
2026-06-29 10:25:09 +08:00
if (not payload.get("dry_run")) and payload.get("done", 0) > 0 and batch_ids:
2026-06-27 17:28:33 +08:00
if self._start_result_write_back(
batch_ids,
auto=True,
apply_summary=payload,
):
self._set_status(f"{message},正在自动回写结果到 Excel...")
return
self._set_status(message)
self._show_apply_summary(payload)
2026-06-27 17:13:18 +08:00
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):
2026-06-29 10:25:09 +08:00
success_label = "可更新" if payload.get("dry_run") else "成功"
return "完成{done}/{total},{success_label}{applied},略过{skipped},失败{failed}".format(
2026-06-27 17:13:18 +08:00
done=payload.get("done", 0),
total=payload.get("total", 0),
2026-06-29 10:25:09 +08:00
success_label=success_label,
2026-06-27 17:13:18 +08:00
applied=payload.get("applied", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
2026-06-29 10:25:09 +08:00
def _append_run_log(self, message):
self.run_log_view.appendPlainText(str(message))
def _load_latest_run_log(self):
try:
logs = db.list_run_logs(limit=1, run_type="apply", path=self.db_path)
if not logs:
return
events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path)
except Exception:
return
lines = [
f"{event.created_at} [{event.level}] {event.message}"
for event in events
]
self.run_log_view.setPlainText("\n".join(lines))
2026-06-27 17:13:18 +08:00
def _show_apply_blocked(self, payload):
lines = ["更新前检查未通过。"]
if payload.get("no_accounts"):
lines.append("当前没有配置账号。")
2026-06-29 10:25:09 +08:00
duplicate_ports = payload.get("duplicate_ports") or []
if duplicate_ports:
for item in duplicate_ports:
lines.append(
"以下账号调试端口冲突:端口 {port} -> {aliases}".format(
port=item.get("debug_port"),
aliases="、".join(item.get("aliases") or []),
)
)
2026-06-27 17:13:18 +08:00
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"
2026-07-01 08:35:46 +08:00
"本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n"
2026-06-27 17:13:18 +08:00
"请先到「④ 账号管理」配置账号、启动对应账号 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 17:28:33 +08:00
def _active_batch_ids(self):
selected_batch = self.batch_filter.currentData()
if selected_batch:
return [selected_batch]
batch_ids = []
for task in self.model.tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
def _update_write_back_button(self):
if getattr(self, "write_back_button", None) is None:
return
enabled = (
self.apply_thread is None
and self.result_write_back_thread is None
and bool(self._active_batch_ids())
)
self.write_back_button.setEnabled(enabled)
def _start_result_write_back(self, batch_ids, auto=False, apply_summary=None):
if self.result_write_back_thread is not None:
self._set_status("Excel 结果回写正在进行...")
return False
worker = WriteBackWorker(batch_ids, db_path=self.db_path, mode="results")
worker.failed.connect(
lambda task_id, error, auto=auto, apply_summary=apply_summary:
self._on_result_write_back_failed(
task_id,
error,
auto=auto,
apply_summary=apply_summary,
)
)
worker.finished.connect(
lambda payload, auto=auto, apply_summary=apply_summary:
self._on_result_write_back_finished(
payload,
auto=auto,
apply_summary=apply_summary,
)
)
thread = run_worker(worker, thread_name="ResultWriteBackWorker", start=False)
thread.finished.connect(lambda: self._forget_result_write_back_thread(thread))
self.result_write_back_worker = worker
self.result_write_back_thread = thread
self._set_result_write_back_running(True)
self._set_status("正在自动回写更新结果到 Excel..." if auto else "正在回写更新结果到 Excel...")
thread.start()
return True
def _on_result_write_back_failed(self, task_id, error, auto=False, apply_summary=None):
message = f"Excel {'自动' if auto else ''}回写更新结果失败:{error}"
if "被占用" in str(error):
message += "\n请关闭原 Excel 后点击「回写结果到 Excel」手动重试;SQLite 已保留更新结果。"
if auto and apply_summary:
message = self._apply_summary_message(apply_summary, error=message)
QMessageBox.warning(self, "回写结果到 Excel", message)
self._set_status(message.replace("\n", " "))
def _on_result_write_back_finished(self, payload, auto=False, apply_summary=None):
self._set_result_write_back_running(False)
self.refresh_tasks()
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._set_status(
"Excel {prefix}回写更新结果完成:文件{files},行{rows}".format(
prefix="自动" if auto else "",
files=payload.get("files", 0),
rows=payload.get("rows", 0),
)
)
if auto and apply_summary:
self._show_apply_summary(apply_summary, write_back_payload=payload)
elif not auto:
QMessageBox.information(
self,
"回写结果到 Excel",
"结果回写完成:文件{files},行{rows}".format(
files=payload.get("files", 0),
rows=payload.get("rows", 0),
),
)
def _show_apply_summary(self, apply_summary, write_back_payload=None):
QMessageBox.information(
self,
2026-07-01 08:35:46 +08:00
"预览本轮更新完成" if apply_summary.get("dry_run") else "更新完成",
2026-06-27 17:28:33 +08:00
self._apply_summary_message(apply_summary, write_back_payload),
)
def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None):
2026-06-29 10:25:09 +08:00
dry_run = bool(apply_summary.get("dry_run"))
2026-06-27 17:28:33 +08:00
lines = [
2026-07-01 08:35:46 +08:00
"预览本轮更新完成,未打开 Shopee、未提交线上、未改任务状态。"
2026-06-29 10:25:09 +08:00
if dry_run
else "更新完成。",
"{success_label}:{applied},失败:{failed},略过:{skipped}".format(
success_label="可更新" if dry_run else "成功",
2026-06-27 17:28:33 +08:00
applied=apply_summary.get("applied", 0),
failed=apply_summary.get("failed", 0),
skipped=apply_summary.get("skipped", 0),
),
]
if write_back_payload:
lines.append(
"Excel 回写:文件{files},行{rows}".format(
files=write_back_payload.get("files", 0),
rows=write_back_payload.get("rows", 0),
)
)
if error:
lines.append(str(error))
return "\n".join(lines)
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
self.last_collect_run_id = 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.run_log_view = QPlainTextEdit()
self.run_log_view.setObjectName("collectRunLogView")
self.run_log_view.setReadOnly(True)
self.run_log_view.setMaximumHeight(128)
self.run_log_view.setPlaceholderText("采集运行日志")
2026-06-27 11:29:48 +08:00
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(QLabel("采集运行日志"))
layout.addWidget(self.run_log_view)
2026-06-27 11:29:48 +08:00
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()
self._load_latest_collect_run_log()
2026-06-27 11:29:48 +08:00
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def _on_collect_log(self, message):
self._append_collect_log(message)
self._set_status(message)
def _append_collect_log(self, message):
self.run_log_view.appendPlainText(str(message))
def _load_latest_collect_run_log(self):
try:
logs = db.list_run_logs(limit=1, run_type="collect", path=self.db_path)
if not logs:
return
events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path)
except Exception:
return
lines = [
f"{event.created_at} [{event.level}] {event.message}"
for event in events
]
self.run_log_view.setPlainText("\n".join(lines))
def _log_collect_run_event(self, run_id, message, level="info"):
safe_message = diagnostics.redact_log_text(message)
try:
db.add_run_log_event(run_id, safe_message, level=level, path=self.db_path)
except Exception:
return
self._append_collect_log(safe_message)
2026-06-27 11:29:48 +08:00
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"
2026-07-01 08:35:46 +08:00
"本轮采集已中止,不会自动打开账号 Chrome。\n"
2026-06-27 15:00:15 +08:00
"请先到「④ 账号管理」配置账号、启动对应账号 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,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
2026-06-27 11:51:42 +08:00
worker.progress.connect(self._on_collect_progress)
worker.row_updated.connect(self._on_collect_row_updated)
worker.log.connect(self._on_collect_log)
2026-06-27 11:51:42 +08:00
worker.failed.connect(self._on_collect_failed)
worker.finished.connect(self._on_collect_finished)
worker.cancelled.connect(self._on_collect_cancelled)
self.run_log_view.clear()
2026-06-27 11:51:42 +08:00
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.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id
2026-06-27 11:51:42 +08:00
self.refresh_tasks()
self._load_latest_collect_run_log()
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):
if self.last_collect_run_id:
self._log_collect_run_event(
self.last_collect_run_id,
"step=excel_write_back result=start detail=采集成功后自动回写旧数据到 Excel",
)
2026-06-27 14:47:08 +08:00
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", " "))
if auto and self.last_collect_run_id:
self._log_collect_run_event(
self.last_collect_run_id,
f"step=excel_write_back result=failed detail={error}",
level="error",
)
2026-06-27 14:37:58 +08:00
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}")
if auto and self.last_collect_run_id:
self._log_collect_run_event(
self.last_collect_run_id,
f"step=excel_write_back result=failed detail={error}",
level="error",
)
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),
)
)
if auto and self.last_collect_run_id:
self._log_collect_run_event(
self.last_collect_run_id,
"step=excel_write_back result=success detail=旧数据已回写 Excel",
)
2026-06-27 14:37:58 +08:00
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,
diagnostic_log_dir=None,
):
2026-06-27 16:26:08 +08:00
super().__init__()
self.tasks = list(tasks)
self.prompt_values = dict(prompt_values or {})
self.db_path = db_path
self.config = config
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
2026-06-27 16:26:08 +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) == "collected"
]
batch_ids = self._batch_ids(eligible)
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
f"phase=preflight step=start result=start detail=AI生成开始 total={len(eligible)}"
2026-06-27 16:26:08 +08:00
)
try:
summary = 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_event": self._on_generation_event,
"on_error": self._on_generation_error,
},
on_progress=self.progress.emit,
should_stop=self.should_cancel,
)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
summary = {
"ok": False,
"error": error,
"total": len(eligible),
"title_done": 0,
"cover_done": 0,
"failed": len(eligible),
"cancelled": self.should_cancel(),
}
self._log_run_event(
f"phase=worker step=execute result=failed detail={error}",
level="error",
)
self._write_diagnostic_log(
"AI生成运行失败",
level="ERROR",
step="execute",
payload={"error": error},
exc=exc,
)
summary["run_id"] = self._run_id
summary["batch_ids"] = batch_ids
status = "cancelled" if summary.get("cancelled") else "done"
self._finish_run_log(status, summary)
return summary
2026-06-27 16:26:08 +08:00
def _emit_row_update(self, task_id, fields):
self.row_updated.emit(int(task_id), dict(fields or {}))
def _on_generation_event(self, payload):
task = payload.get("task")
phase = payload.get("phase") or "generate"
step = payload.get("step") or "unknown"
result = payload.get("result") or "start"
detail = payload.get("detail")
message = f"phase={phase} step={step} result={result}"
if detail:
message += f" detail={detail}"
self._log_run_event(message, task=task, level=payload.get("level") or "info")
def _on_generation_error(self, payload):
task = payload.get("task")
phase = payload.get("phase") or "generate"
step = payload.get("step") or "unknown"
error = diagnostics.redact_log_text(payload.get("error") or "未知错误")
self._write_diagnostic_log(
"AI生成任务失败",
level="ERROR",
step=step,
task=task,
payload={"phase": phase, "error": error},
exc=payload.get("exception"),
)
def _batch_ids(self, tasks):
batch_ids = []
for task in tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
def _create_run_log(self, eligible, batch_ids):
try:
ai_cfg = appconfig.ai_config(self.config)
return db.create_run_log(
"generate",
dry_run=False,
total=len(eligible),
options={
"batch_ids": batch_ids,
"default_text_model": ai_cfg.get("default_text_model"),
"default_image_model": ai_cfg.get("default_image_model"),
"resolution": ai_cfg.get("resolution"),
"title_concurrency": ai_cfg.get("title_concurrency"),
"image_concurrency": ai_cfg.get("image_concurrency"),
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
done = int(summary.get("cover_done", 0) or 0) + int(summary.get("failed", 0) or 0)
db.finish_run_log(
self._run_id,
status=status,
done=done,
success_count=summary.get("cover_done", 0),
skipped_count=0,
failed_count=summary.get("failed", 0),
summary_json=summary,
path=self.db_path,
)
except Exception:
return
def _log_run_event(self, message, task=None, level="info"):
safe_message = diagnostics.redact_log_text(message)
self.log.emit(str(safe_message))
if self._run_id is None:
return
try:
db.add_run_log_event(
self._run_id,
safe_message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
level=level,
path=self.db_path,
)
except Exception:
return
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
task=None,
payload=None,
exc=None,
):
try:
diagnostics.write_diagnostic_log(
message,
level=level,
step=step,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
except Exception:
return
2026-06-27 16:26:08 +08:00
2026-06-27 17:13:18 +08:00
class ApplyWorker(BaseWorker):
2026-06-29 10:25:09 +08:00
"""Apply generated title/cover changes, optionally previewing or grouping by account."""
2026-06-27 17:13:18 +08:00
2026-06-29 09:18:03 +08:00
def __init__(
self,
tasks,
db_path=None,
config=None,
preflight=True,
close_success_tab=False,
2026-06-29 10:25:09 +08:00
dry_run=False,
parallel_accounts=False,
max_parallel_accounts=1,
2026-07-01 08:35:46 +08:00
batch_size=None,
2026-06-29 09:18:03 +08:00
):
2026-06-27 17:13:18 +08:00
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
2026-06-29 09:18:03 +08:00
self.close_success_tab = close_success_tab
2026-06-29 10:25:09 +08:00
self.dry_run = bool(dry_run)
self.parallel_accounts = bool(parallel_accounts)
self.max_parallel_accounts = max(1, int(max_parallel_accounts or 1))
2026-07-01 08:35:46 +08:00
self.batch_size = None if batch_size is None else max(1, int(batch_size or 1))
self._current_batch_size = None
self._batch_count = 0
2026-06-29 10:25:09 +08:00
self._progress_lock = threading.Lock()
self._run_id = None
2026-06-27 17:13:18 +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 self._is_actionable_task(task)]
2026-06-27 17:28:33 +08:00
batch_ids = self._batch_ids(eligible)
2026-06-27 17:13:18 +08:00
total = len(eligible)
2026-07-01 08:35:46 +08:00
batch_size = self._effective_batch_size(total)
batches = self._task_batches(eligible, batch_size)
self._current_batch_size = batch_size
self._batch_count = len(batches)
2026-06-29 10:25:09 +08:00
counters = {
2026-07-01 08:35:46 +08:00
"done": 0,
"applied": 0,
"skipped": 0,
"failed": 0,
2026-06-27 17:13:18 +08:00
}
2026-06-29 10:25:09 +08:00
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
2026-07-01 08:35:46 +08:00
"运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format(
mode="预览本轮更新" if self.dry_run else "真实更新",
2026-06-29 10:25:09 +08:00
total=total,
2026-07-01 08:35:46 +08:00
batch_size=batch_size,
batch_count=len(batches),
2026-06-29 10:25:09 +08:00
parallel=(
f"多账号并行最多{self.max_parallel_accounts}"
if self.parallel_accounts
else "串行"
),
)
)
if self.preflight and not self.dry_run:
blocked = self._preflight_block(eligible, account_rows, account_by_alias)
if blocked:
summary = self._summary(
ok=False,
total=total,
counters=counters,
batch_ids=batch_ids,
blocked=True,
extra=blocked,
)
self._finish_run_log("blocked", summary)
return summary
2026-07-01 08:35:46 +08:00
for batch_index, batch_tasks in enumerate(batches, start=1):
if self.should_cancel():
break
self._log_batch_start(batch_index, len(batches), batch_tasks, counters, total)
if self.dry_run:
for task in batch_tasks:
if self.should_cancel():
break
outcome = self._preview_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
elif self.parallel_accounts and self.max_parallel_accounts > 1:
self._run_parallel_by_account(batch_tasks, account_by_alias, counters, total)
else:
for task in batch_tasks:
if self.should_cancel():
break
outcome = self._apply_one_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
2026-06-29 10:25:09 +08:00
summary = self._summary(
ok=counters["failed"] == 0,
total=total,
counters=counters,
batch_ids=batch_ids,
)
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
return summary
2026-06-27 17:13:18 +08:00
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,
}
2026-06-29 10:25:09 +08:00
duplicate_ports = self._duplicate_debug_ports(account_rows, eligible, account_by_alias)
if duplicate_ports:
return {
"reason": "DUPLICATE_DEBUG_PORT",
"duplicate_ports": duplicate_ports,
}
2026-06-27 17:13:18 +08:00
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
2026-06-29 10:25:09 +08:00
def _duplicate_debug_ports(self, account_rows, eligible, account_by_alias):
required_aliases = {
str(task.alias).strip()
for task in eligible
if account_by_alias.get(str(task.alias).strip()) is not None
}
by_port = {}
for account in account_rows:
if account.alias not in required_aliases:
continue
by_port.setdefault(int(account.debug_port), []).append(account)
duplicates = []
for port, rows in by_port.items():
if len(rows) > 1:
duplicates.append(
{
"debug_port": port,
"aliases": [row.alias for row in rows],
}
)
return duplicates
2026-07-01 08:35:46 +08:00
def _effective_batch_size(self, total):
if self.batch_size is None:
return max(1, int(total or 1))
return self.batch_size
def _task_batches(self, tasks, batch_size):
if not tasks:
return []
return [
tasks[index:index + batch_size]
for index in range(0, len(tasks), batch_size)
]
def _log_batch_start(self, batch_index, batch_count, batch_tasks, counters, total):
first = counters["done"] + 1
last = min(first + len(batch_tasks) - 1, total)
label = "预览批次" if self.dry_run else "更新批次"
self._log_run_event(
f"{label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}"
)
2026-06-29 10:25:09 +08:00
def _run_parallel_by_account(self, eligible, account_by_alias, counters, total):
groups = self._group_tasks_by_alias(eligible)
max_workers = min(self.max_parallel_accounts, len(groups))
if max_workers <= 1:
for group_tasks in groups:
self._run_task_group(group_tasks, account_by_alias, counters, total)
return
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(
self._run_task_group,
group_tasks,
account_by_alias,
counters,
total,
)
for group_tasks in groups
]
for future in as_completed(futures):
future.result()
def _group_tasks_by_alias(self, tasks):
groups = []
index_by_alias = {}
for task in tasks:
alias = str(task.alias).strip()
if alias not in index_by_alias:
index_by_alias[alias] = len(groups)
groups.append([])
groups[index_by_alias[alias]].append(task)
return groups
def _run_task_group(self, tasks, account_by_alias, counters, total):
for task in tasks:
if self.should_cancel():
break
outcome = self._apply_one_task(task, account_by_alias)
self._record_outcome(counters, total, outcome)
def _preview_task(self, task, account_by_alias):
account = account_by_alias.get(str(task.alias).strip())
if account is None:
reason = "别名未匹配账号"
self._log_run_event(
2026-07-01 08:35:46 +08:00
f"预览:任务 {task.id} 商品 {task.item_id} 将略过:{reason}",
2026-06-29 10:25:09 +08:00
task=task,
level="warning",
)
return "skipped"
action_parts = []
if getattr(task, "new_title", None):
action_parts.append("标题")
if getattr(task, "new_cover_path", None):
action_parts.append("封面")
action_text = "+".join(action_parts) or "无变更"
self._log_run_event(
2026-07-01 08:35:46 +08:00
"预览:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format(
2026-06-29 10:25:09 +08:00
task_id=task.id,
item_id=task.item_id,
alias=account.alias,
action=action_text,
),
task=task,
)
return "applied"
def _apply_one_task(self, task, account_by_alias):
account = account_by_alias.get(str(task.alias).strip())
if account is None:
reason = "别名未匹配账号"
db.mark_skipped(task.id, reason, path=self.db_path)
self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason})
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 已略过:{reason}",
task=task,
level="warning",
)
return "skipped"
try:
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}",
task=task,
)
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,
close_success_tab=self.close_success_tab,
)
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)
self.row_updated.emit(
task.id,
{
"stage": "applied",
"status": "success",
"committed": 1,
"last_error": None,
},
)
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 更新成功",
task=task,
)
return "applied"
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},
)
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 更新失败:{error}",
task=task,
level="error",
)
return "failed"
except Exception as exc:
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},
)
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 更新异常:{error}",
task=task,
level="error",
)
return "failed"
def _record_outcome(self, counters, total, outcome):
with self._progress_lock:
counters["done"] += 1
if outcome == "applied":
counters["applied"] += 1
elif outcome == "skipped":
counters["skipped"] += 1
else:
counters["failed"] += 1
self._emit_progress(
counters["done"],
total,
counters["applied"],
counters["skipped"],
counters["failed"],
)
2026-06-27 17:13:18 +08:00
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,
2026-06-29 10:25:09 +08:00
"dry_run": self.dry_run,
2026-07-01 08:35:46 +08:00
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
2026-06-27 17:13:18 +08:00
}
)
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 17:28:33 +08:00
def _batch_ids(self, tasks):
batch_ids = []
for task in tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
2026-06-29 10:25:09 +08:00
def _summary(self, ok, total, counters, batch_ids, blocked=False, extra=None):
summary = {
"ok": ok,
"total": total,
"done": counters["done"],
"applied": counters["applied"],
"skipped": counters["skipped"],
"failed": counters["failed"],
"batch_ids": batch_ids,
"dry_run": self.dry_run,
"parallel_accounts": self.parallel_accounts,
2026-07-01 08:35:46 +08:00
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
2026-06-29 10:25:09 +08:00
"run_id": self._run_id,
}
if blocked:
summary["blocked"] = True
if extra:
summary.update(extra)
return summary
def _create_run_log(self, eligible, batch_ids):
try:
return db.create_run_log(
"apply",
dry_run=self.dry_run,
total=len(eligible),
options={
"batch_ids": batch_ids,
"close_success_tab": self.close_success_tab,
"dry_run": self.dry_run,
"parallel_accounts": self.parallel_accounts,
"max_parallel_accounts": self.max_parallel_accounts,
2026-07-01 08:35:46 +08:00
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
2026-06-29 10:25:09 +08:00
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
db.finish_run_log(
self._run_id,
status=status,
done=summary.get("done", 0),
success_count=summary.get("applied", 0),
skipped_count=summary.get("skipped", 0),
failed_count=summary.get("failed", 0),
summary_json=summary,
path=self.db_path,
)
except Exception:
return
def _log_run_event(self, message, task=None, level="info"):
safe_message = diagnostics.redact_log_text(message)
self.log.emit(str(safe_message))
2026-06-29 10:25:09 +08:00
if self._run_id is None:
return
try:
db.add_run_log_event(
self._run_id,
safe_message,
2026-06-29 10:25:09 +08:00
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
level=level,
path=self.db_path,
)
except Exception:
return
2026-06-27 17:13:18 +08:00
2026-06-27 11:51:42 +08:00
class CollectWorker(BaseWorker):
"""Collect old title and cover for imported tasks."""
def __init__(
self,
tasks,
db_path=None,
config=None,
preflight=True,
diagnostic_log_dir=None,
):
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
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
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"
]
batch_ids = self._batch_ids(eligible)
2026-06-27 11:51:42 +08:00
total = len(eligible)
collected = 0
skipped = 0
failed = 0
done = 0
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
f"step=preflight result=start detail=采集运行开始 total={total}"
)
2026-06-27 15:00:15 +08:00
if self.preflight:
blocked = self._preflight_block(eligible, account_rows, account_by_alias)
if blocked:
self._log_preflight_blocked(blocked)
summary = self._summary(
ok=False,
total=total,
done=done,
collected=collected,
skipped=skipped,
failed=failed,
batch_ids=batch_ids,
blocked=True,
extra=blocked,
2026-06-27 15:00:15 +08:00
)
self._finish_run_log("blocked", summary)
return summary
self._log_run_event("step=preflight result=success detail=账号检查通过")
else:
self._log_run_event(
"step=preflight result=skipped detail=测试模式跳过采集前检查",
level="warning",
)
2026-06-27 15:00:15 +08:00
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._log_run_event(
"step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
2026-06-27 11:51:42 +08:00
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._log_run_event(
"step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format(
task_id=task.id,
item_id=task.item_id,
reason=reason,
),
task=task,
level="warning",
)
2026-06-27 11:51:42 +08:00
self._emit_progress(done, total, collected, skipped, failed)
continue
started = time.monotonic()
current_step = "db_write"
def on_step(step):
nonlocal current_step
current_step = str(step)
self._log_run_event(
"step={step} result=start detail=任务 {task_id} 商品 {item_id}".format(
step=current_step,
task_id=task.id,
item_id=task.item_id,
),
task=task,
)
2026-06-27 11:51:42 +08:00
try:
self._log_run_event(
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 标记采集运行".format(
task_id=task.id,
item_id=task.item_id,
),
task=task,
)
2026-06-27 11:51:42 +08:00
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),
},
on_step=on_step,
)
current_step = "db_write"
self._log_run_event(
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存采集结果".format(
task_id=task.id,
item_id=task.item_id,
),
task=task,
2026-06-27 11:51:42 +08:00
)
db.set_collected(
task.id,
result.get("old_title", ""),
result.get("old_cover_path", ""),
path=self.db_path,
)
collected += 1
elapsed_ms = self._elapsed_ms(started)
2026-06-27 11:51:42 +08:00
self.row_updated.emit(
task.id,
{
"stage": "collected",
"status": "success",
"old_title": result.get("old_title", ""),
"old_cover_path": result.get("old_cover_path", ""),
},
)
self._log_run_event(
"step=db_write result=success detail=任务 {task_id} 商品 {item_id} 采集成功 elapsed_ms={elapsed_ms}".format(
task_id=task.id,
item_id=task.item_id,
elapsed_ms=elapsed_ms,
),
task=task,
)
2026-06-27 11:51:42 +08:00
except Exception as exc:
failed += 1
error = str(exc) or exc.__class__.__name__
safe_error = diagnostics.redact_log_text(error)
elapsed_ms = self._elapsed_ms(started)
db.mark_failed(task.id, "collect", safe_error, path=self.db_path)
self.failed.emit(task.id, safe_error)
self.row_updated.emit(task.id, {"status": "failed", "last_error": safe_error})
self._log_run_event(
"step={step} result=failed detail={error} elapsed_ms={elapsed_ms}".format(
step=current_step,
error=safe_error,
elapsed_ms=elapsed_ms,
),
task=task,
level="error",
)
self._write_diagnostic_log(
"采集任务失败",
level="ERROR",
step=current_step,
task=task,
elapsed_ms=elapsed_ms,
payload={"error": safe_error},
exc=exc,
)
2026-06-27 11:51:42 +08:00
finally:
done += 1
self._emit_progress(done, total, collected, skipped, failed)
summary = self._summary(
ok=failed == 0,
total=total,
done=done,
collected=collected,
skipped=skipped,
failed=failed,
batch_ids=batch_ids,
)
self._finish_run_log("cancelled" if self.should_cancel() else "done", summary)
return summary
2026-06-27 11:51:42 +08:00
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)
2026-07-01 08:35:46 +08:00
return image_paths.task_image_path(image_root, task, account, "old")
2026-06-27 11:51:42 +08:00
def _batch_ids(self, tasks):
batch_ids = []
for task in tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
def _summary(
self,
ok,
total,
done,
collected,
skipped,
failed,
batch_ids,
blocked=False,
extra=None,
):
summary = {
"ok": ok,
"total": total,
"done": done,
"collected": collected,
"skipped": skipped,
"failed": failed,
"batch_ids": batch_ids,
"run_id": self._run_id,
}
if blocked:
summary["blocked"] = True
if extra:
summary.update(extra)
return summary
def _create_run_log(self, eligible, batch_ids):
try:
return db.create_run_log(
"collect",
dry_run=False,
total=len(eligible),
options={
"batch_ids": batch_ids,
"preflight": self.preflight,
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
db.finish_run_log(
self._run_id,
status=status,
done=summary.get("done", 0),
success_count=summary.get("collected", 0),
skipped_count=summary.get("skipped", 0),
failed_count=summary.get("failed", 0),
summary_json=summary,
path=self.db_path,
)
except Exception:
return
def _log_run_event(self, message, task=None, level="info"):
safe_message = diagnostics.redact_log_text(message)
self.log.emit(str(safe_message))
if self._run_id is None:
return
try:
db.add_run_log_event(
self._run_id,
safe_message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
level=level,
path=self.db_path,
)
except Exception:
return
def _log_preflight_blocked(self, blocked):
if blocked.get("no_accounts"):
self._log_run_event(
"step=preflight result=blocked detail=当前没有配置账号",
level="warning",
)
for item in blocked.get("not_running") or []:
self._log_run_event(
"step=preflight result=blocked detail=账号 {alias} Chrome 未启动或调试端口不可访问: {reason}".format(
alias=item.get("alias") or "",
reason=item.get("reason") or "",
),
level="warning",
)
for item in blocked.get("logged_out") or []:
self._log_run_event(
"step=preflight result=blocked detail=账号 {alias} 未登录 Shopee: {reason}".format(
alias=item.get("alias") or "",
reason=item.get("reason") or "",
),
level="warning",
)
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
task=None,
elapsed_ms=None,
payload=None,
exc=None,
):
try:
diagnostics.write_diagnostic_log(
message,
level=level,
step=step,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
except Exception:
return
def _elapsed_ms(self, started):
return int((time.monotonic() - started) * 1000)
2026-06-27 11:51:42 +08:00
2026-06-27 14:37:58 +08:00
class WriteBackWorker(BaseWorker):
2026-06-27 17:28:33 +08:00
"""Write Excel fields back in a background thread."""
2026-06-27 14:37:58 +08:00
2026-06-27 17:28:33 +08:00
def __init__(self, batch_id, db_path=None, excel_path=None, mode="old"):
2026-06-27 14:37:58 +08:00
super().__init__()
self.batch_id = batch_id
self.db_path = db_path
self.excel_path = excel_path
2026-06-27 17:28:33 +08:00
self.mode = mode
2026-06-27 14:37:58 +08:00
def execute(self):
2026-06-27 17:28:33 +08:00
results = [
self._write_one(batch_id)
for batch_id in self._batch_ids()
]
result = results[0] if len(results) == 1 else self._combined_result(results)
2026-06-27 14:37:58 +08:00
self.progress.emit(
{
"done": result.get("rows", 0),
"total": result.get("rows", 0),
"files": result.get("files", 0),
}
)
return result
2026-06-27 17:28:33 +08:00
def _batch_ids(self):
if isinstance(self.batch_id, (list, tuple, set)):
return list(self.batch_id)
return [self.batch_id]
def _write_one(self, batch_id):
if self.mode == "results":
return excel.write_back_results(
batch_id,
excel_path=self.excel_path,
path=self.db_path,
)
return excel.write_back(
batch_id,
excel_path=self.excel_path,
path=self.db_path,
)
def _combined_result(self, results):
written_files = []
for result in results:
for file_path in result.get("written_files", []):
if file_path not in written_files:
written_files.append(file_path)
return {
"ok": all(result.get("ok", False) for result in results),
"batch_id": [result.get("batch_id") for result in results],
"files": sum(result.get("files", 0) for result in results),
"rows": sum(result.get("rows", 0) for result in results),
"written_files": written_files,
}
2026-06-27 14:37:58 +08:00
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}
2026-06-29 08:45:04 +08:00
class AIModelTestWorker(BaseWorker):
"""Test one AI model connection without blocking the GUI thread."""
def __init__(self, model_name, ai_models_path=None):
super().__init__()
self.model_name = model_name
self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH
def execute(self):
result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path)
payload = dict(appconfig.sanitize_for_log(result or {}))
2026-06-29 08:45:04 +08:00
payload["name"] = self.model_name
return payload
class SettingsTab(QWidget):
"""Tab 5: AI model definitions stored in config/ai_models.json."""
CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")]
API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")]
RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"]
2026-06-29 08:45:04 +08:00
def __init__(
self,
parent=None,
config=None,
config_path=None,
2026-06-29 08:45:04 +08:00
ai_models_path=None,
status_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.config_path = (
config_path
or self.config.get("config_path")
or appconfig.CONFIG_PATH
)
2026-06-29 08:45:04 +08:00
self.ai_models_path = (
ai_models_path
or self.config.get("ai_models_path")
or appconfig.AI_MODELS_PATH
)
self.status_callback = status_callback
self.models = []
self.current_model_name = None
self.test_worker = None
self.test_thread = None
self.model_combo = QComboBox()
self.model_combo.setObjectName("aiModelCombo")
self.add_model_button = QPushButton("新增")
self.delete_model_button = QPushButton("删除")
self.enabled_checkbox = QCheckBox("启用")
self.name_edit = QLineEdit()
self.name_edit.setObjectName("modelNameEdit")
self.category_combo = QComboBox()
self.category_combo.setObjectName("modelCategoryCombo")
for label, value in self.CATEGORY_ITEMS:
self.category_combo.addItem(label, value)
self.api_type_combo = QComboBox()
self.api_type_combo.setObjectName("modelApiTypeCombo")
for label, value in self.API_TYPE_ITEMS:
self.api_type_combo.addItem(label, value)
self.model_id_edit = QLineEdit()
self.model_id_edit.setObjectName("modelIdEdit")
self.url_edit = QLineEdit()
self.url_edit.setObjectName("modelUrlEdit")
self.api_key_edit = QLineEdit()
self.api_key_edit.setObjectName("modelApiKeyEdit")
self.api_key_edit.setEchoMode(QLineEdit.Password)
self.connect_timeout_spin = QSpinBox()
self.connect_timeout_spin.setObjectName("connectTimeoutSpin")
self.connect_timeout_spin.setRange(1, 3600)
self.connect_timeout_spin.setValue(30)
self.save_model_button = QPushButton("保存")
self.test_connection_button = QPushButton("测试连接")
self.test_result_label = QLabel("")
self.test_result_label.setWordWrap(True)
self.default_text_model_combo = QComboBox()
self.default_text_model_combo.setObjectName("defaultTextModelCombo")
self.default_image_model_combo = QComboBox()
self.default_image_model_combo.setObjectName("defaultImageModelCombo")
self.title_concurrency_spin = QSpinBox()
self.title_concurrency_spin.setObjectName("titleConcurrencySpin")
self.title_concurrency_spin.setRange(1, 64)
self.image_concurrency_spin = QSpinBox()
self.image_concurrency_spin.setObjectName("imageConcurrencySpin")
self.image_concurrency_spin.setRange(1, 64)
self.retry_spin = QSpinBox()
self.retry_spin.setObjectName("retrySpin")
self.retry_spin.setRange(0, 20)
self.resolution_combo = QComboBox()
self.resolution_combo.setObjectName("resolutionCombo")
for resolution in self.RESOLUTION_ITEMS:
self.resolution_combo.addItem(resolution, resolution)
self.response_timeout_label = QLabel("")
self.jpg_quality_spin = QSpinBox()
self.jpg_quality_spin.setObjectName("jpgQualitySpin")
self.jpg_quality_spin.setRange(1, 100)
self.chrome_path_edit = QLineEdit()
self.chrome_path_edit.setObjectName("chromePathEdit")
self.user_data_root_edit = QLineEdit()
self.user_data_root_edit.setObjectName("userDataRootEdit")
self.image_dir_edit = QLineEdit()
self.image_dir_edit.setObjectName("imageDirEdit")
self.db_path_edit = QLineEdit()
self.db_path_edit.setObjectName("dbPathEdit")
self.default_debug_port_spin = QSpinBox()
self.default_debug_port_spin.setObjectName("defaultDebugPortSpin")
self.default_debug_port_spin.setRange(1, 65535)
self.debug_port_start_spin = QSpinBox()
self.debug_port_start_spin.setObjectName("debugPortStartSpin")
self.debug_port_start_spin.setRange(1, 65535)
self.debug_port_end_spin = QSpinBox()
self.debug_port_end_spin.setObjectName("debugPortEndSpin")
self.debug_port_end_spin.setRange(1, 65535)
self.cdp_ready_timeout_spin = QSpinBox()
self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin")
self.cdp_ready_timeout_spin.setRange(1, 3600)
self.save_config_button = QPushButton("保存设置")
2026-06-29 09:18:03 +08:00
self.test_item_id_edit = QLineEdit()
self.test_item_id_edit.setObjectName("testItemIdEdit")
2026-07-01 08:35:46 +08:00
self.test_item_id_edit.setVisible(False)
2026-06-29 09:18:03 +08:00
self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品")
self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox")
self.allow_cover_update_checkbox = QCheckBox("允许更新封面")
self.allow_cover_update_checkbox.setObjectName("allowCoverUpdateCheckbox")
self.max_items_per_run_spin = QSpinBox()
self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin")
self.max_items_per_run_spin.setRange(1, 9999)
2026-07-01 08:35:46 +08:00
self.max_items_per_run_spin.setToolTip("作为每批最大更新条数;正式更新会分批处理当前筛选全部可更新记录。")
2026-06-29 09:18:03 +08:00
self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页")
self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox")
2026-07-01 08:35:46 +08:00
self.dry_run_checkbox = QCheckBox("预览本轮更新")
2026-06-29 10:25:09 +08:00
self.dry_run_checkbox.setObjectName("dryRunCheckbox")
2026-07-01 08:35:46 +08:00
self.dry_run_checkbox.setVisible(False)
2026-06-29 10:25:09 +08:00
self.parallel_accounts_checkbox = QCheckBox("多账号并行更新")
self.parallel_accounts_checkbox.setObjectName("parallelAccountsCheckbox")
self.max_parallel_accounts_spin = QSpinBox()
self.max_parallel_accounts_spin.setObjectName("maxParallelAccountsSpin")
self.max_parallel_accounts_spin.setRange(1, 16)
2026-07-01 08:35:46 +08:00
self.max_parallel_accounts_label = QLabel("最大并行账号数")
self.parallel_accounts_group = QWidget()
self.parallel_accounts_group.setObjectName("parallelAccountsGroup")
parallel_accounts_layout = QHBoxLayout(self.parallel_accounts_group)
parallel_accounts_layout.setContentsMargins(0, 0, 0, 0)
parallel_accounts_layout.setSpacing(12)
parallel_accounts_layout.addWidget(self.parallel_accounts_checkbox)
parallel_accounts_layout.addWidget(self.max_parallel_accounts_label)
parallel_accounts_layout.addWidget(self.max_parallel_accounts_spin)
parallel_accounts_layout.addStretch(1)
2026-06-29 08:45:04 +08:00
2026-06-29 11:45:10 +08:00
model_picker_layout = QHBoxLayout()
model_picker_layout.addWidget(self.model_combo, 1)
model_picker_layout.addWidget(self.add_model_button)
model_picker_layout.addWidget(self.delete_model_button)
2026-06-29 08:45:04 +08:00
action_layout = QHBoxLayout()
action_layout.addWidget(self.save_model_button)
action_layout.addWidget(self.test_connection_button)
action_layout.addStretch(1)
2026-07-01 08:35:46 +08:00
form = self._three_column_form(
2026-06-29 11:45:10 +08:00
[
("状态", self.enabled_checkbox),
("服务商名", self.name_edit),
("类别", self.category_combo),
("api_type", self.api_type_combo),
("模型ID", self.model_id_edit),
("连接超时(秒)", self.connect_timeout_spin),
("网址", self.url_edit, True),
("密钥", self.api_key_edit, True),
]
)
2026-07-01 08:35:46 +08:00
ai_form = self._three_column_form(
2026-06-29 11:45:10 +08:00
[
("标题大模型", self.default_text_model_combo),
("图片大模型", self.default_image_model_combo),
("标题并发数", self.title_concurrency_spin),
("图片并发数", self.image_concurrency_spin),
("失败重试次数", self.retry_spin),
("分辨率", self.resolution_combo),
("返回超时", self.response_timeout_label),
("jpg质量", self.jpg_quality_spin),
]
)
port_range_layout = QHBoxLayout()
2026-06-29 11:45:10 +08:00
port_range_layout.setContentsMargins(0, 0, 0, 0)
port_range_layout.addWidget(self.debug_port_start_spin)
port_range_layout.addWidget(QLabel("到"))
port_range_layout.addWidget(self.debug_port_end_spin)
2026-06-29 11:45:10 +08:00
port_range_widget = QWidget()
port_range_widget.setLayout(port_range_layout)
2026-07-01 08:35:46 +08:00
path_form = self._three_column_form(
2026-06-29 11:45:10 +08:00
[
("Chrome路径", self.chrome_path_edit, True),
("账号数据根目录", self.user_data_root_edit),
("图片目录", self.image_dir_edit),
("DB路径", self.db_path_edit),
("默认调试端口", self.default_debug_port_spin),
("调试端口范围", port_range_widget),
("CDP就绪超时(秒)", self.cdp_ready_timeout_spin),
]
)
2026-07-01 08:35:46 +08:00
self.shopee_update_form_layout = self._three_column_form(
2026-06-29 11:45:10 +08:00
[
2026-07-01 08:35:46 +08:00
("每批最大更新条数", self.max_items_per_run_spin),
2026-06-29 11:45:10 +08:00
("", self.allow_real_submit_checkbox),
("", self.close_success_tab_checkbox),
2026-07-01 08:35:46 +08:00
("", self.allow_cover_update_checkbox),
("", self.parallel_accounts_group, 2),
2026-06-29 11:45:10 +08:00
]
)
2026-06-29 09:18:03 +08:00
2026-06-29 11:45:10 +08:00
panel = QWidget()
2026-07-01 08:35:46 +08:00
panel.setMaximumWidth(1800)
2026-06-29 11:45:10 +08:00
panel_layout = QVBoxLayout(panel)
2026-07-01 08:35:46 +08:00
panel_layout.setContentsMargins(13, 18, 13, 18)
2026-06-29 11:45:10 +08:00
panel_layout.addWidget(QLabel("AI 模型"))
panel_layout.addLayout(model_picker_layout)
panel_layout.addSpacing(14)
panel_layout.addWidget(QLabel("模型详情"))
panel_layout.addLayout(form)
panel_layout.addLayout(action_layout)
panel_layout.addWidget(self.test_result_label)
panel_layout.addSpacing(18)
panel_layout.addWidget(QLabel("角色与生成参数"))
panel_layout.addLayout(ai_form)
panel_layout.addSpacing(18)
panel_layout.addWidget(QLabel("路径与端口"))
panel_layout.addLayout(path_form)
panel_layout.addSpacing(18)
panel_layout.addWidget(QLabel("Shopee 更新安全"))
2026-07-01 08:35:46 +08:00
panel_layout.addLayout(self.shopee_update_form_layout)
2026-06-29 11:45:10 +08:00
panel_layout.addWidget(self.save_config_button)
panel_layout.addStretch(1)
2026-06-29 08:45:04 +08:00
2026-06-29 11:45:10 +08:00
scroll = QScrollArea()
scroll.setWidgetResizable(True)
2026-07-01 08:35:46 +08:00
scroll_content = QWidget()
scroll_layout = QHBoxLayout(scroll_content)
scroll_layout.setContentsMargins(0, 0, 0, 0)
scroll_layout.addStretch(1)
scroll_layout.addWidget(panel)
scroll_layout.addStretch(1)
scroll.setWidget(scroll_content)
2026-06-29 08:45:04 +08:00
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
2026-06-29 11:45:10 +08:00
layout.addWidget(scroll, 1)
2026-06-29 08:45:04 +08:00
self.model_combo.currentIndexChanged.connect(self.load_selected_model)
self.add_model_button.clicked.connect(self.add_model)
self.delete_model_button.clicked.connect(self.delete_model)
self.save_model_button.clicked.connect(self.save_model)
self.test_connection_button.clicked.connect(self.test_connection)
self.resolution_combo.currentIndexChanged.connect(
self._update_response_timeout_label
)
self.save_config_button.clicked.connect(self.save_app_settings)
2026-06-29 08:45:04 +08:00
self.refresh_models()
self._populate_app_settings()
2026-06-29 08:45:04 +08:00
2026-07-01 08:35:46 +08:00
def _three_column_form(self, fields):
2026-06-29 11:45:10 +08:00
layout = QGridLayout()
layout.setHorizontalSpacing(18)
layout.setVerticalSpacing(8)
2026-07-01 08:35:46 +08:00
for column in (1, 3, 5):
layout.setColumnStretch(column, 1)
2026-06-29 11:45:10 +08:00
row = 0
column_pair = 0
for field in fields:
label = field[0]
widget = field[1]
2026-07-01 08:35:46 +08:00
span_pairs = self._form_field_span_pairs(field)
if span_pairs > 3 - column_pair:
2026-06-29 11:45:10 +08:00
row += 1
column_pair = 0
column = column_pair * 2
2026-07-01 08:35:46 +08:00
self._add_form_field(layout, row, column, label, widget, span_pairs)
column_pair += span_pairs
if column_pair >= 3:
2026-06-29 11:45:10 +08:00
row += 1
column_pair = 0
return layout
2026-07-01 08:35:46 +08:00
def _form_field_span_pairs(self, field):
if len(field) <= 2:
return 1
span = field[2]
if isinstance(span, bool):
return 3 if span else 1
return max(1, min(3, int(span or 1)))
def _add_form_field(self, layout, row, column, label, widget, span_pairs):
2026-06-29 11:45:10 +08:00
if label:
layout.addWidget(QLabel(label), row, column)
2026-07-01 08:35:46 +08:00
layout.addWidget(widget, row, column + 1, 1, span_pairs * 2 - 1)
2026-06-29 11:45:10 +08:00
else:
2026-07-01 08:35:46 +08:00
layout.addWidget(widget, row, column, 1, span_pairs * 2)
2026-06-29 11:45:10 +08:00
2026-06-29 08:45:04 +08:00
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def refresh_models(self, selected=None):
try:
self.models = appconfig.list_ai_models(
path=self.ai_models_path,
reveal_api_key=True,
)
except Exception as exc:
self.models = []
self.current_model_name = None
self._show_error(exc)
current = selected or self.current_model_name
self.model_combo.blockSignals(True)
self.model_combo.clear()
for model in self.models:
label = f"{model['name']} · {self._category_label(model['category'])}"
if not model.get("enabled", True):
label += " · 已停用"
self.model_combo.addItem(label, model["name"])
index = self.model_combo.findData(current)
self.model_combo.setCurrentIndex(index if index >= 0 else (0 if self.models else -1))
self.model_combo.blockSignals(False)
self.load_selected_model()
if hasattr(self, "default_text_model_combo"):
self._populate_role_model_combos()
2026-06-29 08:45:04 +08:00
def load_selected_model(self, index=None):
name = self.model_combo.currentData()
model = self._model_by_name(name)
self.current_model_name = model["name"] if model else None
self._populate_form(model)
self._update_button_state()
def add_model(self, checked=False):
name = self._unique_model_name("新文本模型")
model = {
"name": name,
"category": "text",
"enabled": True,
"url": "",
"model": "",
"api_key": "",
"api_type": "chat",
"connect_timeout_seconds": 30,
"timeout_seconds": 0,
"extra_body": {},
}
try:
appconfig.add_ai_model(model, path=self.ai_models_path)
except Exception as exc:
self._show_error(exc)
return
self.refresh_models(selected=name)
self._set_status(f"AI 模型已新增:{name}")
def save_model(self, checked=False):
model = self._form_values()
if model is None:
return
current = self._current_model()
if self._should_warn_plaintext_api_key(model, current):
self._show_plaintext_api_key_warning()
2026-06-29 08:45:04 +08:00
try:
if self.current_model_name is None:
appconfig.add_ai_model(model, path=self.ai_models_path)
else:
appconfig.update_ai_model(
self.current_model_name,
path=self.ai_models_path,
**model,
)
except Exception as exc:
self._show_error(exc)
return
self.refresh_models(selected=model["name"])
self._set_status(f"AI 模型已保存:{model['name']}")
def delete_model(self, checked=False):
model = self._current_model()
if model is None:
return
if not self._can_delete_model(model):
self._set_status("每个类别至少保留一个模型,当前模型不能删除")
return
answer = QMessageBox.question(
self,
"删除 AI 模型",
f"确认删除模型「{model['name']}」?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
return
try:
appconfig.delete_ai_model(model["name"], path=self.ai_models_path)
except Exception as exc:
self._show_error(exc)
return
self.refresh_models()
self._set_status(f"AI 模型已删除:{model['name']}")
def test_connection(self, checked=False):
if self.test_thread is not None:
self._set_status("模型连接测试正在进行...")
return
model = self._current_model()
if model is None:
return
if self.name_edit.text().strip() != model["name"]:
self._set_status("请先保存模型名称变更后再测试连接")
return
worker = AIModelTestWorker(model["name"], ai_models_path=self.ai_models_path)
worker.finished.connect(self._on_test_finished)
worker.failed.connect(self._on_test_failed)
thread = run_worker(worker, thread_name="AIModelTestWorker", start=False)
thread.finished.connect(lambda: self._forget_test_thread(thread))
self.test_worker = worker
self.test_thread = thread
self._set_test_running(True)
self.test_result_label.setText("正在测试连接...")
self._set_status(f"正在测试 AI 模型连接:{model['name']}")
thread.start()
def save_app_settings(self, checked=False):
settings = self._app_settings_values()
if settings is None:
return
try:
saved = appconfig.save_config(settings, path=self.config_path)
except Exception as exc:
self._show_error(exc)
return
self._replace_config(saved)
self._populate_app_settings()
self._set_status("设置已保存")
2026-07-01 08:35:46 +08:00
QMessageBox.information(self, "保存设置", "设置已保存")
def _app_settings_values(self):
start_port = self.debug_port_start_spin.value()
end_port = self.debug_port_end_spin.value()
default_port = self.default_debug_port_spin.value()
if start_port > end_port:
self._show_error("调试端口范围起始值不能大于结束值")
return None
if not (start_port <= default_port <= end_port):
self._show_error("默认调试端口必须在调试端口范围内")
return None
text_model = self.default_text_model_combo.currentData()
image_model = self.default_image_model_combo.currentData()
if not text_model or not image_model:
self._show_error("标题大模型和图片大模型不能为空")
return None
ai_cfg = appconfig.ai_config(self.config)
ai_cfg.update(
{
"default_text_model": text_model,
"default_image_model": image_model,
"title_concurrency": self.title_concurrency_spin.value(),
"image_concurrency": self.image_concurrency_spin.value(),
"retry": self.retry_spin.value(),
"jpg_quality": self.jpg_quality_spin.value(),
"resolution": self.resolution_combo.currentData() or "1k",
"resolution_timeouts": dict(ai_cfg.get("resolution_timeouts", {})),
}
)
settings = {
key: value
for key, value in self.config.items()
if key not in {"config_path", "ai_models_path"}
}
settings.update(
{
"chrome_path": self.chrome_path_edit.text().strip(),
"user_data_root": self.user_data_root_edit.text().strip(),
"image_dir": self.image_dir_edit.text().strip(),
"db_path": self.db_path_edit.text().strip(),
"default_debug_port": default_port,
"debug_port_range": [start_port, end_port],
"cdp_ready_timeout": self.cdp_ready_timeout_spin.value(),
"ai": ai_cfg,
2026-06-29 09:18:03 +08:00
"shopee_update": {
"test_item_id": self.test_item_id_edit.text().strip(),
"allow_real_submit": self.allow_real_submit_checkbox.isChecked(),
"allow_cover_update": self.allow_cover_update_checkbox.isChecked(),
"max_items_per_run": self.max_items_per_run_spin.value(),
"close_success_tab": self.close_success_tab_checkbox.isChecked(),
2026-07-01 08:35:46 +08:00
"dry_run": False,
2026-06-29 10:25:09 +08:00
"parallel_accounts": self.parallel_accounts_checkbox.isChecked(),
"max_parallel_accounts": self.max_parallel_accounts_spin.value(),
2026-06-29 09:18:03 +08:00
},
}
)
return settings
def _replace_config(self, saved):
internal = {}
if self.config_path != appconfig.CONFIG_PATH:
internal["config_path"] = self.config_path
if self.ai_models_path != appconfig.AI_MODELS_PATH:
internal["ai_models_path"] = self.ai_models_path
self.config.clear()
self.config.update(saved)
self.config.update(internal)
def _populate_app_settings(self):
self._populate_role_model_combos()
ai_cfg = appconfig.ai_config(self.config)
self._set_combo_by_data(
self.default_text_model_combo,
ai_cfg.get("default_text_model", ""),
)
self._set_combo_by_data(
self.default_image_model_combo,
ai_cfg.get("default_image_model", ""),
)
self.title_concurrency_spin.setValue(
int(ai_cfg.get("title_concurrency", 4) or 4)
)
self.image_concurrency_spin.setValue(
int(ai_cfg.get("image_concurrency", 4) or 4)
)
self.retry_spin.setValue(int(ai_cfg.get("retry", 2) or 0))
self._set_combo_by_data(
self.resolution_combo,
str(ai_cfg.get("resolution", "1k")),
)
self.jpg_quality_spin.setValue(int(ai_cfg.get("jpg_quality", 90) or 90))
self.chrome_path_edit.setText(appconfig.chrome_path(self.config))
self.user_data_root_edit.setText(appconfig.user_data_root(self.config))
self.image_dir_edit.setText(appconfig.image_dir(self.config))
self.db_path_edit.setText(appconfig.db_path(self.config))
self.default_debug_port_spin.setValue(
int(appconfig.default_debug_port(self.config))
)
start_port, end_port = appconfig.debug_port_range(self.config)
self.debug_port_start_spin.setValue(int(start_port))
self.debug_port_end_spin.setValue(int(end_port))
self.cdp_ready_timeout_spin.setValue(
int(appconfig.cdp_ready_timeout(self.config))
)
2026-06-29 09:18:03 +08:00
update_cfg = self._shopee_update_config()
self.test_item_id_edit.setText(str(update_cfg.get("test_item_id", "")))
self.allow_real_submit_checkbox.setChecked(
bool(update_cfg.get("allow_real_submit", False))
)
self.allow_cover_update_checkbox.setChecked(
bool(update_cfg.get("allow_cover_update", False))
)
self.max_items_per_run_spin.setValue(
max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
)
self.close_success_tab_checkbox.setChecked(
bool(update_cfg.get("close_success_tab", False))
)
2026-07-01 08:35:46 +08:00
self.dry_run_checkbox.setChecked(False)
2026-06-29 10:25:09 +08:00
self.parallel_accounts_checkbox.setChecked(
bool(update_cfg.get("parallel_accounts", False))
)
self.max_parallel_accounts_spin.setValue(
max(1, int(update_cfg.get("max_parallel_accounts", 2) or 2))
)
self._update_response_timeout_label()
2026-06-29 09:18:03 +08:00
def _shopee_update_config(self):
defaults = appconfig.default_config().get("shopee_update", {})
loaded = self.config.get("shopee_update", {})
if not isinstance(loaded, dict):
loaded = {}
merged = dict(defaults)
merged.update(loaded)
return merged
def _populate_role_model_combos(self):
ai_cfg = appconfig.ai_config(self.config)
self._populate_role_combo(
self.default_text_model_combo,
"text",
ai_cfg.get("default_text_model"),
)
self._populate_role_combo(
self.default_image_model_combo,
"image",
ai_cfg.get("default_image_model"),
)
def _populate_role_combo(self, combo, category, selected):
combo.blockSignals(True)
combo.clear()
for model in self.models:
if model.get("category") == category and model.get("enabled", True):
combo.addItem(model.get("name", ""), model.get("name", ""))
if combo.count() == 0:
combo.addItem("无可用模型", None)
index = combo.findData(selected)
combo.setCurrentIndex(index if index >= 0 else 0)
combo.blockSignals(False)
def _update_response_timeout_label(self, index=None):
ai_cfg = appconfig.ai_config(self.config)
resolution = self.resolution_combo.currentData() or ai_cfg.get("resolution", "1k")
timeouts = ai_cfg.get("resolution_timeouts", {})
timeout = timeouts.get(str(resolution))
if timeout is None:
self.response_timeout_label.setText("未配置")
return
self.response_timeout_label.setText(f"{int(timeout)} 秒")
2026-06-29 08:45:04 +08:00
def _form_values(self):
current = self._current_model() or {}
name = self.name_edit.text().strip()
if not name:
self._show_error("AI 模型服务商名不能为空")
return None
extra_body = current.get("extra_body", {})
if not isinstance(extra_body, dict):
extra_body = {}
return {
"name": name,
"category": self.category_combo.currentData() or "text",
"enabled": self.enabled_checkbox.isChecked(),
"url": self.url_edit.text().strip(),
"model": self.model_id_edit.text().strip(),
"api_key": self.api_key_edit.text(),
"api_type": self.api_type_combo.currentData() or "auto",
"connect_timeout_seconds": self.connect_timeout_spin.value(),
"timeout_seconds": int(current.get("timeout_seconds", 0) or 0),
"extra_body": dict(extra_body),
}
def _populate_form(self, model):
widgets = [
self.enabled_checkbox,
self.name_edit,
self.category_combo,
self.api_type_combo,
self.model_id_edit,
self.url_edit,
self.api_key_edit,
self.connect_timeout_spin,
]
for widget in widgets:
widget.blockSignals(True)
if model is None:
self.enabled_checkbox.setChecked(False)
self.name_edit.clear()
self.category_combo.setCurrentIndex(0)
self.api_type_combo.setCurrentIndex(0)
self.model_id_edit.clear()
self.url_edit.clear()
self.api_key_edit.clear()
self.connect_timeout_spin.setValue(30)
else:
self.enabled_checkbox.setChecked(bool(model.get("enabled", True)))
self.name_edit.setText(model.get("name", ""))
self._set_combo_by_data(self.category_combo, model.get("category", "text"))
self._set_combo_by_data(self.api_type_combo, model.get("api_type", "auto"))
self.model_id_edit.setText(model.get("model", ""))
self.url_edit.setText(model.get("url", ""))
self.api_key_edit.setText(model.get("api_key", ""))
self.connect_timeout_spin.setValue(
int(model.get("connect_timeout_seconds", 30) or 30)
)
for widget in widgets:
widget.blockSignals(False)
def _set_combo_by_data(self, combo, value):
index = combo.findData(value)
combo.setCurrentIndex(index if index >= 0 else 0)
def _update_button_state(self):
has_model = self._current_model() is not None
testing = self.test_thread is not None
for widget in (
self.enabled_checkbox,
self.name_edit,
self.category_combo,
self.api_type_combo,
self.model_id_edit,
self.url_edit,
self.api_key_edit,
self.connect_timeout_spin,
self.save_model_button,
):
widget.setEnabled(has_model and not testing)
self.add_model_button.setEnabled(not testing)
self.delete_model_button.setEnabled(
has_model and not testing and self._can_delete_model(self._current_model())
)
self.test_connection_button.setEnabled(has_model and not testing)
def _set_test_running(self, running):
self._update_button_state()
self.test_connection_button.setEnabled(
not running and self._current_model() is not None
)
def _forget_test_thread(self, thread):
if self.test_thread is thread:
self.test_thread = None
self.test_worker = None
self._set_test_running(False)
def _on_test_finished(self, payload):
if payload.get("ok"):
status = payload.get("status")
suffix = f"(HTTP {status})" if status else ""
message = f"测试连接成功:{payload.get('name')}{suffix}"
else:
error = payload.get("error") or "连接失败"
status = payload.get("status")
status_text = f"HTTP {status}," if status else ""
message = f"测试连接失败:{status_text}{error}"
self.test_result_label.setText(message)
self._set_status(message)
def _on_test_failed(self, _task_id, error):
message = f"测试连接失败:{error}"
self.test_result_label.setText(message)
self._set_status(message)
def _show_error(self, error):
message = str(error)
QMessageBox.warning(self, "设置", message)
self._set_status(message)
def _should_warn_plaintext_api_key(self, model, current):
new_key = str((model or {}).get("api_key") or "")
current_key = str((current or {}).get("api_key") or "")
return bool(new_key) and new_key != current_key
def _show_plaintext_api_key_warning(self):
QMessageBox.warning(
self,
PLAINTEXT_SECRET_TITLE,
PLAINTEXT_API_KEY_WARNING,
)
2026-06-29 08:45:04 +08:00
def _current_model(self):
return self._model_by_name(self.current_model_name)
def _model_by_name(self, name):
for model in self.models:
if model.get("name") == name:
return model
return None
def _unique_model_name(self, base):
names = {model.get("name") for model in self.models}
if base not in names:
return base
counter = 2
while f"{base} {counter}" in names:
counter += 1
return f"{base} {counter}"
def _can_delete_model(self, model):
if model is None:
return False
category = model.get("category")
return sum(1 for item in self.models if item.get("category") == category) > 1
def _category_label(self, category):
return {"text": "文本", "image": "图像"}.get(category, category)
2026-06-27 10:30:45 +08:00
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
values = dialog.values()
if self._should_warn_plaintext_password(values):
self._show_plaintext_password_warning()
2026-06-27 10:30:45 +08:00
try:
accounts.create_account(
path=self.db_path,
config=self.config,
**values,
2026-06-27 10:30:45 +08:00
)
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
values = dialog.values()
if self._should_warn_plaintext_password(values, account):
self._show_plaintext_password_warning()
2026-06-27 10:30:45 +08:00
try:
updated = accounts.update_account(
account.alias,
path=self.db_path,
config=self.config,
**values,
2026-06-27 10:30:45 +08:00
)
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}")
def _should_warn_plaintext_password(self, values, account=None):
new_password = str((values or {}).get("password") or "")
current_password = str(getattr(account, "password", None) or "")
return bool(new_password) and new_password != current_password
def _show_plaintext_password_warning(self):
QMessageBox.warning(
self,
PLAINTEXT_SECRET_TITLE,
PLAINTEXT_PASSWORD_WARNING,
)
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."""
def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=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)
self.config_path = (
config_path
or self.config.get("config_path")
or appconfig.CONFIG_PATH
)
2026-06-29 08:45:04 +08:00
self.ai_models_path = (
ai_models_path
or self.config.get("ai_models_path")
or appconfig.AI_MODELS_PATH
)
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-29 08:45:04 +08:00
return SettingsTab(
config=self.config,
config_path=self.config_path,
2026-06-29 08:45:04 +08:00
ai_models_path=self.ai_models_path,
status_callback=self.statusBar().showMessage,
)
2026-06-27 09:56:53 +08:00
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()