Files
cmshoppe/app/gui.py
T

6318 lines
268 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PySide6 GUI entry point."""
from __future__ import annotations
import os
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
try:
from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QGridLayout,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QProgressBar,
QPushButton,
QScrollArea,
QSplitter,
QTableView,
QSpinBox,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QVBoxLayout,
QWidget,
)
QT_IMPORT_ERROR = None
except ModuleNotFoundError as exc:
QApplication = None
QMainWindow = object
QTabWidget = None
QVBoxLayout = None
QWidget = object
QT_IMPORT_ERROR = exc
TAB_TITLES = [
"① 导入采集",
"② AI生成",
"③ 更新shopee",
"④ 账号管理",
"⑤ 设置",
]
TAB_STYLE = """
QTabWidget::pane {
border-top: 1px solid #c9d1d9;
}
QTabBar::tab {
min-width: 128px;
min-height: 34px;
padding: 8px 18px;
margin-right: 8px;
border: 1px solid #c9d1d9;
border-bottom-color: #b8c0ca;
background: #f4f6f8;
color: #24292f;
}
QTabBar::tab:selected {
background: #ffffff;
border-color: #687785;
border-bottom-color: #ffffff;
font-weight: 600;
}
QTabBar::tab:hover:!selected {
background: #eaf2ff;
}
"""
COLOR_SUCCESS = "#1a7f37"
COLOR_DANGER = "#cf222e"
COLOR_INFO = "#0969da"
COLOR_PENDING = "#9a6700"
COLOR_MUTED = "#6e7781"
COLOR_WARNING = "#bc4c00"
COLOR_DANGER_BG = "#ffebe9"
if QT_IMPORT_ERROR is None:
from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, image_paths, prompts
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,请勿提交或分享。"
)
def _qcolor(color):
return QColor(color)
def _status_base_color(status):
if status == "running":
return COLOR_INFO
if status == "failed":
return COLOR_DANGER
if status in {"skipped", "cancelled"}:
return COLOR_MUTED
return None
def _danger_metric_text(text, active):
if not active:
return text
return f'<span style="color:{COLOR_DANGER}; font-weight:600;">{text}</span>'
def _danger_outline_button_style(object_name):
return (
f"QPushButton#{object_name} {{ "
f"color: {COLOR_DANGER}; border: 1px solid {COLOR_DANGER}; "
"font-weight: 600; padding: 3px 10px; border-radius: 4px; "
"}"
)
def _login_status_display(status):
return f"● {status or '未知'}"
def _login_status_color(status):
status_text = str(status or "")
if status_text == "已登录":
return _qcolor(COLOR_SUCCESS)
if status_text == "检测中":
return _qcolor(COLOR_INFO)
if "未登录" in status_text or "失败" in status_text:
return _qcolor(COLOR_DANGER)
return _qcolor(COLOR_MUTED)
def _warning_dot_icon(size=12):
pixmap = QPixmap(size, size)
pixmap.fill(Qt.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(Qt.NoPen)
painter.setBrush(QColor(COLOR_WARNING))
margin = max(1, size // 6)
painter.drawEllipse(margin, margin, size - margin * 2, size - margin * 2)
painter.end()
return QIcon(pixmap)
def _build_empty_state_card(object_name, button_text="前往账号管理"):
card = QWidget()
card.setObjectName(object_name)
card.setStyleSheet(
f"QWidget#{object_name} {{ "
"background: #f6f8fa; border: 1px solid #d0d7de; "
"border-radius: 6px; padding: 10px; "
"}"
)
layout = QHBoxLayout(card)
layout.setContentsMargins(12, 10, 12, 10)
label = QLabel("")
label.setWordWrap(True)
button = QPushButton(button_text)
button.setObjectName(f"{object_name}Button")
button.setVisible(False)
layout.addWidget(label, 1)
layout.addWidget(button)
card.setVisible(False)
return card, label, button
def _set_empty_state(card, label, button, message=None, show_button=False):
active = bool(message)
card.setVisible(active)
label.setText(message or "")
button.setVisible(active and show_button)
BATCH_PROGRESS_FIELDS = [
("total", "总数"),
("imported", "导入"),
("collected", "已采集"),
("generated", "已生成"),
("applied", "已更新"),
("failed", "失败"),
("skipped", "略过"),
]
def _summarize_batch_progress(tasks):
summary = {key: 0 for key, _label in BATCH_PROGRESS_FIELDS}
task_rows = list(tasks or [])
summary["total"] = len(task_rows)
for task in task_rows:
status = getattr(task, "status", None)
stage = getattr(task, "stage", None)
if status == "failed":
summary["failed"] += 1
elif status == "skipped":
summary["skipped"] += 1
elif stage == "applied":
summary["applied"] += 1
elif stage == "generated":
summary["generated"] += 1
elif stage == "collected":
summary["collected"] += 1
else:
summary["imported"] += 1
return summary
def _format_batch_progress(summary):
parts = [f"{label}{summary.get(key, 0)}" for key, label in BATCH_PROGRESS_FIELDS]
return "批次进度:" + " · ".join(parts)
def _build_batch_progress_overview(object_name):
label = QLabel("")
label.setObjectName(object_name)
label.setWordWrap(True)
label.setStyleSheet(
f"QLabel#{object_name} {{ "
"background: #f6f8fa; border: 1px solid #d0d7de; "
"border-radius: 6px; padding: 8px 10px; color: #24292f; "
"}"
)
label.setVisible(False)
return label
def _set_batch_progress_overview(label, tasks):
summary = _summarize_batch_progress(tasks)
active = summary["total"] > 0
label.setVisible(active)
label.setText(_format_batch_progress(summary) if active else "")
def _database_path(db_path=None, config=None) -> str:
return db_path or appconfig.db_path(config)
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
def _safe_create_run_log(run_type, db_path=None, dry_run=False, total=0, options=None):
try:
return db.create_run_log(
run_type,
dry_run=dry_run,
total=total,
options=options or {},
path=db_path,
)
except Exception:
return None
def _safe_finish_run_log(run_id, db_path=None, **fields):
if run_id is None:
return
try:
db.finish_run_log(run_id, path=db_path, **fields)
except Exception:
return
def _safe_add_run_log_event(
run_id,
message,
db_path=None,
task=None,
account=None,
level="info",
task_id=None,
alias=None,
item_id=None,
):
safe_message = diagnostics.redact_log_text(message)
if task is not None:
task_id = getattr(task, "id", task_id)
alias = getattr(task, "alias", alias)
item_id = getattr(task, "item_id", item_id)
if account is not None:
alias = getattr(account, "alias", alias)
if run_id is None:
return safe_message
try:
db.add_run_log_event(
run_id,
safe_message,
task_id=task_id,
alias=alias,
item_id=item_id,
level=level,
path=db_path,
)
except Exception:
pass
return safe_message
def _safe_write_diagnostic_log(
message,
level="INFO",
step=None,
task=None,
account=None,
elapsed_ms=None,
payload=None,
exc=None,
log_dir=None,
):
try:
diagnostics.write_diagnostic_log(
message,
level=level,
step=step,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None) or getattr(account, "alias", None),
item_id=getattr(task, "item_id", None),
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=log_dir,
)
except Exception:
return
def _elapsed_ms(started):
return int((time.monotonic() - started) * 1000)
class TaskTableModel(QAbstractTableModel):
"""Table model for task rows shared by workflow tabs."""
HEADERS = ["账号", "别名", "商品ID", "阶段"]
STAGE_TEXT = {
"imported": "待采集",
"collected": "已采集",
"generated": "已生成",
"applied": "已更新",
}
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
}
def __init__(self, parent=None):
super().__init__(parent)
self.tasks = []
self.all_tasks = []
self.account_by_alias = {}
self.filter_mode = "all"
def set_tasks(self, tasks, accounts):
self.beginResetModel()
self.all_tasks = list(tasks)
self.account_by_alias = {
str(account.alias).strip(): account
for account in accounts
if str(account.alias).strip()
}
self.tasks = self._filtered_tasks()
self.endResetModel()
def set_filter_mode(self, mode):
self.beginResetModel()
self.filter_mode = mode if mode in {"all", "unmatched"} else "all"
self.tasks = self._filtered_tasks()
self.endResetModel()
def _filtered_tasks(self):
if self.filter_mode == "unmatched":
return [task for task in self.all_tasks if self.is_unmatched(task)]
return list(self.all_tasks)
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.tasks)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.HEADERS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal and 0 <= section < len(self.HEADERS):
return self.HEADERS[section]
return section + 1 if orientation == Qt.Vertical else None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
task = self.tasks[index.row()]
if role == Qt.DisplayRole:
return self._display_value(task, index.column())
if role == Qt.ForegroundRole and index.column() == 3:
return self._stage_color(task)
if role == Qt.ToolTipRole and self.is_unmatched(task):
return "别名未匹配账号,采集时将略过"
return None
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def is_unmatched(self, task) -> bool:
return str(task.alias).strip() not in self.account_by_alias
def unmatched_count(self) -> int:
return sum(1 for task in self.all_tasks if self.is_unmatched(task))
def _account_name(self, task) -> str:
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
return account.account_name
return task.account_name or ""
def _stage_text(self, task) -> str:
if self.is_unmatched(task):
return "略过"
if task.status in self.STATUS_TEXT and task.status != "pending":
return self.STATUS_TEXT[task.status]
return self.STAGE_TEXT.get(task.stage, task.stage)
def _stage_color(self, task):
if self.is_unmatched(task):
return _qcolor(COLOR_MUTED)
base_color = _status_base_color(getattr(task, "status", None))
if base_color is not None:
return _qcolor(base_color)
if getattr(task, "stage", None) in {"collected", "generated", "applied"}:
return _qcolor(COLOR_SUCCESS)
return _qcolor(COLOR_PENDING)
def _display_value(self, task, column):
values = [
self._account_name(task),
task.alias,
task.item_id,
self._stage_text(task),
]
return values[column] if 0 <= column < len(values) else None
class GenerateTaskTableModel(QAbstractTableModel):
"""Table model for Tab 2 generation candidates."""
HEADERS = ["店铺", "商品ID", "旧标题", "新标题", "状态"]
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
}
STAGE_TEXT = {
"imported": "未采集",
"collected": "待生成",
"generated": "已生成",
"applied": "已更新",
}
def __init__(self, parent=None, db_path=None, status_callback=None):
super().__init__(parent)
self.tasks = []
self.account_by_alias = {}
self.db_path = db_path
self.status_callback = status_callback
self.last_edit_error = None
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 in (Qt.DisplayRole, Qt.EditRole):
return self._display_value(task, index.column())
if role == Qt.ForegroundRole and index.column() == 4:
return self._status_color(task)
if role == Qt.ToolTipRole:
if index.column() == 3 and self._can_edit_title(task):
return "双击可微调新标题,只修改本地待更新内容"
if task.last_error:
return task.last_error
return None
def setData(self, index, value, role=Qt.EditRole):
if role != Qt.EditRole or not index.isValid() or index.column() != 3:
return False
task = self.tasks[index.row()]
if not self._can_edit_title(task):
self._set_status("该任务不能修改新标题")
return False
title = str(value or "").strip()
if title == str(task.new_title or ""):
return True
try:
db.update_generated_title(task.id, title, path=self.db_path)
updated = db.get_task(task.id, path=self.db_path)
except Exception as exc:
self.last_edit_error = str(exc)
self._set_status(f"新标题修改失败:{exc}")
return False
self.tasks[index.row()] = updated
self.last_edit_error = None
self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole, Qt.ToolTipRole])
self._set_status(f"已修改新标题:商品 {task.item_id}")
return True
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
if index.column() == 3 and self._can_edit_title(self.tasks[index.row()]):
flags |= Qt.ItemIsEditable
return flags
def _can_edit_title(self, task):
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) != "running"
and int(getattr(task, "committed", 0) or 0) == 0
and bool(getattr(task, "new_title", None))
)
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def account_name_for(self, task):
return self._account_name(task)
def _account_name(self, task):
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
return account.account_name
return task.account_name or task.alias or ""
def _status_text(self, task):
if task.status in self.STATUS_TEXT and task.status != "pending":
return self.STATUS_TEXT[task.status]
return self.STAGE_TEXT.get(task.stage, task.stage)
def _status_color(self, task):
base_color = _status_base_color(getattr(task, "status", None))
if base_color is not None:
return _qcolor(base_color)
if getattr(task, "stage", None) in {"generated", "applied"}:
return _qcolor(COLOR_SUCCESS)
return _qcolor(COLOR_PENDING)
def _display_value(self, task, column):
values = [
self._account_name(task),
task.item_id,
task.old_title or "",
task.new_title or "",
self._status_text(task),
]
return values[column] if 0 <= column < len(values) else None
class 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.ForegroundRole:
if index.column() == 4:
return self._stage_color(task)
if index.column() == 5:
return self._result_color(task)
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)
def _stage_color(self, task):
if getattr(task, "stage", None) == "applied":
return _qcolor(COLOR_SUCCESS)
return _qcolor(COLOR_PENDING)
def _result_color(self, task):
base_color = _status_base_color(getattr(task, "status", None))
if base_color is not None:
return _qcolor(base_color)
if getattr(task, "status", None) == "pending":
return _qcolor(COLOR_PENDING)
if getattr(task, "status", None) == "success" and getattr(task, "stage", None) == "generated":
return _qcolor(COLOR_PENDING)
if getattr(task, "stage", None) == "applied" or getattr(task, "status", None) == "success":
return _qcolor(COLOR_SUCCESS)
return _qcolor(COLOR_PENDING)
class GenerateTab(QWidget):
"""Tab 2: prompt area plus generation task filters/list."""
STATUS_FILTERS = [
("全部状态", "all"),
("待生成", "to_generate"),
("已生成", "generated"),
("失败", "failed"),
("略过", "skipped"),
("已更新", "applied"),
]
def __init__(
self,
parent=None,
db_path=None,
config=None,
config_path=None,
status_callback=None,
title_prompt_path=None,
cover_prompts_dir=None,
open_accounts_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
self.config_path = (
config_path
or self.config.get("config_path")
or appconfig.CONFIG_PATH
)
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.title_prompt_path = title_prompt_path or prompts.TITLE_PROMPT_PATH
self.cover_prompts_dir = cover_prompts_dir or prompts.COVER_PROMPTS_DIR
self.current_cover_template = None
self.generate_worker = None
self.generate_thread = None
self.title_prompt_edit = QPlainTextEdit()
self.title_prompt_edit.setObjectName("titlePromptEdit")
self.title_prompt_edit.setPlaceholderText("标题提示词")
self.title_prompt_edit.setPlainText(
prompts.load_title_prompt(self.title_prompt_path)
)
self.save_title_button = QPushButton("保存标题提示词")
self.cover_prompt_edit = QPlainTextEdit()
self.cover_prompt_edit.setObjectName("coverPromptEdit")
self.cover_prompt_edit.setPlaceholderText("封面提示词")
self.cover_template_combo = QComboBox()
self.cover_template_combo.setObjectName("coverTemplateCombo")
self.new_cover_template_button = QPushButton("新建")
self.save_cover_template_button = QPushButton("保存")
self.cover_template_actions_button = QPushButton("模板操作")
self.cover_template_actions_button.setObjectName("coverTemplateActionsButton")
self.cover_template_actions_menu = QMenu(self)
self.save_cover_template_as_action = self.cover_template_actions_menu.addAction("另存为")
self.save_cover_template_as_action.setObjectName("saveCoverTemplateAsAction")
self.rename_cover_template_action = self.cover_template_actions_menu.addAction("重命名")
self.rename_cover_template_action.setObjectName("renameCoverTemplateAction")
self.delete_cover_template_action = self.cover_template_actions_menu.addAction("删除")
self.delete_cover_template_action.setObjectName("deleteCoverTemplateAction")
self.cover_template_actions_button.setMenu(self.cover_template_actions_menu)
self.insert_title_button = QPushButton("插入标题")
self.preview_prompt_button = QPushButton("预览")
self.generate_button = QPushButton("开始生成")
self.stop_generate_button = QPushButton("停止")
self.reset_generate_button = QPushButton("重置生成结果")
self.reset_generate_button.setObjectName("resetGenerateButton")
self.stop_generate_button.setEnabled(False)
ai_settings = appconfig.ai_config(self.config)
self.generate_cover_checkbox = QCheckBox("生成封面图片(成本较高)")
self.generate_cover_checkbox.setObjectName("generateCoverCheckbox")
self.generate_cover_checkbox.setToolTip("关闭后只生成标题并保存为可更新,不调用图片模型")
self.generate_cover_checkbox.setChecked(bool(ai_settings.get("generate_cover", False)))
self.progress_label = QLabel("进度:标题0/0 · 图片0/0 · 失败0")
self.title_progress_label = QLabel("标题 0/0")
self.title_progress_label.setObjectName("generateTitleProgressLabel")
self.title_progress_bar = QProgressBar()
self.title_progress_bar.setObjectName("generateTitleProgressBar")
self.title_progress_bar.setTextVisible(False)
self.title_progress_bar.setRange(0, 1)
self.title_progress_bar.setValue(0)
self.cover_progress_label = QLabel("图片 0/0")
self.cover_progress_label.setObjectName("generateCoverProgressLabel")
self.cover_progress_bar = QProgressBar()
self.cover_progress_bar.setObjectName("generateCoverProgressBar")
self.cover_progress_bar.setTextVisible(False)
self.cover_progress_bar.setRange(0, 1)
self.cover_progress_bar.setValue(0)
self.failed_progress_label = QLabel("失败 0")
self.failed_progress_label.setObjectName("generateFailedProgressLabel")
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(0, 0, 12, 0)
left_layout.addWidget(QLabel("标题提示词"))
left_layout.addWidget(self.title_prompt_edit, 1)
left_layout.addWidget(self.save_title_button)
left_layout.addWidget(QLabel("封面提示词"))
left_layout.addWidget(self.cover_template_combo)
cover_template_layout = QHBoxLayout()
cover_template_layout.addWidget(self.new_cover_template_button)
cover_template_layout.addWidget(self.save_cover_template_button)
cover_template_layout.addWidget(self.cover_template_actions_button)
cover_template_layout.addStretch(1)
left_layout.addLayout(cover_template_layout)
left_layout.addWidget(self.cover_prompt_edit, 2)
cover_action_layout = QHBoxLayout()
cover_action_layout.addWidget(self.insert_title_button)
cover_action_layout.addWidget(self.preview_prompt_button)
left_layout.addLayout(cover_action_layout)
self.batch_filter = QComboBox()
self.batch_filter.setObjectName("batchFilter")
self.shop_filter = QComboBox()
self.shop_filter.setObjectName("shopFilter")
self.item_filter = QLineEdit()
self.item_filter.setObjectName("generateItemFilter")
self.item_filter.setPlaceholderText("商品ID")
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)
filter_layout.addWidget(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
self.batch_progress_label = _build_batch_progress_overview("generateBatchProgressOverview")
(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
) = _build_empty_state_card("generateEmptyStateCard")
if self.open_accounts_callback is not None:
self.empty_state_button.clicked.connect(self.open_accounts_callback)
self.task_table = QTableView()
self.model = GenerateTaskTableModel(self.task_table, db_path=self.db_path, status_callback=self._set_status)
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.task_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed)
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生成运行日志")
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.batch_progress_label)
right_layout.addWidget(self.empty_state_card)
right_layout.addWidget(self.task_table, 1)
right_layout.addWidget(QLabel("AI生成运行日志"))
right_layout.addWidget(self.run_log_view)
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])
title_progress_layout = QHBoxLayout()
title_progress_layout.addWidget(self.title_progress_label)
title_progress_layout.addWidget(self.title_progress_bar, 1)
cover_progress_layout = QHBoxLayout()
cover_progress_layout.addWidget(self.cover_progress_label)
cover_progress_layout.addWidget(self.cover_progress_bar, 1)
cover_progress_layout.addWidget(self.failed_progress_label)
progress_layout = QVBoxLayout()
progress_layout.addLayout(title_progress_layout)
progress_layout.addLayout(cover_progress_layout)
button_layout = QHBoxLayout()
button_layout.addStretch(1)
button_layout.addWidget(self.generate_button)
button_layout.addWidget(self.stop_generate_button)
button_layout.addWidget(self.reset_generate_button)
bottom_layout = QHBoxLayout()
bottom_layout.addWidget(self.generate_cover_checkbox)
bottom_layout.addLayout(progress_layout, 1)
bottom_layout.addLayout(button_layout)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addWidget(self.splitter, 1)
layout.addLayout(bottom_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)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.save_title_button.clicked.connect(self.save_title_prompt)
self.cover_template_combo.currentIndexChanged.connect(self.load_selected_cover_template)
self.new_cover_template_button.clicked.connect(self.new_cover_template)
self.save_cover_template_button.clicked.connect(self.save_cover_template)
self.save_cover_template_as_action.triggered.connect(self.save_cover_template_as)
self.rename_cover_template_action.triggered.connect(self.rename_cover_template)
self.delete_cover_template_action.triggered.connect(self.delete_cover_template)
self.insert_title_button.clicked.connect(self.insert_title_placeholder)
self.preview_prompt_button.clicked.connect(self.preview_cover_prompt)
self.generate_cover_checkbox.toggled.connect(self._on_generate_cover_toggled)
self.generate_button.clicked.connect(self.start_generate)
self.stop_generate_button.clicked.connect(self.stop_generate)
self.reset_generate_button.clicked.connect(self.reset_generated_result)
self.task_table.doubleClicked.connect(self.show_task_images)
self.refresh_cover_templates()
self.refresh_tasks()
self._load_latest_generate_run_log()
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))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def _on_generate_cover_toggled(self, checked):
previous = bool(appconfig.ai_config(self.config).get("generate_cover", False))
if self._save_generate_cover_setting(show_status=True):
return
self.generate_cover_checkbox.blockSignals(True)
self.generate_cover_checkbox.setChecked(previous)
self.generate_cover_checkbox.blockSignals(False)
def _save_generate_cover_setting(self, show_status=True):
generate_cover = bool(self.generate_cover_checkbox.isChecked())
ai_settings = appconfig.ai_config(self.config)
ai_settings["generate_cover"] = generate_cover
payload = {
key: value
for key, value in self.config.items()
if key not in {"config_path", "ai_models_path"}
}
payload["ai"] = ai_settings
try:
saved = appconfig.save_config(payload, path=self.config_path)
except Exception as exc:
self._set_status(f"生成封面开关保存失败:{exc}")
return False
internal = {
key: value
for key, value in self.config.items()
if key in {"config_path", "ai_models_path"}
}
self.config.clear()
self.config.update(saved)
self.config.update(internal)
if self.config_path != appconfig.CONFIG_PATH:
self.config["config_path"] = self.config_path
if show_status:
mode = "会同时生成封面图片" if generate_cover else "只生成标题,不生成图片"
self._set_status(f"AI生成设置已保存:{mode}")
return True
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 reversed(events)
]
self.run_log_view.setPlainText("\n".join(lines))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def save_title_prompt(self, checked=False):
try:
prompts.save_title_prompt(
self.title_prompt_edit.toPlainText(),
self.title_prompt_path,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self._set_status("标题提示词已保存")
def refresh_cover_templates(self, selected=None):
try:
template_names = prompts.list_cover_templates(self.cover_prompts_dir)
except Exception as exc:
template_names = []
self._show_prompt_error(exc)
current = selected if selected is not None else self.current_cover_template
self.cover_template_combo.blockSignals(True)
self.cover_template_combo.clear()
if template_names:
for name in template_names:
self.cover_template_combo.addItem(name, name)
index = self.cover_template_combo.findData(current)
self.cover_template_combo.setCurrentIndex(index if index >= 0 else 0)
else:
self.cover_template_combo.addItem("默认", None)
self.cover_template_combo.setCurrentIndex(0)
self.cover_template_combo.blockSignals(False)
self.load_selected_cover_template()
def load_selected_cover_template(self, index=None):
name = self.cover_template_combo.currentData()
self.current_cover_template = name
if name is None:
self.cover_prompt_edit.setPlainText("")
return
try:
self.cover_prompt_edit.setPlainText(
prompts.load_cover_template(name, self.cover_prompts_dir)
)
except Exception as exc:
self.cover_prompt_edit.setPlainText("")
self._show_prompt_error(exc)
def new_cover_template(self, checked=False):
name = self._ask_template_name("新建封面提示词模板")
if not name:
return
try:
prompts.save_cover_template(name, "", self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=name)
self._set_status(f"封面提示词模板已新建:{name}")
def save_cover_template(self, checked=False):
name = self.current_cover_template
if name is None:
self.save_cover_template_as()
return
try:
prompts.save_cover_template(
name,
self.cover_prompt_edit.toPlainText(),
self.cover_prompts_dir,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self._set_status(f"封面提示词模板已保存:{name}")
def save_cover_template_as(self, checked=False):
name = self._ask_template_name("另存封面提示词模板")
if not name:
return
try:
prompts.save_cover_template(
name,
self.cover_prompt_edit.toPlainText(),
self.cover_prompts_dir,
)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=name)
self._set_status(f"封面提示词模板已另存为:{name}")
def rename_cover_template(self, checked=False):
old_name = self.current_cover_template
if old_name is None:
self._set_status("没有可重命名的封面提示词模板")
return
new_name = self._ask_template_name("重命名封面提示词模板", text=old_name)
if not new_name or new_name == old_name:
return
try:
prompts.rename_cover_template(old_name, new_name, self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates(selected=new_name)
self._set_status(f"封面提示词模板已重命名:{new_name}")
def delete_cover_template(self, checked=False):
name = self.current_cover_template
if name is None:
self._set_status("没有可删除的封面提示词模板")
return
choice = QMessageBox.question(
self,
"删除封面提示词模板",
f"确定删除「{name}」吗?",
)
if choice != QMessageBox.Yes:
return
try:
prompts.delete_cover_template(name, self.cover_prompts_dir)
except Exception as exc:
self._show_prompt_error(exc)
return
self.refresh_cover_templates()
self._set_status(f"封面提示词模板已删除:{name}")
def insert_title_placeholder(self, checked=False):
self.cover_prompt_edit.insertPlainText("{新标题}")
def preview_cover_prompt(self, checked=False):
task = self._selected_task()
if task is None:
self._set_status("没有可预览的任务")
return
rendered = prompts.render_prompt(
self.cover_prompt_edit.toPlainText(),
self._prompt_context(task),
)
QMessageBox.information(self, "封面提示词预览", rendered)
self._set_status("封面提示词预览已生成")
def start_generate(self, checked=False):
if self.generate_thread is not None:
self._set_status("AI 生成正在进行...")
return
if not self._save_generate_cover_setting(show_status=False):
return
generate_cover = bool(self.generate_cover_checkbox.isChecked())
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,
)
worker.progress.connect(self._on_generate_progress)
worker.row_updated.connect(self._on_generate_row_updated)
worker.log.connect(self._on_generate_log)
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()
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,
"cover_total": len(tasks) if generate_cover else 0,
"generated_done": 0,
"failed": 0,
"generate_cover": generate_cover,
}
)
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._append_generate_log("[停止] 已收到停止请求,当前正在运行的任务结束后停止")
self._set_status("正在停止 AI 生成...")
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}"
)
def show_task_images(self, index):
if index.isValid() and index.column() == 3:
return
task = self.model.task_at(index.row()) if index.isValid() else self._selected_task()
if task is None:
self._set_status("没有可预览的任务")
return
dialog = QDialog(self)
dialog.setWindowTitle(f"封面对照:{task.item_id}")
layout = QVBoxLayout(dialog)
images_layout = QHBoxLayout()
images_layout.addWidget(self._image_panel("旧封面", task.old_cover_path))
images_layout.addWidget(self._image_panel("新封面", task.new_cover_path))
layout.addLayout(images_layout)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons.rejected.connect(dialog.reject)
layout.addWidget(buttons)
dialog.resize(720, 420)
dialog.exec()
def _image_panel(self, title, path):
panel = QWidget()
layout = QVBoxLayout(panel)
layout.addWidget(QLabel(title))
image_label = QLabel()
image_label.setAlignment(Qt.AlignCenter)
image_label.setMinimumSize(260, 260)
image_label.setWordWrap(True)
if path and os.path.exists(str(path)):
pixmap = QPixmap(str(path))
if not pixmap.isNull():
image_label.setPixmap(
pixmap.scaled(
260,
260,
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
)
else:
image_label.setText(f"图片无法读取\n{path}")
else:
image_label.setText(f"无图片\n{path or ''}".strip())
layout.addWidget(image_label, 1)
return panel
def _set_generate_running(self, running):
self.generate_button.setEnabled(not running)
self.stop_generate_button.setEnabled(running)
self.reset_generate_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.save_title_button.setEnabled(not running)
self.new_cover_template_button.setEnabled(not running)
self.save_cover_template_button.setEnabled(not running)
self.cover_template_actions_button.setEnabled(not running)
self.save_cover_template_as_action.setEnabled(not running)
self.rename_cover_template_action.setEnabled(not running)
self.delete_cover_template_action.setEnabled(not running)
self.generate_cover_checkbox.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()
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()
self._update_generate_progress(payload)
self._set_status("AI 生成已停止:" + self._generate_progress_text(payload))
def _update_generate_progress(self, payload):
total = max(0, int(payload.get("total", 0) or 0))
title_done = max(0, int(payload.get("title_done", 0) or 0))
cover_done = max(0, int(payload.get("cover_done", 0) or 0))
cover_total = self._cover_total_for_progress(payload, total)
failed = max(0, int(payload.get("failed", 0) or 0))
self.progress_label.setText("进度:" + self._generate_progress_text(payload))
self.title_progress_label.setText(f"标题 {title_done}/{total}")
self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}")
self.failed_progress_label.setText(f"失败 {failed}")
self._set_progress_bar(self.title_progress_bar, title_done, total)
self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total)
def _set_progress_bar(self, bar, done, total):
maximum = max(1, int(total or 0))
value = min(max(0, int(done or 0)), maximum)
bar.setRange(0, maximum)
bar.setValue(value)
def _cover_total_for_progress(self, payload, total):
cover_total = payload.get("cover_total")
if cover_total is None:
cover_total = total if payload.get("generate_cover", True) else 0
return max(0, int(cover_total or 0))
def _generate_progress_text(self, payload):
total = max(0, int(payload.get("total", 0) or 0))
cover_total = self._cover_total_for_progress(payload, total)
return "标题{title}/{total} · 图片{cover}/{cover_total} · 失败{failed}".format(
title=payload.get("title_done", 0),
cover=payload.get("cover_done", 0),
cover_total=cover_total,
total=payload.get("total", 0),
failed=payload.get("failed", 0),
)
def _selected_task(self):
index = self.task_table.currentIndex()
if index.isValid():
return self.model.task_at(index.row())
if self.model.rowCount() > 0:
return self.model.task_at(0)
return None
def _prompt_context(self, task):
return {
"old_title": task.old_title,
"new_title": task.new_title,
"item_id": task.item_id,
"account_name": self.model.account_name_for(task),
"alias": task.alias,
}
def _ask_template_name(self, title, text=""):
value, ok = QInputDialog.getText(
self,
title,
"模板名",
QLineEdit.Normal,
text,
)
if not ok:
return None
return str(value).strip()
def _show_prompt_error(self, error):
message = str(error)
QMessageBox.warning(self, "提示词管理", message)
self._set_status(message)
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
batches = db.list_batches(path=self.db_path)
accounts_rows = accounts.list_accounts(path=self.db_path, config=self.config)
selected_batch = self.batch_filter.currentData()
selected_shop = self.shop_filter.currentData()
selected_status = self.status_filter.currentData() or "all"
item_query = self.item_filter.text().strip()
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)
and self._matches_status(task, selected_status)
]
except Exception as exc:
self.model.set_tasks([], [])
self.summary_label.setText("任务读取失败")
_set_batch_progress_overview(self.batch_progress_label, [])
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
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)} 条"
)
_set_batch_progress_overview(self.batch_progress_label, batch_tasks)
self._update_empty_state(batch_tasks, filtered_tasks, accounts_rows)
def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows):
if not account_rows:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"第一步:前往『④账号管理』配置并登录账号,再回到②生成标题和封面。",
self.open_accounts_callback is not None,
)
return
if not batch_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"还没有可生成任务。请先在①导入采集完成旧标题和旧封面采集。",
)
return
if not filtered_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"当前筛选没有匹配的生成任务,请调整批次、店铺、商品ID或状态筛选。",
)
return
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
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", ""))
def _matches_status(self, task, selected_status):
if selected_status in (None, "all"):
return True
if selected_status == "to_generate":
return task.stage == "collected" and task.status in {"success", "pending"}
if selected_status == "generated":
return task.stage == "generated"
if selected_status == "applied":
return task.stage == "applied"
if selected_status == "failed":
return task.status == "failed"
if selected_status == "skipped":
return task.status == "skipped"
return True
class ApplyTab(QWidget):
"""Tab 3: list generated tasks and confirm the update scope."""
STATUS_FILTERS = [
("已生成", "generated"),
("失败", "failed"),
("已更新", "applied"),
("略过", "skipped"),
("全部状态", "all"),
]
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
open_accounts_callback=None,
open_settings_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.open_settings_callback = open_settings_callback
self.apply_worker = None
self.apply_thread = None
self.result_write_back_worker = None
self.result_write_back_thread = None
self.last_apply_summary = None
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")
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)
filter_layout.addWidget(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
self.batch_progress_label = _build_batch_progress_overview("applyBatchProgressOverview")
self.risk_label = QLabel("可先点击「检查本轮更新」确认当前筛选范围;点击「开始更新」后会再次确认并按批提交线上。")
(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
) = _build_empty_state_card("applyEmptyStateCard")
if self.open_accounts_callback is not None:
self.empty_state_button.clicked.connect(self.open_accounts_callback)
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.setContextMenuPolicy(Qt.CustomContextMenu)
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False)
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("运行日志")
self.preview_update_button = QPushButton("检查本轮更新")
self.preview_update_button.setObjectName("previewUpdateButton")
self.start_update_button = QPushButton("开始更新")
self.start_update_button.setObjectName("startUpdateButton")
self.start_update_button.setMinimumWidth(118)
self.start_update_button.setStyleSheet(
"QPushButton#startUpdateButton { "
"font-weight: 600; padding: 6px 16px; "
f"color: {COLOR_WARNING}; border: 1px solid {COLOR_WARNING}; "
"border-radius: 4px; }"
)
self.stop_update_button = QPushButton("停止")
self.reset_update_button = QPushButton("重置更新状态")
self.reset_update_button.setObjectName("resetUpdateButton")
self.reset_update_button.setVisible(False)
self.write_back_button = QPushButton("回写结果到 Excel")
self.stop_update_button.setEnabled(False)
self.write_back_button.setEnabled(False)
action_layout = QHBoxLayout()
action_layout.addWidget(self.preview_update_button)
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
action_layout.addStretch(1)
action_layout.addWidget(self.write_back_button)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(filter_layout)
layout.addWidget(self.risk_label)
layout.addWidget(self.summary_label)
layout.addWidget(self.batch_progress_label)
layout.addWidget(self.empty_state_card)
layout.addWidget(self.task_table, 1)
layout.addWidget(QLabel("运行日志"))
layout.addWidget(self.run_log_view)
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)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.preview_update_button.clicked.connect(self.preview_update)
self.start_update_button.clicked.connect(self.start_update)
self.stop_update_button.clicked.connect(self.stop_update)
self.reset_update_button.clicked.connect(self.reset_apply_status)
self.task_table.customContextMenuRequested.connect(self.show_task_context_menu)
self.write_back_button.clicked.connect(self.write_back_results)
self.refresh_tasks()
self._load_latest_run_log()
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()
self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData()
all_batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path)
batch_tasks = [
task for task in all_batch_tasks
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)
and self._matches_status(task, selected_status)
]
except Exception as exc:
self.model.set_tasks([], [])
self.summary_label.setText("任务读取失败")
_set_batch_progress_overview(self.batch_progress_label, [])
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
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)} 条"
)
_set_batch_progress_overview(self.batch_progress_label, all_batch_tasks)
self._update_empty_state(batch_tasks, filtered_tasks, account_rows)
self._update_write_back_button()
def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows):
if not account_rows:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"第一步:前往『④账号管理』配置并登录账号,再回到③更新 Shopee。",
self.open_accounts_callback is not None,
)
return
if not batch_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"还没有可更新任务。请先在②AI生成完成新标题或新封面。",
)
return
if not filtered_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"当前筛选没有可更新任务,请调整批次、店铺、商品ID或状态筛选。",
)
return
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
def start_update(self, checked=False):
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):
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)
]
if not tasks:
self._set_status("当前筛选结果没有可更新任务")
return
update_cfg = self._shopee_update_config()
dry_run = bool(dry_run)
safety_error = self._update_safety_error(tasks, dry_run=dry_run)
if safety_error:
self._show_update_safety_error(safety_error)
self._set_status(safety_error.replace("\n", " "))
return
answer = QMessageBox.question(
self,
"确认检查本轮更新" if dry_run else "确认开始更新",
self._confirmation_message(tasks, dry_run=dry_run),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新")
return
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
worker = ApplyWorker(
tasks,
db_path=self.db_path,
config=self.config,
close_success_tab=bool(update_cfg.get("close_success_tab", False)),
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),
),
batch_size=batch_size,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
worker.progress.connect(self._on_apply_progress)
worker.row_updated.connect(self._on_apply_row_updated)
worker.log.connect(self._on_apply_log)
worker.failed.connect(self._on_apply_failed)
worker.finished.connect(self._on_apply_finished)
worker.cancelled.connect(self._on_apply_cancelled)
thread = run_worker(worker, thread_name="ApplyWorker", start=False)
thread.finished.connect(lambda: self._forget_apply_thread(thread))
self.apply_worker = worker
self.apply_thread = thread
self._set_apply_running(True)
self.run_log_view.clear()
if dry_run:
self._set_status(f"开始检查本轮更新:{len(tasks)} 条")
else:
self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行")
thread.start()
def stop_update(self, checked=False):
if self.apply_worker is not None:
self.apply_worker.cancel()
self._set_status("正在停止更新...")
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)
def show_task_context_menu(self, position):
index = self.task_table.indexAt(position)
if index.isValid():
self.task_table.setCurrentIndex(index)
menu = QMenu(self)
reset_action = menu.addAction("重置更新状态")
reset_action.setEnabled(
self.apply_thread is None
and self.result_write_back_thread is None
and self._selected_task() is not None
)
reset_action.triggered.connect(self.reset_apply_status)
menu.exec(self.task_table.viewport().mapToGlobal(position))
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
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 _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 "全部商品"
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", ""))
def _matches_status(self, task, selected_status):
if selected_status in (None, "all"):
return True
if selected_status == "generated":
return task.stage == "generated" and task.status in {"success", "pending"}
if selected_status == "failed":
return task.status == "failed"
if selected_status == "applied":
return task.stage == "applied"
if selected_status == "skipped":
return task.status == "skipped"
return True
def _confirmation_message(self, tasks, dry_run=False):
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 "否"
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
parallel_text = (
f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号"
if update_cfg.get("parallel_accounts")
else "关闭"
)
intro = (
"即将检查当前筛选结果。\n\n"
if dry_run
else "即将按当前筛选结果分批更新 Shopee 线上商品。\n\n"
)
return (
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"
+ (
"检查只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态。"
if dry_run
else f"确认后会打开商品编辑页、替换标题/允许时替换封面,并按每批最多 {batch_size} 条点击「更新」提交线上;点击停止后不再开始下一条或下一批。"
)
)
def _update_safety_error(self, tasks, dry_run=False):
update_cfg = self._shopee_update_config()
if dry_run:
return None
if not update_cfg.get("allow_real_submit", False):
return (
"设置未开启「允许真实提交线上商品」,已阻止本次更新。\n"
"请到⑤设置 > Shopee 更新安全开启该开关后再开始更新。"
)
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 (
"设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。\n"
"请到⑤设置 > Shopee 更新安全开启该开关,或先筛掉含新封面的任务。"
)
return None
def _show_update_safety_error(self, message):
box = QMessageBox(self)
box.setIcon(QMessageBox.Warning)
box.setWindowTitle("更新安全开关")
box.setText(str(message))
settings_button = None
if self.open_settings_callback is not None:
settings_button = box.addButton("前往设置", QMessageBox.ActionRole)
box.addButton(QMessageBox.Ok)
box.exec()
if settings_button is not None and box.clickedButton() is settings_button:
self.open_settings_callback()
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 _set_apply_running(self, running):
self.preview_update_button.setEnabled(not running)
self.start_update_button.setEnabled(not running)
self.stop_update_button.setEnabled(running)
self.reset_update_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self._update_write_back_button()
def _set_result_write_back_running(self, running):
self.preview_update_button.setEnabled(not running)
self.start_update_button.setEnabled(not running)
self.reset_update_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids()))
def _forget_apply_thread(self, thread):
if self.apply_thread is thread:
self.apply_thread = None
self.apply_worker = None
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()
def _on_apply_progress(self, payload):
self._set_status("更新进度:" + self._apply_progress_text(payload))
def _on_apply_log(self, message):
self._append_run_log(message)
self._set_status(message)
def _on_apply_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_apply_failed(self, task_id, error):
self._set_status(f"任务 {task_id} 更新失败:{error}")
def _on_apply_finished(self, payload):
self._set_apply_running(False)
self.refresh_tasks()
if payload.get("blocked"):
self._show_apply_blocked(payload)
return
self.last_apply_summary = dict(payload)
prefix = "检查本轮更新完成:" if payload.get("dry_run") else "更新完成:"
message = prefix + self._apply_progress_text(payload)
batch_ids = payload.get("batch_ids") or self._active_batch_ids()
if (not payload.get("dry_run")) and payload.get("done", 0) > 0 and batch_ids:
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)
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):
success_label = "可更新" if payload.get("dry_run") else "成功"
return "完成{done}/{total},{success_label}{applied},略过{skipped},失败{failed}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
success_label=success_label,
applied=payload.get("applied", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
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 reversed(events)
]
self.run_log_view.setPlainText("\n".join(lines))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def _show_apply_blocked(self, payload):
lines = ["更新前检查未通过。"]
if payload.get("no_accounts"):
lines.append("当前没有配置账号。")
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 []),
)
)
not_running = payload.get("not_running") or []
if not_running:
lines.append(
"以下账号 Chrome 未启动或调试端口不可访问:"
+ "、".join(self._account_label(item) for item in not_running)
)
logged_out = payload.get("logged_out") or []
if logged_out:
lines.append(
"以下账号未登录 Shopee:"
+ "、".join(self._account_label(item) for item in logged_out)
)
self._show_account_guide("\n".join(lines))
def _show_account_guide(self, message):
full_message = (
f"{message}\n\n"
"本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n"
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。"
)
QMessageBox.warning(self, "账号未就绪", full_message)
self._set_status(full_message.replace("\n", " "))
if self.open_accounts_callback is not None:
self.open_accounts_callback()
def _account_label(self, item):
if isinstance(item, dict):
name = item.get("account_name") or item.get("alias") or ""
alias = item.get("alias") or ""
reason = item.get("reason")
else:
name = getattr(item, "account_name", "") or getattr(item, "alias", "")
alias = getattr(item, "alias", "")
reason = getattr(item, "reason", None)
label = f"{name}({alias})" if alias and name != alias else (name or alias)
return f"{label}: {reason}" if reason else label
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",
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
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,
"检查本轮更新完成" if apply_summary.get("dry_run") else "更新完成",
self._apply_summary_message(apply_summary, write_back_payload),
)
def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None):
dry_run = bool(apply_summary.get("dry_run"))
lines = [
"检查本轮更新完成,未打开 Shopee、未提交线上、未改任务状态。"
if dry_run
else "更新完成。",
"{success_label}:{applied},失败:{failed},略过:{skipped}".format(
success_label="可更新" if dry_run else "成功",
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)
class CollectTab(QWidget):
"""Tab 1: import Excel files and list imported tasks."""
STATUS_FILTERS = [
("全部状态", "all"),
("待采集", "to_collect"),
("已采集", "collected"),
("已生成", "generated"),
("已更新", "applied"),
("失败", "failed"),
("略过", "skipped"),
]
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
open_accounts_callback=None,
refresh_workflow_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.refresh_workflow_callback = refresh_workflow_callback
self.current_batch_id = None
self.has_import_result = False
self.last_import_stats = None
self.collect_worker = None
self.collect_thread = None
self.write_back_worker = None
self.write_back_thread = None
self.last_collect_run_id = None
self.import_button = QPushButton("导入 Excel...")
self.refresh_button = QPushButton("刷新")
self.collect_button = QPushButton("采集旧标题/旧封面")
self.stop_collect_button = QPushButton("停止")
self.write_back_button = QPushButton("回写旧数据到 Excel")
self.stop_collect_button.setEnabled(False)
self.batch_filter = QComboBox()
self.batch_filter.setObjectName("collectBatchFilter")
self.shop_filter = QComboBox()
self.shop_filter.setObjectName("collectShopFilter")
self.item_filter = QLineEdit()
self.item_filter.setObjectName("collectItemFilter")
self.item_filter.setPlaceholderText("商品ID")
self.status_filter = QComboBox()
self.status_filter.setObjectName("collectStatusFilter")
for label, value in self.STATUS_FILTERS:
self.status_filter.addItem(label, value)
self.delete_batch_button = QPushButton("删除批次")
self.delete_batch_button.setObjectName("deleteBatchButton")
self.delete_batch_button.setStyleSheet(_danger_outline_button_style("deleteBatchButton"))
self.delete_batch_button.setEnabled(False)
toolbar = QHBoxLayout()
toolbar.addWidget(self.import_button)
toolbar.addWidget(self.refresh_button)
toolbar.addWidget(self.collect_button)
toolbar.addWidget(self.stop_collect_button)
toolbar.addWidget(self.write_back_button)
toolbar.addStretch(1)
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)
filter_layout.addWidget(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.delete_batch_button)
self.summary_label = QLabel("未导入任务")
self.summary_label.setTextFormat(Qt.RichText)
self.batch_progress_label = _build_batch_progress_overview("collectBatchProgressOverview")
self.match_detail_label = QLabel("")
self.show_all_button = QPushButton("全部")
self.show_unmatched_button = QPushButton("未匹配(0)")
self.show_unmatched_button.setObjectName("showUnmatchedButton")
summary_layout = QHBoxLayout()
summary_layout.addWidget(self.summary_label)
summary_layout.addStretch(1)
summary_layout.addWidget(self.show_all_button)
summary_layout.addWidget(self.show_unmatched_button)
self.table = QTableView()
self.model = TaskTableModel(self.table)
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.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("采集运行日志")
self.empty_label = QLabel("")
(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
) = _build_empty_state_card("collectEmptyStateCard")
if self.open_accounts_callback is not None:
self.empty_state_button.clicked.connect(self.open_accounts_callback)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(toolbar)
layout.addLayout(filter_layout)
layout.addLayout(summary_layout)
layout.addWidget(self.match_detail_label)
layout.addWidget(self.batch_progress_label)
layout.addWidget(self.empty_state_card)
layout.addWidget(self.table, 1)
layout.addWidget(QLabel("采集运行日志"))
layout.addWidget(self.run_log_view)
layout.addWidget(self.empty_label)
self.import_button.clicked.connect(self.import_excel)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
self.item_filter.textChanged.connect(self.refresh_tasks)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.delete_batch_button.clicked.connect(self.delete_current_batch)
self.collect_button.clicked.connect(self.collect_old_data)
self.stop_collect_button.clicked.connect(self.stop_collect)
self.write_back_button.clicked.connect(self.write_back_old_data)
self.show_all_button.clicked.connect(self.show_all_tasks)
self.show_unmatched_button.clicked.connect(self.show_unmatched_tasks)
self.refresh_tasks()
self._load_latest_collect_run_log()
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 reversed(events)
]
self.run_log_view.setPlainText("\n".join(lines))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
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)
def _show_error(self, message):
QMessageBox.warning(self, "导入采集", str(message))
self._set_status(str(message))
def _show_account_guide(self, message):
full_message = (
f"{message}\n\n"
"本轮采集已中止,不会自动打开账号 Chrome。\n"
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。"
)
QMessageBox.warning(self, "账号未就绪", full_message)
self._set_status(full_message.replace("\n", " "))
if self.open_accounts_callback is not None:
self.open_accounts_callback()
def _choose_excel_files(self):
files, _selected_filter = QFileDialog.getOpenFileNames(
self,
"选择 Excel 文件",
"",
"Excel 文件 (*.xlsx *.xlsm)",
)
return files
def import_excel(self, checked=False):
file_paths = self._choose_excel_files()
if not file_paths:
return
run_id = _safe_create_run_log(
"import",
db_path=self.db_path,
total=len(file_paths),
options={"files": file_paths},
)
started = time.monotonic()
_safe_add_run_log_event(
run_id,
f"step=select_files result=success detail=选择 Excel 文件 {len(file_paths)} 个",
db_path=self.db_path,
)
try:
_safe_add_run_log_event(
run_id,
"step=parse_file result=start detail=开始解析 Excel 并写入 SQLite",
db_path=self.db_path,
)
result = excel.import_tasks(file_paths, path=self.db_path)
except Exception as exc:
elapsed_ms = _elapsed_ms(started)
safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
_safe_add_run_log_event(
run_id,
f"step=import result=failed detail={safe_error} elapsed_ms={elapsed_ms}",
db_path=self.db_path,
level="error",
)
_safe_write_diagnostic_log(
"Excel导入失败",
level="ERROR",
step="import",
elapsed_ms=elapsed_ms,
payload={"files": file_paths, "error": safe_error},
exc=exc,
log_dir=diagnostics.DEFAULT_LOG_DIR,
)
_safe_finish_run_log(
run_id,
db_path=self.db_path,
status="failed",
done=0,
failed_count=1,
summary_json={"ok": False, "error": safe_error, "elapsed_ms": elapsed_ms},
)
self._show_error(safe_error)
return
elapsed_ms = _elapsed_ms(started)
self.has_import_result = True
self.last_import_stats = result.get("stats") or {}
self.current_batch_id = result.get("batch_id")
file_errors = self.last_import_stats.get("file_errors") or []
row_errors = self.last_import_stats.get("row_errors") or []
for item in file_errors:
missing = ",".join(item.get("missing_columns") or [])
detail = "file={file} sheet={sheet} error={error}{missing}".format(
file=os.path.basename(str(item.get("file") or "")),
sheet=item.get("sheet") or "",
error=item.get("error") or "",
missing=f" missing={missing}" if missing else "",
)
_safe_add_run_log_event(
run_id,
f"step=parse_file result=failed detail={detail}",
db_path=self.db_path,
level="error",
)
for item in row_errors:
detail = "file={file} sheet={sheet} row={row} error={error}".format(
file=os.path.basename(str(item.get("file") or "")),
sheet=item.get("sheet") or "",
row=item.get("row") or "",
error=item.get("error") or "",
)
_safe_add_run_log_event(
run_id,
f"step=row_validate result=failed detail={detail}",
db_path=self.db_path,
level="warning",
)
_safe_add_run_log_event(
run_id,
"step=db_insert result=success detail=batch_id={batch_id} files={files} total={total} valid={valid} invalid={invalid} inserted={inserted} elapsed_ms={elapsed_ms}".format(
batch_id=self.current_batch_id or "",
files=self.last_import_stats.get("files", 0),
total=self.last_import_stats.get("total", 0),
valid=self.last_import_stats.get("valid", 0),
invalid=self.last_import_stats.get("invalid", 0),
inserted=self.last_import_stats.get("inserted", 0),
elapsed_ms=elapsed_ms,
),
db_path=self.db_path,
)
_safe_finish_run_log(
run_id,
db_path=self.db_path,
status="done",
done=self.last_import_stats.get("files", 0),
success_count=self.last_import_stats.get("inserted", 0),
failed_count=len(file_errors) + len(row_errors),
summary_json={
"ok": True,
"batch_id": self.current_batch_id,
"stats": self.last_import_stats,
"elapsed_ms": elapsed_ms,
},
)
self.refresh_tasks()
self._set_status(
"导入完成:有效{valid},无效{invalid},入库{inserted},未匹配{unmatched}".format(
valid=self.last_import_stats.get("valid", 0),
invalid=self.last_import_stats.get("invalid", 0),
inserted=self.last_import_stats.get("inserted", 0),
unmatched=self.model.unmatched_count(),
)
)
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
batches = db.list_batches(path=self.db_path)
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()
if self.current_batch_id and self.batch_filter.findData(self.current_batch_id) < 0:
selected_batch = self.current_batch_id
self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData()
self.current_batch_id = selected_batch
task_rows = db.list_tasks(batch_id=selected_batch, path=self.db_path)
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
self._populate_shop_filter(task_rows, account_rows, selected_shop)
selected_shop = self.shop_filter.currentData()
filtered_rows = [
task for task in task_rows
if self._matches_shop(task, selected_shop)
and self._matches_item(task, item_query)
and self._matches_status(task, selected_status, account_rows)
]
except Exception as exc:
self.model.set_tasks([], [])
self.empty_label.setText("任务读取失败")
_set_batch_progress_overview(self.batch_progress_label, [])
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
self._set_status(f"任务读取失败:{exc}")
return
self.model.set_tasks(filtered_rows, account_rows)
self._update_summary(task_rows, account_rows)
_set_batch_progress_overview(self.batch_progress_label, task_rows)
self._update_empty_state(task_rows, account_rows)
self._update_delete_batch_button()
def _populate_batch_filter(self, batches, selected_batch):
batch_ids = {batch.id for batch in batches}
previous = selected_batch if selected_batch in batch_ids 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 _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 _populate_shop_filter(self, task_rows, account_rows, selected_shop):
aliases = {str(task.alias).strip() for task in task_rows if str(task.alias).strip()}
previous = selected_shop if selected_shop in aliases else None
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
self.shop_filter.blockSignals(True)
self.shop_filter.clear()
self.shop_filter.addItem("全部店铺", None)
for alias in sorted(aliases):
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 _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", ""))
def _matches_status(self, task, selected_status, account_rows):
if selected_status in (None, "all"):
return True
if selected_status == "to_collect":
return task.stage == "imported" and task.status in {"pending", "success"}
if selected_status in {"collected", "generated", "applied"}:
return task.stage == selected_status
if selected_status == "failed":
return task.status == "failed"
if selected_status == "skipped":
return task.status == "skipped" or self._is_unmatched_task(task, account_rows)
return True
def _is_unmatched_task(self, task, account_rows):
aliases = {
str(account.alias).strip()
for account in account_rows
if str(account.alias).strip()
}
return str(task.alias).strip() not in aliases
def _selected_batch_id(self):
return self.batch_filter.currentData()
def _update_delete_batch_button(self):
running = bool(self.collect_thread or self.write_back_thread)
self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id()))
def delete_current_batch(self, checked=False):
batch_id = self._selected_batch_id()
if not batch_id:
self._set_status("请先选择一个具体批次")
return
batch = db.get_batch(batch_id, path=self.db_path)
if batch is None:
self._set_status("批次不存在或已删除")
self.current_batch_id = None
self.refresh_tasks()
return
tasks = db.list_tasks(batch_id=batch_id, path=self.db_path)
committed_count = sum(1 for task in tasks if int(getattr(task, "committed", 0) or 0) == 1)
lines = [
f"确定要软删除批次 {self._batch_label(batch)} 吗?",
f"任务数:{len(tasks)}",
f"已提交线上:{committed_count}",
"",
"软删除后,该批次不会再出现在①/②/③页面、筛选、采集、生成、更新或回写入口中。",
"软删除只隐藏本地批次,不会回滚 Shopee 线上修改,不删除原始 Excel,也不删除本地图片。",
]
answer = QMessageBox.question(
self,
"删除批次",
"\n".join(lines),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消删除批次")
return
try:
result = db.delete_batch(batch_id, reason="用户在导入采集页软删除", path=self.db_path)
except Exception as exc:
QMessageBox.warning(self, "删除批次", str(exc))
self._set_status(f"删除批次失败:{exc}")
return
self.current_batch_id = None
self.has_import_result = False
self.refresh_tasks()
if self.refresh_workflow_callback is not None:
self.refresh_workflow_callback()
message = "已软删除批次:任务{task_count},已提交线上{committed_count}".format(
task_count=result.get("task_count", 0),
committed_count=result.get("committed_count", 0),
)
self._set_status(message)
QMessageBox.information(self, "删除批次", message)
def collect_old_data(self, checked=False):
tasks = list(self.model.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,
)
worker.progress.connect(self._on_collect_progress)
worker.row_updated.connect(self._on_collect_row_updated)
worker.log.connect(self._on_collect_log)
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()
thread = run_worker(worker, thread_name="CollectWorker", start=False)
thread.finished.connect(lambda: self._forget_collect_thread(thread))
self.collect_worker = worker
self.collect_thread = thread
self._set_collect_running(True)
thread.start()
def stop_collect(self, checked=False):
if self.collect_worker is not None:
self.collect_worker.cancel()
self._set_status("正在停止采集...")
def write_back_old_data(self, checked=False):
batch_id = self._active_batch_id()
if not batch_id:
self._set_status("没有可回写批次")
return
self._start_write_back(batch_id)
def _start_write_back(self, batch_id, auto=False):
if self.write_back_thread is not None:
self._set_status("Excel 回写正在进行...")
return False
worker = WriteBackWorker(
batch_id,
db_path=self.db_path,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
worker.failed.connect(
lambda task_id, error, auto=auto: self._on_write_back_failed(
task_id,
error,
auto=auto,
)
)
worker.finished.connect(
lambda payload, auto=auto: self._on_write_back_finished(
payload,
auto=auto,
)
)
thread = run_worker(worker, thread_name="WriteBackWorker", start=False)
thread.finished.connect(lambda: self._forget_write_back_thread(thread))
self.write_back_worker = worker
self.write_back_thread = thread
self._set_write_back_running(True)
self._set_status("正在自动回写旧数据到 Excel..." if auto else "正在回写旧数据到 Excel...")
thread.start()
return True
def _active_batch_id(self):
if self.current_batch_id:
return self.current_batch_id
batch_ids = {
task.batch_id
for task in self.model.all_tasks
if getattr(task, "batch_id", None)
}
if len(batch_ids) == 1:
return next(iter(batch_ids))
return None
def _set_collect_running(self, running):
self.import_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.collect_button.setEnabled(not running)
self.write_back_button.setEnabled(not running)
self.stop_collect_button.setEnabled(running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self._update_delete_batch_button()
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)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self._update_delete_batch_button()
def _forget_collect_thread(self, thread):
if self.collect_thread is thread:
self.collect_thread = None
self.collect_worker = None
def _forget_write_back_thread(self, thread):
if self.write_back_thread is thread:
self.write_back_thread = None
self.write_back_worker = None
def _on_collect_progress(self, payload):
self._set_status(
"采集进度:{done}/{total},成功{collected},略过{skipped},失败{failed}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
collected=payload.get("collected", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
)
def _on_collect_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_collect_failed(self, task_id, error):
self._set_status(f"任务 {task_id} 采集失败:{error}")
def _on_collect_finished(self, payload):
self._set_collect_running(False)
self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id
self.refresh_tasks()
self._load_latest_collect_run_log()
if payload.get("blocked"):
self._show_collect_blocked(payload)
return
message = "采集完成:成功{collected},略过{skipped},失败{failed}".format(
collected=payload.get("collected", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
if payload.get("collected", 0) > 0:
batch_id = self._active_batch_id()
if batch_id and self._start_write_back(batch_id, auto=True):
if self.last_collect_run_id:
self._log_collect_run_event(
self.last_collect_run_id,
"step=excel_write_back result=start detail=采集成功后自动回写旧数据到 Excel",
)
self._set_status(f"{message},正在自动回写 Excel...")
return
if not batch_id:
self._set_status(f"{message},但没有可回写批次")
return
self._set_status(f"{message},Excel 回写已在进行")
return
self._set_status(message)
def _show_collect_blocked(self, payload):
lines = ["采集前检查未通过。"]
if payload.get("no_accounts"):
lines.append("当前没有配置账号。")
not_running = payload.get("not_running") or []
if not_running:
lines.append(
"以下账号 Chrome 未启动或调试端口不可访问:"
+ "、".join(self._account_label(item) for item in not_running)
)
logged_out = payload.get("logged_out") or []
if logged_out:
lines.append(
"以下账号未登录 Shopee:"
+ "、".join(self._account_label(item) for item in logged_out)
)
self._show_account_guide("\n".join(lines))
def _account_label(self, item):
if isinstance(item, dict):
name = item.get("account_name") or item.get("alias") or ""
alias = item.get("alias") or ""
reason = item.get("reason")
else:
name = getattr(item, "account_name", "") or getattr(item, "alias", "")
alias = getattr(item, "alias", "")
reason = getattr(item, "reason", None)
label = f"{name}({alias})" if alias and name != alias else (name or alias)
return f"{label}: {reason}" if reason else label
def _on_collect_cancelled(self, payload):
self._set_collect_running(False)
self.refresh_tasks()
self._set_status(
"采集已停止:完成{done}/{total}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
)
)
def _on_write_back_failed(self, task_id, error, auto=False):
message = f"Excel {'自动' if auto else ''}回写失败:{error}"
if "被占用" in str(error):
if auto:
message += "\n请关闭原 Excel 后点击「回写旧数据到 Excel」手动重试;SQLite 已保留采集结果,也可另存副本。"
else:
message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。"
QMessageBox.warning(self, "回写旧数据", message)
self._set_status(message.replace("\n", " "))
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",
)
def _on_write_back_finished(self, payload, auto=False):
self._set_write_back_running(False)
if payload.get("ok") is False:
error = payload.get("error") or "未知错误"
retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else ""
self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}")
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",
)
return
self.refresh_tasks()
self._set_status(
"Excel {prefix}回写完成:文件{files},行{rows}".format(
prefix="自动" if auto else "",
files=payload.get("files", 0),
rows=payload.get("rows", 0),
)
)
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",
)
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)
invalid_text = _danger_metric_text(f"无效{invalid}", invalid > 0)
unmatched_text = _danger_metric_text(f"未匹配{unmatched}", unmatched > 0)
self.summary_label.setText(
f"{files} 文件 · {total} 行 · 有效{valid}/{invalid_text} · 匹配{matched} · {unmatched_text}"
)
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)
self.show_unmatched_button.setStyleSheet(
_danger_outline_button_style("showUnmatchedButton") if unmatched > 0 else ""
)
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_state(self, task_rows, account_rows):
if not account_rows:
self.empty_label.setText("")
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"第一步:前往『④账号管理』配置并登录账号,再回到①导入 Excel。",
self.open_accounts_callback is not None,
)
return
if not task_rows:
self.empty_label.setText("")
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"还没有导入任务。请点击「导入 Excel...」导入待处理商品。",
)
return
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
self._update_empty_label(len(task_rows))
def _update_empty_label(self, total_rows):
if total_rows == 0:
self.empty_label.setText("暂无任务")
return
if self.model.rowCount() == 0 and self.model.filter_mode == "unmatched":
self.empty_label.setText("当前筛选没有未匹配任务")
return
if self.model.rowCount() == 0:
self.empty_label.setText("当前筛选没有匹配任务")
return
unmatched = self.model.unmatched_count()
self.empty_label.setText(
"" if unmatched == 0 else f"{unmatched} 条任务别名未匹配账号,阶段显示为“略过”"
)
class AccountDialog(QDialog):
"""Dialog for adding or editing one account."""
def __init__(self, parent=None, account=None, default_port=9222, config=None):
super().__init__(parent)
self._account = account
self._config = config
self.setWindowTitle("编辑账号" if account else "新增账号")
self.account_name_edit = QLineEdit()
self.alias_edit = QLineEdit()
self.region_host_edit = QLineEdit(accounts.DEFAULT_REGION_HOST)
self.debug_port_spin = QSpinBox()
self.debug_port_spin.setRange(1, 65535)
self.debug_port_spin.setValue(int(default_port))
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.Password)
self.note_edit = QPlainTextEdit()
self.note_edit.setMaximumHeight(76)
self.slug_edit = QLineEdit()
self.slug_edit.setReadOnly(True)
self.user_data_dir_edit = QLineEdit()
self.user_data_dir_edit.setReadOnly(True)
if account is not None:
self.account_name_edit.setText(account.account_name)
self.alias_edit.setText(account.alias)
self.region_host_edit.setText(account.region_host)
self.debug_port_spin.setValue(int(account.debug_port))
self.password_edit.setText(account.password or "")
self.note_edit.setPlainText(account.note or "")
self.slug_edit.setText(account.slug)
self.user_data_dir_edit.setText(account.user_data_dir)
form = QFormLayout()
form.addRow("账号名", self.account_name_edit)
form.addRow("别名", self.alias_edit)
form.addRow("地区", self.region_host_edit)
form.addRow("调试端口", self.debug_port_spin)
form.addRow("密码", self.password_edit)
form.addRow("备注", self.note_edit)
form.addRow("slug", self.slug_edit)
form.addRow("数据目录", self.user_data_dir_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(buttons)
self.alias_edit.textChanged.connect(self._update_path_preview)
self._update_path_preview()
def _update_path_preview(self):
alias = self.alias_edit.text().strip()
if not alias:
self.slug_edit.clear()
self.user_data_dir_edit.clear()
return
try:
slug = account_config.make_slug(alias)
self.slug_edit.setText(slug)
if self._account is not None and alias == self._account.alias:
self.user_data_dir_edit.setText(self._account.user_data_dir)
else:
self.user_data_dir_edit.setText(
accounts.preview_user_data_dir(alias, config=self._config)
)
except Exception:
self.slug_edit.clear()
self.user_data_dir_edit.clear()
def values(self):
return {
"account_name": self.account_name_edit.text().strip(),
"alias": self.alias_edit.text().strip(),
"region_host": self.region_host_edit.text().strip(),
"debug_port": self.debug_port_spin.value(),
"password": self.password_edit.text(),
"note": self.note_edit.toPlainText().strip(),
}
from .workers import BaseWorker, run_worker
class GenerateWorker(BaseWorker):
"""Generate titles and covers for collected tasks."""
def __init__(
self,
tasks,
prompt_values,
db_path=None,
config=None,
diagnostic_log_dir=None,
):
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
self._account_by_alias = {}
self._task_positions = {}
self._eligible_total = 0
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()
}
self._account_by_alias = account_by_alias
eligible = [
task for task in self.tasks
if getattr(task, "stage", None) == "collected"
]
self._eligible_total = len(eligible)
self._task_positions = {
getattr(task, "id", None): index
for index, task in enumerate(eligible, start=1)
}
batch_ids = self._batch_ids(eligible)
self._run_id = self._create_run_log(eligible, batch_ids)
ai_cfg = appconfig.ai_config(self.config)
generate_cover = bool(ai_cfg.get("generate_cover", False))
if generate_cover:
start_message = "[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format(
total=len(eligible),
title_concurrency=ai_cfg.get("title_concurrency", 1),
image_concurrency=ai_cfg.get("image_concurrency", 1),
)
else:
start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题并发{title_concurrency}".format(
total=len(eligible),
title_concurrency=ai_cfg.get("title_concurrency", 1),
)
self._log_run_event(start_message)
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,
"generate_cover": generate_cover,
},
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,
"cover_total": len(eligible) if generate_cover else 0,
"generated_done": 0,
"failed": len(eligible),
"cancelled": self.should_cancel(),
"generate_cover": generate_cover,
}
self._log_run_event(
f"[失败] AI 生成运行失败:{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"
level = "warning" if summary.get("cancelled") or summary.get("error") else "info"
self._log_run_event(self._format_generate_completion(summary), level=level)
self._finish_run_log(status, summary)
return summary
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")
message = self._format_generation_event(payload)
if not message:
return
self._log_run_event(message, task=task, level=payload.get("level") or "info")
def _format_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 = self._short_detail(payload.get("detail"))
if phase == "title":
if result == "start" and step == "title_submit":
return f"[标题] {self._task_progress_label(task)} 开始生成"
if result == "success" and step == "title_done":
return f"[标题] {self._task_progress_label(task)} 成功"
if result == "success" and step == "db_write":
suffix = f",{detail}" if detail else ""
return f"[标题] {self._task_progress_label(task)} 已保存{suffix}"
if result == "retry":
return self._retry_message("标题", task, payload, detail)
if result == "failed":
return f"[失败] {self._task_plain_label(task)} 标题生成失败:{detail or '未知错误'}"
if result == "cancelled":
return f"[停止] {self._task_plain_label(task)} 标题生成已取消"
return None
if phase == "cover":
if result == "start" and step == "cover_submit":
return f"[图片] {self._task_progress_label(task)} 开始生成"
if result == "success" and step == "db_write":
suffix = f",已保存 {detail}" if detail else ""
return f"[图片] {self._task_progress_label(task)} 成功{suffix}"
if result == "retry":
return self._retry_message("图片", task, payload, detail)
if result == "failed":
return f"[失败] {self._task_plain_label(task)} 图片生成失败:{detail or '未知错误'}"
if result == "cancelled":
return f"[停止] {self._task_plain_label(task)} 图片生成已取消"
return None
return None
def _retry_message(self, label, task, payload, detail):
attempt = int(payload.get("attempt", 0) or 0)
attempts = int(payload.get("attempts", 0) or 0)
max_retries = max(0, attempts - 1)
retry_text = f"准备重试 {attempt}/{max_retries}" if max_retries else "准备重试"
reason = f":{detail}" if detail else ""
return f"[{label}] {self._task_progress_label(task)} 调用失败,{retry_text}{reason}"
def _task_progress_label(self, task):
index = self._task_positions.get(getattr(task, "id", None), 0)
total = self._eligible_total or 0
item_id = getattr(task, "item_id", "") or "未知商品"
shop = self._task_shop_label(task)
shop_text = f"({shop})" if shop else ""
return f"{index}/{total} 商品 {item_id}{shop_text}"
def _task_plain_label(self, task):
item_id = getattr(task, "item_id", "") or "未知商品"
shop = self._task_shop_label(task)
return f"商品 {item_id}({shop})" if shop else f"商品 {item_id}"
def _task_shop_label(self, task):
alias = str(getattr(task, "alias", "") or "").strip()
account = self._account_by_alias.get(alias)
if account is not None:
return getattr(account, "account_name", None) or getattr(account, "alias", None) or alias
return getattr(task, "account_name", None) or alias
def _short_detail(self, detail):
if detail is None:
return ""
text = diagnostics.redact_log_text(str(detail)).replace("\r", " ").replace("\n", " ").strip()
if len(text) > 180:
return text[:177] + "..."
return text
def _format_generate_completion(self, summary):
progress = self._summary_text(summary)
if summary.get("cancelled"):
return f"[停止] AI 生成已停止:{progress}"
if summary.get("error"):
return f"[失败] AI 生成失败:{summary.get('error')},{progress}"
return f"[完成] AI 生成完成:{progress}"
def _summary_text(self, summary):
cover_total = summary.get("cover_total", summary.get("total", 0))
return "标题{title}/{total},图片{cover}/{cover_total},失败{failed}".format(
title=summary.get("title_done", 0),
cover=summary.get("cover_done", 0),
cover_total=cover_total,
total=summary.get("total", 0),
failed=summary.get("failed", 0),
)
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"),
"generate_cover": ai_cfg.get("generate_cover", False),
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
generated_done = summary.get("generated_done")
if generated_done is None:
generated_done = summary.get("cover_done", 0)
if not summary.get("generate_cover", True) and not generated_done:
generated_done = summary.get("title_done", 0)
done = int(generated_done or 0) + int(summary.get("failed", 0) or 0)
db.finish_run_log(
self._run_id,
status=status,
done=done,
success_count=generated_done,
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
class ApplyWorker(BaseWorker):
"""Apply generated title/cover changes, optionally previewing or grouping by account."""
def __init__(
self,
tasks,
db_path=None,
config=None,
preflight=True,
close_success_tab=False,
dry_run=False,
parallel_accounts=False,
max_parallel_accounts=1,
batch_size=None,
diagnostic_log_dir=None,
):
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
self.close_success_tab = close_success_tab
self.dry_run = bool(dry_run)
self.parallel_accounts = bool(parallel_accounts)
self.max_parallel_accounts = max(1, int(max_parallel_accounts or 1))
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
self._progress_lock = threading.Lock()
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
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)]
batch_ids = self._batch_ids(eligible)
total = len(eligible)
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)
counters = {
"done": 0,
"applied": 0,
"skipped": 0,
"failed": 0,
}
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
"step=start result=start detail=运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format(
mode="检查本轮更新" if self.dry_run else "真实更新",
total=total,
batch_size=batch_size,
batch_count=len(batches),
parallel=(
f"多账号并行最多{self.max_parallel_accounts}"
if self.parallel_accounts
else "串行"
),
)
)
if self.preflight and not self.dry_run:
self._log_run_event("step=preflight result=start detail=账号就绪检查")
blocked = self._preflight_block(eligible, account_rows, account_by_alias)
if blocked:
self._log_preflight_blocked(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
self._log_run_event("step=preflight result=success detail=账号检查通过")
elif not self.preflight:
self._log_run_event(
"step=preflight result=skipped detail=测试模式跳过更新前检查",
level="warning",
)
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)
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
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,
}
duplicate_ports = self._duplicate_debug_ports(account_rows, eligible, account_by_alias)
if duplicate_ports:
return {
"reason": "DUPLICATE_DEBUG_PORT",
"duplicate_ports": duplicate_ports,
}
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:
self._log_run_event(
f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}",
level="info",
)
if not chrome.is_running(account.debug_port):
self._log_run_event(
f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}",
level="warning",
)
not_running.append(self._account_payload(account, "CDP 端口未响应"))
continue
self._log_run_event(
f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}",
level="info",
)
self._log_run_event(
f"step=login_check result=start detail=账号 {account.alias}",
level="info",
)
status = self._login_status(account)
if not status.get("logged_in"):
reason = self._login_skip_reason(status)
self._log_run_event(
f"step=login_check result=blocked detail=账号 {account.alias} {reason}",
level="warning",
)
logged_out.append(
self._account_payload(account, reason)
)
else:
self._log_run_event(
f"step=login_check result=success detail=账号 {account.alias}",
level="info",
)
if not_running or logged_out:
return {
"reason": "ACCOUNT_NOT_READY",
"not_running": not_running,
"logged_out": logged_out,
}
return None
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
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"step=batch result=start detail={label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}"
)
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(
f"step=preview result=skipped detail=检查:任务 {task.id} 商品 {task.item_id} 将略过:{reason}",
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(
"step=preview result=success detail=检查:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format(
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"step=preflight result=skipped detail=任务 {task.id} 商品 {task.item_id} 已略过:{reason}",
task=task,
level="warning",
)
return "skipped"
started = time.monotonic()
current_step = "db_write"
def on_step(event):
nonlocal current_step
if isinstance(event, dict):
step = str(event.get("step") or "apply_task")
result = str(event.get("result") or "start")
detail = event.get("detail")
else:
step = str(event)
result = "start"
detail = None
current_step = step
level = "error" if result == "failed" else "info"
detail_text = "任务 {task_id} 商品 {item_id}".format(
task_id=task.id,
item_id=task.item_id,
)
if detail:
detail_text = f"{detail_text} {detail}"
self._log_run_event(
f"step={step} result={result} detail={detail_text}",
task=task,
level=level,
)
try:
self._log_run_event(
f"step=apply_task result=start detail=任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}",
task=task,
)
current_step = "db_write"
self._log_run_event(
f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 标记更新运行",
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,
on_step=on_step,
)
committed = bool(result.get("committed")) and not result.get("error")
error = result.get("error")
current_step = "db_write"
self._log_run_event(
f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 保存更新结果",
task=task,
)
if committed:
db.set_applied(task.id, True, path=self.db_path)
elapsed_ms = self._elapsed_ms(started)
self.row_updated.emit(
task.id,
{
"stage": "applied",
"status": "success",
"committed": 1,
"last_error": None,
},
)
self._log_run_event(
f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 更新成功 elapsed_ms={elapsed_ms}",
task=task,
)
return "applied"
error = diagnostics.redact_log_text(error or "更新未提交")
failed_step = self._failed_apply_step(result, current_step)
db.set_applied(task.id, False, error, path=self.db_path)
elapsed_ms = self._elapsed_ms(started)
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"step={failed_step} result=failed detail={error} elapsed_ms={elapsed_ms}",
task=task,
level="error",
)
self._log_run_event(
f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 保存失败状态 elapsed_ms={elapsed_ms}",
task=task,
)
self._write_diagnostic_log(
"Shopee更新任务失败",
level="ERROR",
step=failed_step,
task=task,
elapsed_ms=elapsed_ms,
payload={"error": error, "result": result},
)
return "failed"
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
db.set_applied(task.id, False, error, path=self.db_path)
elapsed_ms = self._elapsed_ms(started)
self.failed.emit(task.id, error)
self.row_updated.emit(
task.id,
{"status": "failed", "last_error": error, "committed": 0},
)
self._log_run_event(
f"step={current_step} result=failed detail={error} elapsed_ms={elapsed_ms}",
task=task,
level="error",
)
self._write_diagnostic_log(
"Shopee更新任务异常",
level="ERROR",
step=current_step,
task=task,
elapsed_ms=elapsed_ms,
payload={"error": error},
exc=exc,
)
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"],
)
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,
"dry_run": self.dry_run,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
}
)
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 _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, 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,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
"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,
"batch_size": self._current_batch_size,
"batch_count": self._batch_count,
},
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))
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("duplicate_ports") or []:
self._log_run_event(
"step=preflight result=blocked detail=调试端口重复 debug_port={port} aliases={aliases}".format(
port=item.get("debug_port") or "",
aliases=",".join(item.get("aliases") or []),
),
level="warning",
)
for item in blocked.get("not_running") or []:
self._log_run_event(
"step=check_chrome 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=login_check result=blocked detail=账号 {alias} 未登录 Shopee: {reason}".format(
alias=item.get("alias") or "",
reason=item.get("reason") or "",
),
level="warning",
)
def _failed_apply_step(self, result, fallback):
if not isinstance(result, dict):
return fallback or "apply_task"
title = result.get("title")
if isinstance(title, dict) and not title.get("ok", True):
return "change_title"
cover = result.get("cover")
if isinstance(cover, dict) and not cover.get("ok", True):
return "replace_cover"
update = result.get("update")
if isinstance(update, dict):
return "click_update"
return fallback or "apply_task"
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
task=None,
elapsed_ms=None,
payload=None,
exc=None,
):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
task=task,
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _elapsed_ms(self, started):
return _elapsed_ms(started)
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,
):
super().__init__()
self.tasks = list(tasks)
self.db_path = db_path
self.config = config
self.preflight = preflight
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
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)
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}"
)
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,
)
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",
)
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",
)
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",
)
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,
)
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,
)
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,
)
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)
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,
)
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,
)
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
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:
self._log_run_event(
f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}",
level="info",
)
if not chrome.is_running(account.debug_port):
self._log_run_event(
f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}",
level="warning",
)
not_running.append(self._account_payload(account, "CDP 端口未响应"))
continue
self._log_run_event(
f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}",
level="info",
)
self._log_run_event(
f"step=login_check result=start detail=账号 {account.alias}",
level="info",
)
status = self._login_status(account)
if not status.get("logged_in"):
reason = self._login_skip_reason(status)
self._log_run_event(
f"step=login_check result=blocked detail=账号 {account.alias} {reason}",
level="warning",
)
logged_out.append(
self._account_payload(account, reason)
)
else:
self._log_run_event(
f"step=login_check result=success detail=账号 {account.alias}",
level="info",
)
if not_running or logged_out:
return {
"reason": "ACCOUNT_NOT_READY",
"not_running": not_running,
"logged_out": logged_out,
}
return None
def _account_payload(self, account, reason=None):
payload = {
"account_name": account.account_name,
"alias": account.alias,
"debug_port": account.debug_port,
}
if reason:
payload["reason"] = reason
return payload
def _emit_progress(self, done, total, collected, skipped, failed):
self.progress.emit(
{
"done": done,
"total": total,
"collected": collected,
"skipped": skipped,
"failed": failed,
}
)
def _login_status(self, account):
try:
return accounts.detect_login(account, path=self.db_path, config=self.config)
except Exception as exc:
return {
"logged_in": False,
"reason": f"LOGIN_CHECK_FAILED: {exc}",
}
def _login_skip_reason(self, status):
reason = status.get("reason")
return f"账号未登录: {reason}" if reason else "账号未登录"
def _old_cover_path(self, account, task):
image_root = appconfig.image_dir(self.config)
return image_paths.task_image_path(image_root, task, account, "old")
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)
class WriteBackWorker(BaseWorker):
"""Write Excel fields back in a background thread."""
def __init__(self, batch_id, db_path=None, excel_path=None, mode="old", diagnostic_log_dir=None):
super().__init__()
self.batch_id = batch_id
self.db_path = db_path
self.excel_path = excel_path
self.mode = mode
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self):
batch_ids = self._batch_ids()
self._run_id = _safe_create_run_log(
"write_back",
db_path=self.db_path,
total=len(batch_ids),
options={
"batch_ids": batch_ids,
"mode": self.mode,
"excel_path": self.excel_path,
},
)
self._log_run_event(
f"step=start result=start detail=Excel 回写开始 mode={self.mode} batch_count={len(batch_ids)}"
)
results = []
try:
for batch_id in batch_ids:
started = time.monotonic()
self._log_run_event(
f"step=write_excel result=start detail=batch_id={batch_id} mode={self.mode}"
)
result = self._write_one(batch_id)
results.append(result)
self._log_run_event(
"step=write_excel result=success detail=batch_id={batch_id} files={files} rows={rows} elapsed_ms={elapsed_ms}".format(
batch_id=batch_id,
files=result.get("files", 0),
rows=result.get("rows", 0),
elapsed_ms=self._elapsed_ms(started),
)
)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
self._log_run_event(
f"step=write_excel result=failed detail={error}",
level="error",
)
self._write_diagnostic_log(
"Excel回写失败",
level="ERROR",
step="write_excel",
payload={"batch_ids": batch_ids, "mode": self.mode, "error": error},
exc=exc,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="failed",
done=len(results),
success_count=sum(result.get("rows", 0) for result in results),
failed_count=1,
summary_json={"ok": False, "error": error, "mode": self.mode},
)
raise
result = results[0] if len(results) == 1 else self._combined_result(results)
self.progress.emit(
{
"done": result.get("rows", 0),
"total": result.get("rows", 0),
"files": result.get("files", 0),
}
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="done",
done=len(batch_ids),
success_count=result.get("rows", 0),
failed_count=0,
summary_json={"ok": result.get("ok", False), "mode": self.mode, "result": result},
)
return result
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,
}
def _log_run_event(self, message, level="info"):
safe_message = _safe_add_run_log_event(
self._run_id,
message,
db_path=self.db_path,
level=level,
)
self.log.emit(str(safe_message))
def _write_diagnostic_log(self, message, level="INFO", step=None, payload=None, exc=None):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _elapsed_ms(self, started):
return _elapsed_ms(started)
class AccountLoginCheckWorker(BaseWorker):
def __init__(self, account, db_path=None, config=None, timeout=8, diagnostic_log_dir=None):
super().__init__()
self.account = account
self.db_path = db_path
self.config = config
self.timeout = timeout
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self):
self._run_id = _safe_create_run_log(
"login_check",
db_path=self.db_path,
total=1,
options={
"alias": self.account.alias,
"debug_port": self.account.debug_port,
"timeout": self.timeout,
},
)
started = time.monotonic()
self._log_run_event(
f"step=detect_login result=start detail=账号 {self.account.alias} debug_port={self.account.debug_port}"
)
try:
status = accounts.detect_login(
self.account,
timeout=self.timeout,
path=self.db_path,
config=self.config,
)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
elapsed_ms = self._elapsed_ms(started)
self._log_run_event(
f"step=detect_login result=failed detail={error} elapsed_ms={elapsed_ms}",
level="error",
)
self._write_diagnostic_log(
"登录检测失败",
level="ERROR",
step="detect_login",
elapsed_ms=elapsed_ms,
payload={"alias": self.account.alias, "error": error},
exc=exc,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="failed",
done=0,
failed_count=1,
summary_json={"ok": False, "alias": self.account.alias, "error": error},
)
raise
elapsed_ms = self._elapsed_ms(started)
logged_in = bool(status.get("logged_in"))
result_text = "success" if logged_in else "failed"
level = "info" if logged_in else "warning"
self._log_run_event(
"step=detect_login result={result} detail=账号 {alias} logged_in={logged_in} reason={reason} elapsed_ms={elapsed_ms}".format(
result=result_text,
alias=self.account.alias,
logged_in=logged_in,
reason=status.get("reason") or "",
elapsed_ms=elapsed_ms,
),
level=level,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="done",
done=1,
success_count=1 if logged_in else 0,
failed_count=0 if logged_in else 1,
summary_json={"ok": logged_in, "alias": self.account.alias, "status": status},
)
self.row_updated.emit(self.account.id, status)
return {"alias": self.account.alias, "status": status}
def _log_run_event(self, message, level="info"):
safe_message = _safe_add_run_log_event(
self._run_id,
message,
db_path=self.db_path,
account=self.account,
level=level,
)
self.log.emit(str(safe_message))
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
elapsed_ms=None,
payload=None,
exc=None,
):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
account=self.account,
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _elapsed_ms(self, started):
return _elapsed_ms(started)
class AIModelTestWorker(BaseWorker):
"""Test one AI model connection without blocking the GUI thread."""
def __init__(self, model_name, ai_models_path=None, db_path=None, diagnostic_log_dir=None):
super().__init__()
self.model_name = model_name
self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH
self.db_path = db_path
self.diagnostic_log_dir = diagnostic_log_dir
self._run_id = None
def execute(self):
self._run_id = self._create_run_log()
started = time.monotonic()
self._log_run_event(
f"step=test_connection result=start detail=AI模型 {self.model_name}"
)
try:
result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
elapsed_ms = self._elapsed_ms(started)
self._log_run_event(
f"step=test_connection result=failed detail={error} elapsed_ms={elapsed_ms}",
level="error",
)
self._write_diagnostic_log(
"AI模型测试连接异常",
level="ERROR",
step="test_connection",
elapsed_ms=elapsed_ms,
payload={"model_name": self.model_name, "error": error},
exc=exc,
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="failed",
done=0,
failed_count=1,
summary_json={"ok": False, "name": self.model_name, "error": error},
)
raise
elapsed_ms = self._elapsed_ms(started)
payload = dict(appconfig.sanitize_for_log(result or {}))
payload["name"] = self.model_name
ok = bool(payload.get("ok"))
self._log_run_event(
"step=test_connection result={result} detail=AI模型 {name} status={status} error={error} elapsed_ms={elapsed_ms}".format(
result="success" if ok else "failed",
name=self.model_name,
status=payload.get("status") or "",
error=payload.get("error") or "",
elapsed_ms=elapsed_ms,
),
level="info" if ok else "warning",
)
_safe_finish_run_log(
self._run_id,
db_path=self.db_path,
status="done",
done=1,
success_count=1 if ok else 0,
failed_count=0 if ok else 1,
summary_json=payload,
)
return payload
def _create_run_log(self):
if not self.db_path:
return None
return _safe_create_run_log(
"ai_model_test",
db_path=self.db_path,
total=1,
options={"model_name": self.model_name},
)
def _log_run_event(self, message, level="info"):
safe_message = _safe_add_run_log_event(
self._run_id,
message,
db_path=self.db_path,
level=level,
)
self.log.emit(str(safe_message))
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
elapsed_ms=None,
payload=None,
exc=None,
):
_safe_write_diagnostic_log(
message,
level=level,
step=step,
elapsed_ms=elapsed_ms,
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
def _elapsed_ms(self, started):
return _elapsed_ms(started)
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"]
def __init__(
self,
parent=None,
config=None,
config_path=None,
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
)
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._compat_test_item_id = ""
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("保存设置")
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)
self.max_items_per_run_spin.setToolTip("作为每批最大更新条数;正式更新会分批处理当前筛选全部可更新记录。")
self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页")
self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox")
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)
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)
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)
action_layout = QHBoxLayout()
action_layout.addWidget(self.save_model_button)
action_layout.addWidget(self.test_connection_button)
action_layout.addStretch(1)
form = self._three_column_form(
[
("状态", 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),
]
)
ai_form = self._three_column_form(
[
("标题大模型", 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()
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)
port_range_widget = QWidget()
port_range_widget.setLayout(port_range_layout)
path_form = self._three_column_form(
[
("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),
]
)
self.shopee_update_form_layout = self._three_column_form(
[
("每批最大更新条数", self.max_items_per_run_spin),
("", self.allow_real_submit_checkbox),
("", self.close_success_tab_checkbox),
("", self.allow_cover_update_checkbox),
("", self.parallel_accounts_group, 2),
]
)
panel = QWidget()
panel.setMaximumWidth(1800)
panel_layout = QVBoxLayout(panel)
self.settings_panel_layout = panel_layout
panel_layout.setContentsMargins(13, 18, 13, 18)
self.ai_model_section_title = self._section_title(
"AI 模型",
"settingsAiModelSectionTitle",
)
self.model_detail_section_title = self._section_title(
"模型详情",
"settingsModelDetailSectionTitle",
)
self.generation_section_title = self._section_title(
"角色与生成参数",
"settingsGenerationSectionTitle",
)
self.shopee_update_section_title = self._section_title(
"Shopee 更新安全 / 执行模式",
"settingsShopeeUpdateSectionTitle",
)
self.infrastructure_section_title = self._section_title(
"基础设施(路径与端口)",
"settingsInfrastructureSectionTitle",
)
panel_layout.addWidget(self.ai_model_section_title)
panel_layout.addLayout(model_picker_layout)
panel_layout.addSpacing(14)
panel_layout.addWidget(self.model_detail_section_title)
panel_layout.addLayout(form)
panel_layout.addLayout(action_layout)
panel_layout.addWidget(self.test_result_label)
panel_layout.addSpacing(18)
panel_layout.addWidget(self.generation_section_title)
panel_layout.addLayout(ai_form)
panel_layout.addSpacing(18)
panel_layout.addWidget(self.shopee_update_section_title)
panel_layout.addLayout(self.shopee_update_form_layout)
panel_layout.addSpacing(18)
panel_layout.addWidget(self.infrastructure_section_title)
panel_layout.addLayout(path_form)
panel_layout.addWidget(self.save_config_button)
panel_layout.addStretch(1)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
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)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addWidget(scroll, 1)
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)
self.refresh_models()
self._populate_app_settings()
def _three_column_form(self, fields):
layout = QGridLayout()
layout.setHorizontalSpacing(18)
layout.setVerticalSpacing(8)
for column in (1, 3, 5):
layout.setColumnStretch(column, 1)
row = 0
column_pair = 0
for field in fields:
label = field[0]
widget = field[1]
span_pairs = self._form_field_span_pairs(field)
if span_pairs > 3 - column_pair:
row += 1
column_pair = 0
column = column_pair * 2
self._add_form_field(layout, row, column, label, widget, span_pairs)
column_pair += span_pairs
if column_pair >= 3:
row += 1
column_pair = 0
return layout
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):
if label:
layout.addWidget(QLabel(label), row, column)
layout.addWidget(widget, row, column + 1, 1, span_pairs * 2 - 1)
else:
layout.addWidget(widget, row, column, 1, span_pairs * 2)
def _section_title(self, text, object_name):
label = QLabel(text)
label.setObjectName(object_name)
label.setStyleSheet("color: #24292f; font-weight: 600; padding-top: 4px;")
return label
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()
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()
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,
db_path=_database_path(config=self.config),
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
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("设置已保存")
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,
"shopee_update": {
"test_item_id": str(self._compat_test_item_id or ""),
"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(),
"dry_run": False,
"parallel_accounts": self.parallel_accounts_checkbox.isChecked(),
"max_parallel_accounts": self.max_parallel_accounts_spin.value(),
},
}
)
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))
)
update_cfg = self._shopee_update_config()
self._compat_test_item_id = 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))
)
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()
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)} 秒")
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,
)
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)
class AccountsTab(QWidget):
COLUMNS = ["账号名", "别名", "地区", "端口", "登录状态", "备注"]
def __init__(self, parent=None, db_path=None, config=None, status_callback=None):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.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.delete_button.setObjectName("deleteAccountButton")
self.delete_button.setStyleSheet(_danger_outline_button_style("deleteAccountButton"))
self.launch_button = QPushButton("启动登录")
self.check_button = QPushButton("检测登录")
self.shortcut_button = QPushButton("快捷方式")
toolbar = QHBoxLayout()
for button in (
self.add_button,
self.edit_button,
self.delete_button,
self.launch_button,
self.check_button,
self.shortcut_button,
):
toolbar.addWidget(button)
toolbar.addStretch(1)
self.empty_label = QLabel("")
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(toolbar)
layout.addWidget(self.table, 1)
layout.addWidget(self.empty_label)
self.add_button.clicked.connect(self.add_account)
self.edit_button.clicked.connect(self.edit_account)
self.delete_button.clicked.connect(self.delete_account)
self.launch_button.clicked.connect(self.launch_login)
self.check_button.clicked.connect(self.check_login)
self.shortcut_button.clicked.connect(self.create_shortcut)
self.table.itemSelectionChanged.connect(self._update_button_state)
self.table.doubleClicked.connect(self.edit_account)
self.refresh_accounts()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def _selected_account(self):
selected = self.table.selectionModel().selectedRows()
if not selected:
return None
row = selected[0].row()
if row < 0 or row >= len(self.account_rows):
return None
return self.account_rows[row]
def _update_button_state(self):
has_selection = self._selected_account() is not None
for button in (
self.edit_button,
self.delete_button,
self.launch_button,
self.check_button,
self.shortcut_button,
):
button.setEnabled(has_selection)
def refresh_accounts(self):
try:
self.account_rows = accounts.list_accounts(
path=self.db_path,
config=self.config,
)
except Exception as exc:
self.account_rows = []
self._set_status(f"账号读取失败:{exc}")
self.table.setRowCount(len(self.account_rows))
for row, account in enumerate(self.account_rows):
status = self.login_statuses.get(account.alias, "未知")
values = [
account.account_name,
account.alias,
account.region_host,
str(account.debug_port),
_login_status_display(status),
account.note or "",
]
for column, value in enumerate(values):
item = QTableWidgetItem(value)
if column == 4:
item.setForeground(_login_status_color(status))
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()
try:
accounts.create_account(
path=self.db_path,
config=self.config,
**values,
)
except Exception as exc:
self._show_error(exc)
return
self.refresh_accounts()
self._set_status("账号已新增")
def edit_account(self, checked=False):
account = self._selected_account()
if account is None:
return
dialog = AccountDialog(
self,
account=account,
default_port=account.debug_port,
config=self.config,
)
if dialog.exec() != QDialog.Accepted:
return
values = dialog.values()
if self._should_warn_plaintext_password(values, account):
self._show_plaintext_password_warning()
try:
updated = accounts.update_account(
account.alias,
path=self.db_path,
config=self.config,
**values,
)
except Exception as exc:
self._show_error(exc)
return
if updated.alias != account.alias:
self.login_statuses.pop(account.alias, None)
self.refresh_accounts()
self._set_status("账号已保存")
def delete_account(self, checked=False):
account = self._selected_account()
if account is None:
return
answer = QMessageBox.question(
self,
"删除账号",
f"确认删除账号「{account.alias}」?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
return
try:
accounts.delete_account(account.alias, path=self.db_path, config=self.config)
except Exception as exc:
self._show_error(exc)
return
self.login_statuses.pop(account.alias, None)
self.refresh_accounts()
self._set_status("账号已删除")
def launch_login(self, checked=False):
account = self._selected_account()
if account is None:
return
run_id = _safe_create_run_log(
"chrome_launch",
db_path=self.db_path,
total=1,
options={"alias": account.alias, "debug_port": account.debug_port},
)
started = time.monotonic()
_safe_add_run_log_event(
run_id,
f"step=launch_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}",
db_path=self.db_path,
account=account,
)
try:
process = accounts.launch_for_login(account, config=self.config)
except Exception as exc:
elapsed_ms = _elapsed_ms(started)
safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
_safe_add_run_log_event(
run_id,
f"step=launch_chrome result=failed detail={safe_error} elapsed_ms={elapsed_ms}",
db_path=self.db_path,
account=account,
level="error",
)
_safe_write_diagnostic_log(
"Chrome启动失败",
level="ERROR",
step="launch_chrome",
account=account,
elapsed_ms=elapsed_ms,
payload={"alias": account.alias, "debug_port": account.debug_port, "error": safe_error},
exc=exc,
log_dir=diagnostics.DEFAULT_LOG_DIR,
)
_safe_finish_run_log(
run_id,
db_path=self.db_path,
status="failed",
done=0,
failed_count=1,
summary_json={"ok": False, "alias": account.alias, "error": safe_error},
)
self._show_error(safe_error)
return
elapsed_ms = _elapsed_ms(started)
pid = getattr(process, "pid", None)
_safe_add_run_log_event(
run_id,
f"step=launch_chrome result=success detail=账号 {account.alias} pid={pid or ''} elapsed_ms={elapsed_ms}",
db_path=self.db_path,
account=account,
)
_safe_finish_run_log(
run_id,
db_path=self.db_path,
status="done",
done=1,
success_count=1,
failed_count=0,
summary_json={"ok": True, "alias": account.alias, "debug_port": account.debug_port, "pid": pid},
)
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,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
worker.finished.connect(self._on_login_check_finished)
worker.failed.connect(
lambda _task_id, error, alias=account.alias: self._on_login_check_failed(
alias,
error,
)
)
thread = run_worker(worker, start=False)
thread.finished.connect(lambda: self._forget_thread(thread))
self.threads.append(thread)
thread.start()
def _forget_thread(self, thread):
if thread in self.threads:
self.threads.remove(thread)
def _on_login_check_finished(self, payload):
if payload.get("ok") is False and not payload.get("alias"):
return
alias = payload.get("alias")
status = payload.get("status") or {}
if alias:
self.login_statuses[alias] = accounts.login_status_text(status)
self.refresh_accounts()
self._set_status("登录状态已刷新")
def _on_login_check_failed(self, alias, error):
self.login_statuses[alias] = "检测失败"
self.refresh_accounts()
self._set_status(f"登录检测失败:{error}")
def create_shortcut(self, checked=False):
account = self._selected_account()
if account is None:
return
try:
shortcut_path = accounts.create_shortcut(account, config=self.config)
except Exception as exc:
self._show_error(exc)
return
self._set_status(f"快捷方式已生成:{shortcut_path}")
QMessageBox.information(self, "账号管理", f"快捷方式已生成:\n{shortcut_path}")
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,
)
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):
super().__init__()
self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config
self.config_path = (
config_path
or self.config.get("config_path")
or appconfig.CONFIG_PATH
)
self.db_path = _database_path(db_path, self.config)
self.ai_models_path = (
ai_models_path
or self.config.get("ai_models_path")
or appconfig.AI_MODELS_PATH
)
self.setWindowTitle("cmshopee")
self.resize(1180, 760)
self.tabs = QTabWidget()
self.tabs.setObjectName("mainTabs")
self.tabs.setStyleSheet(TAB_STYLE)
self.tabs.currentChanged.connect(self._on_tab_changed)
for title in TAB_TITLES:
self.tabs.addTab(self._build_tab(title), title)
self.tabs.setTabIcon(TAB_TITLES.index("③ 更新shopee"), _warning_dot_icon())
self.setCentralWidget(self.tabs)
self.statusBar().showMessage("就绪")
def _build_tab(self, title):
if title == "① 导入采集":
return CollectTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
open_accounts_callback=lambda: self.open_accounts_tab(),
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
)
if title == "② AI生成":
return GenerateTab(
db_path=self.db_path,
config=self.config,
config_path=self.config_path,
status_callback=self.statusBar().showMessage,
open_accounts_callback=lambda: self.open_accounts_tab(),
)
if title == "③ 更新shopee":
return ApplyTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
open_accounts_callback=lambda: self.open_accounts_tab(),
open_settings_callback=lambda: self.open_settings_tab(),
)
if title == "④ 账号管理":
return AccountsTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
)
return SettingsTab(
config=self.config,
config_path=self.config_path,
ai_models_path=self.ai_models_path,
status_callback=self.statusBar().showMessage,
)
def refresh_task_tabs(self):
for index in range(self.tabs.count()):
widget = self.tabs.widget(index)
if hasattr(widget, "refresh_tasks"):
widget.refresh_tasks()
def _on_tab_changed(self, index):
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
def open_accounts_tab(self):
self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理"))
def open_settings_tab(self):
self.tabs.setCurrentIndex(TAB_TITLES.index("⑤ 设置"))
else:
class MainWindow(QMainWindow):
def __init__(self):
raise RuntimeError("PySide6 未安装,无法启动 GUI")
def _ensure_offscreen_for_headless_tests():
if "PYTEST_CURRENT_TEST" in os.environ and "QT_QPA_PLATFORM" not in os.environ:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
def main() -> int:
if QT_IMPORT_ERROR is not None:
print("cmshopee GUI 无法启动:当前 Python 环境未安装 PySide6。")
return 1
_ensure_offscreen_for_headless_tests()
app = QApplication.instance() or QApplication(sys.argv)
window = MainWindow()
window.show()
return app.exec()