Files
cmshoppe/app/gui.py
T

4631 lines
192 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 QPixmap
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QGridLayout,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPlainTextEdit,
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;
}
"""
if QT_IMPORT_ERROR is None:
from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, 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 _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
class TaskTableModel(QAbstractTableModel):
"""Table model for task rows shared by workflow tabs."""
HEADERS = ["账号", "别名", "商品ID", "阶段"]
STAGE_TEXT = {
"imported": "待采集",
"collected": "已采集",
"generated": "已生成",
"applied": "已更新",
}
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
}
def __init__(self, parent=None):
super().__init__(parent)
self.tasks = []
self.all_tasks = []
self.account_by_alias = {}
self.filter_mode = "all"
def set_tasks(self, tasks, accounts):
self.beginResetModel()
self.all_tasks = list(tasks)
self.account_by_alias = {
str(account.alias).strip(): account
for account in accounts
if str(account.alias).strip()
}
self.tasks = self._filtered_tasks()
self.endResetModel()
def set_filter_mode(self, mode):
self.beginResetModel()
self.filter_mode = mode if mode in {"all", "unmatched"} else "all"
self.tasks = self._filtered_tasks()
self.endResetModel()
def _filtered_tasks(self):
if self.filter_mode == "unmatched":
return [task for task in self.all_tasks if self.is_unmatched(task)]
return list(self.all_tasks)
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.tasks)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.HEADERS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal and 0 <= section < len(self.HEADERS):
return self.HEADERS[section]
return section + 1 if orientation == Qt.Vertical else None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
task = self.tasks[index.row()]
if role == Qt.DisplayRole:
return self._display_value(task, index.column())
if role == Qt.ToolTipRole and self.is_unmatched(task):
return "别名未匹配账号,采集时将略过"
return None
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def is_unmatched(self, task) -> bool:
return str(task.alias).strip() not in self.account_by_alias
def unmatched_count(self) -> int:
return sum(1 for task in self.all_tasks if self.is_unmatched(task))
def _account_name(self, task) -> str:
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
return account.account_name
return task.account_name or ""
def _stage_text(self, task) -> str:
if self.is_unmatched(task):
return "略过"
if task.status in self.STATUS_TEXT and task.status != "pending":
return self.STATUS_TEXT[task.status]
return self.STAGE_TEXT.get(task.stage, task.stage)
def _display_value(self, task, column):
values = [
self._account_name(task),
task.alias,
task.item_id,
self._stage_text(task),
]
return values[column] if 0 <= column < len(values) else None
class GenerateTaskTableModel(QAbstractTableModel):
"""Table model for Tab 2 generation candidates."""
HEADERS = ["店铺", "商品ID", "旧标题", "新标题", "状态"]
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
}
STAGE_TEXT = {
"imported": "未采集",
"collected": "待生成",
"generated": "已生成",
"applied": "已更新",
}
def __init__(self, parent=None):
super().__init__(parent)
self.tasks = []
self.account_by_alias = {}
def set_tasks(self, tasks, accounts):
self.beginResetModel()
self.tasks = list(tasks)
self.account_by_alias = {
str(account.alias).strip(): account
for account in accounts
if str(account.alias).strip()
}
self.endResetModel()
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.tasks)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.HEADERS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal and 0 <= section < len(self.HEADERS):
return self.HEADERS[section]
return section + 1 if orientation == Qt.Vertical else None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
task = self.tasks[index.row()]
if role == Qt.DisplayRole:
return self._display_value(task, index.column())
if role == Qt.ToolTipRole and task.last_error:
return task.last_error
return None
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def account_name_for(self, task):
return self._account_name(task)
def _account_name(self, task):
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
return account.account_name
return task.account_name or task.alias or ""
def _status_text(self, task):
if task.status in self.STATUS_TEXT and task.status != "pending":
return self.STATUS_TEXT[task.status]
return self.STAGE_TEXT.get(task.stage, task.stage)
def _display_value(self, task, column):
values = [
self._account_name(task),
task.item_id,
task.old_title or "",
task.new_title or "",
self._status_text(task),
]
return values[column] if 0 <= column < len(values) else None
class ApplyTaskTableModel(QAbstractTableModel):
"""Table model for Tab 3 update candidates."""
HEADERS = ["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"]
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
"pending": "待更新",
"success": "成功",
}
def __init__(self, parent=None):
super().__init__(parent)
self.tasks = []
self.account_by_alias = {}
def set_tasks(self, tasks, accounts):
self.beginResetModel()
self.tasks = list(tasks)
self.account_by_alias = {
str(account.alias).strip(): account
for account in accounts
if str(account.alias).strip()
}
self.endResetModel()
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.tasks)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.HEADERS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal and 0 <= section < len(self.HEADERS):
return self.HEADERS[section]
return section + 1 if orientation == Qt.Vertical else None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
task = self.tasks[index.row()]
if role == Qt.DisplayRole:
return self._display_value(task, index.column())
if role == Qt.ToolTipRole and task.last_error:
return task.last_error
return None
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def account_name_for(self, task):
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
return account.account_name
return task.account_name or task.alias or ""
def _display_value(self, task, column):
values = [
self.account_name_for(task),
task.item_id,
task.new_title or "",
os.path.basename(task.new_cover_path or ""),
self._stage_text(task),
self._result_text(task),
]
return values[column] if 0 <= column < len(values) else None
def _stage_text(self, task):
if task.stage == "generated":
return "待更新"
if task.stage == "applied":
return "已更新"
return task.stage
def _result_text(self, task):
if task.status == "success" and task.stage == "generated":
return "待更新"
return self.STATUS_TEXT.get(task.status, task.status)
class GenerateTab(QWidget):
"""Tab 2: prompt area plus generation task filters/list."""
STATUS_FILTERS = [
("全部状态", "all"),
("待生成", "to_generate"),
("已生成", "generated"),
("失败", "failed"),
("略过", "skipped"),
("已更新", "applied"),
]
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
title_prompt_path=None,
cover_prompts_dir=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.title_prompt_path = title_prompt_path or prompts.TITLE_PROMPT_PATH
self.cover_prompts_dir = cover_prompts_dir or prompts.COVER_PROMPTS_DIR
self.current_cover_template = None
self.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.save_cover_template_as_button = QPushButton("另存为")
self.rename_cover_template_button = QPushButton("重命名")
self.delete_cover_template_button = QPushButton("删除")
self.insert_title_button = QPushButton("插入标题")
self.preview_prompt_button = QPushButton("预览")
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)
self.progress_label = QLabel("进度:标题0/0 · 封面0/0 · 失败0")
left_panel = QWidget()
left_layout = QVBoxLayout(left_panel)
left_layout.setContentsMargins(0, 0, 12, 0)
left_layout.addWidget(QLabel("标题提示词"))
left_layout.addWidget(self.title_prompt_edit, 1)
left_layout.addWidget(self.save_title_button)
left_layout.addWidget(QLabel("封面提示词"))
left_layout.addWidget(self.cover_template_combo)
cover_template_layout = QHBoxLayout()
cover_template_layout.addWidget(self.new_cover_template_button)
cover_template_layout.addWidget(self.save_cover_template_button)
cover_template_layout.addWidget(self.save_cover_template_as_button)
cover_template_layout.addWidget(self.rename_cover_template_button)
cover_template_layout.addWidget(self.delete_cover_template_button)
left_layout.addLayout(cover_template_layout)
left_layout.addWidget(self.cover_prompt_edit, 2)
cover_action_layout = QHBoxLayout()
cover_action_layout.addWidget(self.insert_title_button)
cover_action_layout.addWidget(self.preview_prompt_button)
left_layout.addLayout(cover_action_layout)
self.batch_filter = QComboBox()
self.batch_filter.setObjectName("batchFilter")
self.shop_filter = QComboBox()
self.shop_filter.setObjectName("shopFilter")
self.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.task_table = QTableView()
self.model = GenerateTaskTableModel(self.task_table)
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.task_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False)
self.run_log_view = QPlainTextEdit()
self.run_log_view.setObjectName("generateRunLogView")
self.run_log_view.setReadOnly(True)
self.run_log_view.setMaximumHeight(128)
self.run_log_view.setPlaceholderText("AI生成运行日志")
right_panel = QWidget()
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(12, 0, 0, 0)
right_layout.addLayout(filter_layout)
right_layout.addWidget(self.summary_label)
right_layout.addWidget(self.task_table, 1)
right_layout.addWidget(QLabel("AI生成运行日志"))
right_layout.addWidget(self.run_log_view)
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])
bottom_layout = QHBoxLayout()
bottom_layout.addWidget(self.progress_label)
bottom_layout.addStretch(1)
bottom_layout.addWidget(self.generate_button)
bottom_layout.addWidget(self.stop_generate_button)
bottom_layout.addWidget(self.reset_generate_button)
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_button.clicked.connect(self.save_cover_template_as)
self.rename_cover_template_button.clicked.connect(self.rename_cover_template)
self.delete_cover_template_button.clicked.connect(self.delete_cover_template)
self.insert_title_button.clicked.connect(self.insert_title_placeholder)
self.preview_prompt_button.clicked.connect(self.preview_cover_prompt)
self.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))
def _load_latest_generate_run_log(self):
try:
logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path)
if not logs:
return
events = db.list_run_log_events(logs[0].id, limit=40, path=self.db_path)
except Exception:
return
lines = [
f"{event.created_at} [{event.level}] {event.message}"
for event in events
]
self.run_log_view.setPlainText("\n".join(lines))
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
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, "failed": 0}
)
self._set_status(f"开始 AI 生成:{len(tasks)} 条")
thread.start()
def stop_generate(self, checked=False):
if self.generate_worker is not None:
self.generate_worker.cancel()
self._set_status("正在停止 AI 生成...")
def 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):
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.save_cover_template_button.setEnabled(not running)
self.save_cover_template_as_button.setEnabled(not running)
self.rename_cover_template_button.setEnabled(not running)
self.delete_cover_template_button.setEnabled(not running)
def _forget_generate_thread(self, thread):
if self.generate_thread is thread:
self.generate_thread = None
self.generate_worker = None
def _on_generate_progress(self, payload):
self._update_generate_progress(payload)
self._set_status("生成进度:" + self._generate_progress_text(payload))
def _on_generate_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_generate_failed(self, task_id, error):
self._set_status(f"AI 生成失败:{error}")
def _on_generate_finished(self, payload):
self._set_generate_running(False)
self.refresh_tasks()
self._load_latest_generate_run_log()
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):
self.progress_label.setText("进度:" + self._generate_progress_text(payload))
def _generate_progress_text(self, payload):
return "标题{title}/{total} · 封面{cover}/{total} · 失败{failed}".format(
title=payload.get("title_done", 0),
cover=payload.get("cover_done", 0),
total=payload.get("total", 0),
failed=payload.get("failed", 0),
)
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("任务读取失败")
self._set_status(f"AI 生成任务读取失败:{exc}")
return
self.model.set_tasks(filtered_tasks, accounts_rows)
self.summary_label.setText(
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
)
def _populate_batch_filter(self, batches, selected_batch):
previous = selected_batch if selected_batch in {batch.id for batch in batches} else None
self.batch_filter.blockSignals(True)
self.batch_filter.clear()
self.batch_filter.addItem("全部批次", None)
for batch in batches:
self.batch_filter.addItem(self._batch_label(batch), batch.id)
index = self.batch_filter.findData(previous)
self.batch_filter.setCurrentIndex(index if index >= 0 else 0)
self.batch_filter.blockSignals(False)
def _populate_shop_filter(self, tasks, account_rows, selected_shop):
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
aliases = []
for task in tasks:
alias = str(task.alias).strip()
if alias and alias not in aliases:
aliases.append(alias)
previous = selected_shop if selected_shop in aliases else None
self.shop_filter.blockSignals(True)
self.shop_filter.clear()
self.shop_filter.addItem("全部店铺", None)
for alias in sorted(aliases, key=lambda value: self._shop_label(value, account_by_alias)):
self.shop_filter.addItem(self._shop_label(alias, account_by_alias), alias)
index = self.shop_filter.findData(previous)
self.shop_filter.setCurrentIndex(index if index >= 0 else 0)
self.shop_filter.blockSignals(False)
def _batch_label(self, batch):
source_files = batch.source_files
first_file = os.path.basename(source_files[0]) if source_files else batch.id
return f"{batch.created_at} · {first_file}"
def _shop_label(self, alias, account_by_alias):
account = account_by_alias.get(alias)
if account is not None:
return f"{account.account_name} ({alias})"
return alias
def _matches_shop(self, task, selected_shop):
return selected_shop is None or str(task.alias).strip() == selected_shop
def _matches_item(self, task, item_query):
if not item_query:
return True
return item_query in str(getattr(task, "item_id", ""))
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,
):
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.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.risk_label = QLabel("点击「开始更新」后会先确认当前筛选范围;确认后才允许后续任务提交线上。")
self.task_table = QTableView()
self.model = ApplyTaskTableModel(self.task_table)
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.task_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False)
self.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.start_update_button = QPushButton("开始更新")
self.stop_update_button = QPushButton("停止")
self.reset_update_button = QPushButton("重置更新状态")
self.reset_update_button.setObjectName("resetUpdateButton")
self.write_back_button = QPushButton("回写结果到 Excel")
self.stop_update_button.setEnabled(False)
self.write_back_button.setEnabled(False)
action_layout = QHBoxLayout()
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
action_layout.addWidget(self.reset_update_button)
action_layout.addStretch(1)
action_layout.addWidget(self.write_back_button)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(filter_layout)
layout.addWidget(self.risk_label)
layout.addWidget(self.summary_label)
layout.addWidget(self.task_table, 1)
layout.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.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.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()
batch_tasks = [
task for task in db.list_tasks(batch_id=selected_batch, path=self.db_path)
if self._is_update_task(task)
]
self._populate_shop_filter(batch_tasks, account_rows, selected_shop)
selected_shop = self.shop_filter.currentData()
filtered_tasks = [
task for task in batch_tasks
if self._matches_shop(task, selected_shop)
and self._matches_item(task, item_query)
and self._matches_status(task, selected_status)
]
except Exception as exc:
self.model.set_tasks([], [])
self.summary_label.setText("任务读取失败")
self._set_status(f"更新任务读取失败:{exc}")
return
self.model.set_tasks(filtered_tasks, account_rows)
self.summary_label.setText(
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
)
self._update_write_back_button()
def start_update(self, checked=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(update_cfg.get("dry_run", False))
safety_error = self._update_safety_error(tasks, dry_run=dry_run)
if safety_error:
QMessageBox.warning(self, "更新安全开关", safety_error)
self._set_status(safety_error.replace("\n", " "))
return
answer = QMessageBox.question(
self,
"确认开始更新",
self._confirmation_message(tasks),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消开始更新")
return
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),
),
)
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"开始 dry-run 预览:{len(tasks)} 条")
else:
self._set_status(f"开始更新:{len(tasks)} 条")
thread.start()
def stop_update(self, checked=False):
if self.apply_worker is not None:
self.apply_worker.cancel()
self._set_status("正在停止更新...")
def 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 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):
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 "否"
dry_run_text = "开启" if update_cfg.get("dry_run") else "关闭"
parallel_text = (
f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号"
if update_cfg.get("parallel_accounts")
else "关闭"
)
return (
"即将按当前筛选结果开始更新 Shopee 线上商品。\n\n"
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\n"
"安全设置:"
f"测试商品ID={update_cfg.get('test_item_id') or '未配置'},"
f"封面更新={cover_text},"
f"最大条数={update_cfg.get('max_items_per_run', 1)},"
f"成功后关闭新页={close_text},"
f"dry-run={dry_run_text},"
f"多账号并行={parallel_text}\n\n"
+ (
"dry-run 开启时只写运行日志和预览,不打开 Shopee、不点击「更新」、不改任务状态。"
if update_cfg.get("dry_run")
else "确认后后续执行会打开商品编辑页、替换标题/允许时替换封面,并点击「更新」提交线上。"
)
)
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 "设置未开启「允许真实提交线上商品」,已阻止本次更新。"
max_items = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
if len(tasks) > max_items:
return f"当前筛选结果有 {len(tasks)} 条,超过单次最大更新条数 {max_items}。"
test_item_id = str(update_cfg.get("test_item_id", "")).strip()
if not test_item_id:
return "未配置测试商品ID,已阻止真实提交。"
mismatched = [
str(getattr(task, "item_id", ""))
for task in tasks
if str(getattr(task, "item_id", "")) != test_item_id
]
if mismatched:
shown = "、".join(mismatched[:5])
return f"当前任务包含非测试商品ID:{shown}。只允许更新测试商品 {test_item_id}。"
if not update_cfg.get("allow_cover_update", False):
cover_tasks = [
str(getattr(task, "item_id", ""))
for task in tasks
if getattr(task, "new_cover_path", None)
]
if cover_tasks:
return "设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。"
return None
def _shopee_update_config(self):
defaults = appconfig.default_config().get("shopee_update", {})
loaded = self.config.get("shopee_update", {})
if not isinstance(loaded, dict):
loaded = {}
merged = dict(defaults)
merged.update(loaded)
return merged
def _set_apply_running(self, 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.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 = "dry-run 预览完成:" 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 events
]
self.run_log_view.setPlainText("\n".join(lines))
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,并确认已人工登录 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")
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,
"dry-run 预览完成" 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 = [
"dry-run 预览完成,未打开 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."""
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
open_accounts_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.current_batch_id = None
self.has_import_result = False
self.last_import_stats = None
self.collect_worker = None
self.collect_thread = None
self.write_back_worker = None
self.write_back_thread = None
self.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)
toolbar = QHBoxLayout()
toolbar.addWidget(self.import_button)
toolbar.addWidget(self.refresh_button)
toolbar.addWidget(self.collect_button)
toolbar.addWidget(self.stop_collect_button)
toolbar.addWidget(self.write_back_button)
toolbar.addStretch(1)
self.summary_label = QLabel("未导入任务")
self.match_detail_label = QLabel("")
self.show_all_button = QPushButton("全部")
self.show_unmatched_button = QPushButton("未匹配(0)")
summary_layout = QHBoxLayout()
summary_layout.addWidget(self.summary_label)
summary_layout.addStretch(1)
summary_layout.addWidget(self.show_all_button)
summary_layout.addWidget(self.show_unmatched_button)
self.table = QTableView()
self.model = TaskTableModel(self.table)
self.table.setModel(self.model)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.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("")
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(toolbar)
layout.addLayout(summary_layout)
layout.addWidget(self.match_detail_label)
layout.addWidget(self.table, 1)
layout.addWidget(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.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 events
]
self.run_log_view.setPlainText("\n".join(lines))
def _log_collect_run_event(self, run_id, message, level="info"):
safe_message = diagnostics.redact_log_text(message)
try:
db.add_run_log_event(run_id, safe_message, level=level, path=self.db_path)
except Exception:
return
self._append_collect_log(safe_message)
def _show_error(self, message):
QMessageBox.warning(self, "导入采集", str(message))
self._set_status(str(message))
def _show_account_guide(self, message):
full_message = (
f"{message}\n\n"
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。"
)
QMessageBox.warning(self, "账号未就绪", full_message)
self._set_status(full_message.replace("\n", " "))
if self.open_accounts_callback is not None:
self.open_accounts_callback()
def _choose_excel_files(self):
files, _selected_filter = QFileDialog.getOpenFileNames(
self,
"选择 Excel 文件",
"",
"Excel 文件 (*.xlsx *.xlsm)",
)
return files
def import_excel(self, checked=False):
file_paths = self._choose_excel_files()
if not file_paths:
return
try:
result = excel.import_tasks(file_paths, path=self.db_path)
except Exception as exc:
self._show_error(exc)
return
self.has_import_result = True
self.last_import_stats = result.get("stats") or {}
self.current_batch_id = result.get("batch_id")
self.refresh_tasks()
self._set_status(
"导入完成:有效{valid},无效{invalid},入库{inserted},未匹配{unmatched}".format(
valid=self.last_import_stats.get("valid", 0),
invalid=self.last_import_stats.get("invalid", 0),
inserted=self.last_import_stats.get("inserted", 0),
unmatched=self.model.unmatched_count(),
)
)
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
if self.current_batch_id is None and self.has_import_result:
task_rows = []
else:
task_rows = db.list_tasks(batch_id=self.current_batch_id, path=self.db_path)
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
except Exception as exc:
self.model.set_tasks([], [])
self.empty_label.setText("任务读取失败")
self._set_status(f"任务读取失败:{exc}")
return
self.model.set_tasks(task_rows, account_rows)
self._update_summary(task_rows, account_rows)
self._update_empty_label(len(task_rows))
def collect_old_data(self, checked=False):
tasks = list(self.model.all_tasks)
if not tasks:
self._set_status("没有可采集任务")
return
worker = CollectWorker(
tasks,
db_path=self.db_path,
config=self.config,
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)
worker.failed.connect(
lambda task_id, error, auto=auto: self._on_write_back_failed(
task_id,
error,
auto=auto,
)
)
worker.finished.connect(
lambda payload, auto=auto: self._on_write_back_finished(
payload,
auto=auto,
)
)
thread = run_worker(worker, thread_name="WriteBackWorker", start=False)
thread.finished.connect(lambda: self._forget_write_back_thread(thread))
self.write_back_worker = worker
self.write_back_thread = thread
self._set_write_back_running(True)
self._set_status("正在自动回写旧数据到 Excel..." if auto else "正在回写旧数据到 Excel...")
thread.start()
return True
def _active_batch_id(self):
if self.current_batch_id:
return self.current_batch_id
batch_ids = {
task.batch_id
for task in self.model.all_tasks
if getattr(task, "batch_id", None)
}
if len(batch_ids) == 1:
return next(iter(batch_ids))
return None
def _set_collect_running(self, running):
self.import_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.collect_button.setEnabled(not running)
self.write_back_button.setEnabled(not running)
self.stop_collect_button.setEnabled(running)
def _set_write_back_running(self, running):
self.import_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.collect_button.setEnabled(not running)
self.write_back_button.setEnabled(not running)
def _forget_collect_thread(self, thread):
if self.collect_thread is thread:
self.collect_thread = None
self.collect_worker = None
def _forget_write_back_thread(self, thread):
if self.write_back_thread is thread:
self.write_back_thread = None
self.write_back_worker = None
def _on_collect_progress(self, payload):
self._set_status(
"采集进度:{done}/{total},成功{collected},略过{skipped},失败{failed}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
collected=payload.get("collected", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
)
def _on_collect_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_collect_failed(self, task_id, error):
self._set_status(f"任务 {task_id} 采集失败:{error}")
def _on_collect_finished(self, payload):
self._set_collect_running(False)
self.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)
self.summary_label.setText(
f"{files} 文件 · {total} 行 · 有效{valid}/无效{invalid} · 匹配{matched} · 未匹配{unmatched}"
)
self.match_detail_label.setText(self._match_detail(task_rows, account_rows))
self.show_unmatched_button.setText(f"未匹配({unmatched})")
self.show_unmatched_button.setEnabled(unmatched > 0)
if unmatched == 0 and self.model.filter_mode == "unmatched":
self.model.set_filter_mode("all")
def _match_detail(self, task_rows, account_rows):
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
counts = {}
for task in task_rows:
account = account_by_alias.get(str(task.alias).strip())
if account is None:
continue
name = account.account_name or account.alias
counts[name] = counts.get(name, 0) + 1
if not counts:
return "匹配明细:无"
parts = [f"{name}{count}" for name, count in sorted(counts.items())]
return "匹配明细:" + " · ".join(parts)
def _unmatched_count(self, task_rows, account_rows):
aliases = {
str(account.alias).strip()
for account in account_rows
if str(account.alias).strip()
}
return sum(1 for task in task_rows if str(task.alias).strip() not in aliases)
def _update_empty_label(self, total_rows):
if total_rows == 0:
self.empty_label.setText("暂无任务")
return
if self.model.rowCount() == 0 and self.model.filter_mode == "unmatched":
self.empty_label.setText("没有未匹配任务")
return
unmatched = self.model.unmatched_count()
self.empty_label.setText(
"" if unmatched == 0 else f"{unmatched} 条任务别名未匹配账号,阶段显示为“略过”"
)
class AccountDialog(QDialog):
"""Dialog for adding or editing one account."""
def __init__(self, parent=None, account=None, default_port=9222, config=None):
super().__init__(parent)
self._account = account
self._config = config
self.setWindowTitle("编辑账号" if account else "新增账号")
self.account_name_edit = QLineEdit()
self.alias_edit = QLineEdit()
self.region_host_edit = QLineEdit(accounts.DEFAULT_REGION_HOST)
self.debug_port_spin = QSpinBox()
self.debug_port_spin.setRange(1, 65535)
self.debug_port_spin.setValue(int(default_port))
self.password_edit = QLineEdit()
self.password_edit.setEchoMode(QLineEdit.Password)
self.note_edit = QPlainTextEdit()
self.note_edit.setMaximumHeight(76)
self.slug_edit = QLineEdit()
self.slug_edit.setReadOnly(True)
self.user_data_dir_edit = QLineEdit()
self.user_data_dir_edit.setReadOnly(True)
if account is not None:
self.account_name_edit.setText(account.account_name)
self.alias_edit.setText(account.alias)
self.region_host_edit.setText(account.region_host)
self.debug_port_spin.setValue(int(account.debug_port))
self.password_edit.setText(account.password or "")
self.note_edit.setPlainText(account.note or "")
self.slug_edit.setText(account.slug)
self.user_data_dir_edit.setText(account.user_data_dir)
form = QFormLayout()
form.addRow("账号名", self.account_name_edit)
form.addRow("别名", self.alias_edit)
form.addRow("地区", self.region_host_edit)
form.addRow("调试端口", self.debug_port_spin)
form.addRow("密码", self.password_edit)
form.addRow("备注", self.note_edit)
form.addRow("slug", self.slug_edit)
form.addRow("数据目录", self.user_data_dir_edit)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
layout = QVBoxLayout(self)
layout.addLayout(form)
layout.addWidget(buttons)
self.alias_edit.textChanged.connect(self._update_path_preview)
self._update_path_preview()
def _update_path_preview(self):
alias = self.alias_edit.text().strip()
if not alias:
self.slug_edit.clear()
self.user_data_dir_edit.clear()
return
try:
slug = account_config.make_slug(alias)
self.slug_edit.setText(slug)
if self._account is not None and alias == self._account.alias:
self.user_data_dir_edit.setText(self._account.user_data_dir)
else:
self.user_data_dir_edit.setText(
accounts.preview_user_data_dir(alias, config=self._config)
)
except Exception:
self.slug_edit.clear()
self.user_data_dir_edit.clear()
def values(self):
return {
"account_name": self.account_name_edit.text().strip(),
"alias": self.alias_edit.text().strip(),
"region_host": self.region_host_edit.text().strip(),
"debug_port": self.debug_port_spin.value(),
"password": self.password_edit.text(),
"note": self.note_edit.toPlainText().strip(),
}
from .workers import BaseWorker, run_worker
class 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
def execute(self):
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
eligible = [
task for task in self.tasks
if getattr(task, "stage", None) == "collected"
]
batch_ids = self._batch_ids(eligible)
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
f"phase=preflight step=start result=start detail=AI生成开始 total={len(eligible)}"
)
try:
summary = ai.generate_batch(
self.tasks,
self.prompt_values,
ai_cfg={
"config": self.config,
"db_path": self.db_path,
"image_dir": appconfig.image_dir(self.config),
"account_by_alias": account_by_alias,
"on_task_update": self._emit_row_update,
"on_event": self._on_generation_event,
"on_error": self._on_generation_error,
},
on_progress=self.progress.emit,
should_stop=self.should_cancel,
)
except Exception as exc:
error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__)
summary = {
"ok": False,
"error": error,
"total": len(eligible),
"title_done": 0,
"cover_done": 0,
"failed": len(eligible),
"cancelled": self.should_cancel(),
}
self._log_run_event(
f"phase=worker step=execute result=failed detail={error}",
level="error",
)
self._write_diagnostic_log(
"AI生成运行失败",
level="ERROR",
step="execute",
payload={"error": error},
exc=exc,
)
summary["run_id"] = self._run_id
summary["batch_ids"] = batch_ids
status = "cancelled" if summary.get("cancelled") else "done"
self._finish_run_log(status, summary)
return summary
def _emit_row_update(self, task_id, fields):
self.row_updated.emit(int(task_id), dict(fields or {}))
def _on_generation_event(self, payload):
task = payload.get("task")
phase = payload.get("phase") or "generate"
step = payload.get("step") or "unknown"
result = payload.get("result") or "start"
detail = payload.get("detail")
message = f"phase={phase} step={step} result={result}"
if detail:
message += f" detail={detail}"
self._log_run_event(message, task=task, level=payload.get("level") or "info")
def _on_generation_error(self, payload):
task = payload.get("task")
phase = payload.get("phase") or "generate"
step = payload.get("step") or "unknown"
error = diagnostics.redact_log_text(payload.get("error") or "未知错误")
self._write_diagnostic_log(
"AI生成任务失败",
level="ERROR",
step=step,
task=task,
payload={"phase": phase, "error": error},
exc=payload.get("exception"),
)
def _batch_ids(self, tasks):
batch_ids = []
for task in tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
def _create_run_log(self, eligible, batch_ids):
try:
ai_cfg = appconfig.ai_config(self.config)
return db.create_run_log(
"generate",
dry_run=False,
total=len(eligible),
options={
"batch_ids": batch_ids,
"default_text_model": ai_cfg.get("default_text_model"),
"default_image_model": ai_cfg.get("default_image_model"),
"resolution": ai_cfg.get("resolution"),
"title_concurrency": ai_cfg.get("title_concurrency"),
"image_concurrency": ai_cfg.get("image_concurrency"),
},
path=self.db_path,
)
except Exception:
return None
def _finish_run_log(self, status, summary):
if self._run_id is None:
return
try:
done = int(summary.get("cover_done", 0) or 0) + int(summary.get("failed", 0) or 0)
db.finish_run_log(
self._run_id,
status=status,
done=done,
success_count=summary.get("cover_done", 0),
skipped_count=0,
failed_count=summary.get("failed", 0),
summary_json=summary,
path=self.db_path,
)
except Exception:
return
def _log_run_event(self, message, task=None, level="info"):
safe_message = diagnostics.redact_log_text(message)
self.log.emit(str(safe_message))
if self._run_id is None:
return
try:
db.add_run_log_event(
self._run_id,
safe_message,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
level=level,
path=self.db_path,
)
except Exception:
return
def _write_diagnostic_log(
self,
message,
level="INFO",
step=None,
task=None,
payload=None,
exc=None,
):
try:
diagnostics.write_diagnostic_log(
message,
level=level,
step=step,
task_id=getattr(task, "id", None),
alias=getattr(task, "alias", None),
item_id=getattr(task, "item_id", None),
payload=payload,
exc=exc,
log_dir=self.diagnostic_log_dir,
)
except Exception:
return
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,
):
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._progress_lock = threading.Lock()
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)
applied = 0
skipped = 0
failed = 0
done = 0
counters = {
"done": done,
"applied": applied,
"skipped": skipped,
"failed": failed,
}
self._run_id = self._create_run_log(eligible, batch_ids)
self._log_run_event(
"运行开始:{mode},任务{total},{parallel}".format(
mode="dry-run 预览" if self.dry_run else "真实更新",
total=total,
parallel=(
f"多账号并行最多{self.max_parallel_accounts}"
if self.parallel_accounts
else "串行"
),
)
)
if self.preflight and not self.dry_run:
blocked = self._preflight_block(eligible, account_rows, account_by_alias)
if blocked:
summary = self._summary(
ok=False,
total=total,
counters=counters,
batch_ids=batch_ids,
blocked=True,
extra=blocked,
)
self._finish_run_log("blocked", summary)
return summary
if self.dry_run:
for task in eligible:
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(eligible, account_by_alias, counters, total)
else:
for task in eligible:
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:
if not chrome.is_running(account.debug_port):
not_running.append(self._account_payload(account, "CDP 端口未响应"))
continue
status = self._login_status(account)
if not status.get("logged_in"):
logged_out.append(
self._account_payload(account, self._login_skip_reason(status))
)
if not_running or logged_out:
return {
"reason": "ACCOUNT_NOT_READY",
"not_running": not_running,
"logged_out": logged_out,
}
return None
def _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 _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"dry-run:任务 {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(
"dry-run:任务 {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"任务 {task.id} 商品 {task.item_id} 已略过:{reason}",
task=task,
level="warning",
)
return "skipped"
try:
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}",
task=task,
)
db.mark_running(task.id, "apply", path=self.db_path)
self.row_updated.emit(task.id, {"status": "running", "last_error": None})
result = editor.apply_task(
account,
task,
close_success_tab=self.close_success_tab,
)
committed = bool(result.get("committed")) and not result.get("error")
error = result.get("error")
if committed:
db.set_applied(task.id, True, path=self.db_path)
self.row_updated.emit(
task.id,
{
"stage": "applied",
"status": "success",
"committed": 1,
"last_error": None,
},
)
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 更新成功",
task=task,
)
return "applied"
error = error or "更新未提交"
db.set_applied(task.id, False, error, path=self.db_path)
self.failed.emit(task.id, str(error))
self.row_updated.emit(
task.id,
{"status": "failed", "last_error": str(error), "committed": 0},
)
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 更新失败:{error}",
task=task,
level="error",
)
return "failed"
except Exception as exc:
error = str(exc) or exc.__class__.__name__
db.set_applied(task.id, False, error, path=self.db_path)
self.failed.emit(task.id, error)
self.row_updated.emit(
task.id,
{"status": "failed", "last_error": error, "committed": 0},
)
self._log_run_event(
f"任务 {task.id} 商品 {task.item_id} 更新异常:{error}",
task=task,
level="error",
)
return "failed"
def _record_outcome(self, counters, total, outcome):
with self._progress_lock:
counters["done"] += 1
if outcome == "applied":
counters["applied"] += 1
elif outcome == "skipped":
counters["skipped"] += 1
else:
counters["failed"] += 1
self._emit_progress(
counters["done"],
total,
counters["applied"],
counters["skipped"],
counters["failed"],
)
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,
}
)
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,
"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,
},
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
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:
if not chrome.is_running(account.debug_port):
not_running.append(self._account_payload(account, "CDP 端口未响应"))
continue
status = self._login_status(account)
if not status.get("logged_in"):
logged_out.append(
self._account_payload(account, self._login_skip_reason(status))
)
if not_running or logged_out:
return {
"reason": "ACCOUNT_NOT_READY",
"not_running": not_running,
"logged_out": logged_out,
}
return None
def _account_payload(self, account, reason=None):
payload = {
"account_name": account.account_name,
"alias": account.alias,
"debug_port": account.debug_port,
}
if reason:
payload["reason"] = reason
return payload
def _emit_progress(self, done, total, collected, skipped, failed):
self.progress.emit(
{
"done": done,
"total": total,
"collected": collected,
"skipped": skipped,
"failed": failed,
}
)
def _login_status(self, account):
try:
return accounts.detect_login(account, path=self.db_path, config=self.config)
except Exception as exc:
return {
"logged_in": False,
"reason": f"LOGIN_CHECK_FAILED: {exc}",
}
def _login_skip_reason(self, status):
reason = status.get("reason")
return f"账号未登录: {reason}" if reason else "账号未登录"
def _old_cover_path(self, account, task):
image_root = appconfig.image_dir(self.config)
return os.path.abspath(
os.path.join(
image_root,
account.slug,
f"{task.item_id}_old.jpg",
)
)
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"):
super().__init__()
self.batch_id = batch_id
self.db_path = db_path
self.excel_path = excel_path
self.mode = mode
def execute(self):
results = [
self._write_one(batch_id)
for batch_id in self._batch_ids()
]
result = results[0] if len(results) == 1 else self._combined_result(results)
self.progress.emit(
{
"done": result.get("rows", 0),
"total": result.get("rows", 0),
"files": result.get("files", 0),
}
)
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,
}
class AccountLoginCheckWorker(BaseWorker):
def __init__(self, account, db_path=None, config=None, timeout=8):
super().__init__()
self.account = account
self.db_path = db_path
self.config = config
self.timeout = timeout
def execute(self):
status = accounts.detect_login(
self.account,
timeout=self.timeout,
path=self.db_path,
config=self.config,
)
self.row_updated.emit(self.account.id, status)
return {"alias": self.account.alias, "status": status}
class AIModelTestWorker(BaseWorker):
"""Test one AI model connection without blocking the GUI thread."""
def __init__(self, model_name, ai_models_path=None):
super().__init__()
self.model_name = model_name
self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH
def execute(self):
result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path)
payload = dict(appconfig.sanitize_for_log(result or {}))
payload["name"] = self.model_name
return payload
class SettingsTab(QWidget):
"""Tab 5: AI model definitions stored in config/ai_models.json."""
CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")]
API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")]
RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"]
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.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.test_item_id_edit = QLineEdit()
self.test_item_id_edit.setObjectName("testItemIdEdit")
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.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页")
self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox")
self.dry_run_checkbox = QCheckBox("dry-run 只预览不提交")
self.dry_run_checkbox.setObjectName("dryRunCheckbox")
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)
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._two_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._two_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._two_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),
]
)
update_form = self._two_column_form(
[
("测试商品ID", self.test_item_id_edit),
("单次最大更新条数", self.max_items_per_run_spin),
("", self.allow_real_submit_checkbox),
("", self.allow_cover_update_checkbox),
("", self.close_success_tab_checkbox),
("", self.dry_run_checkbox),
("", self.parallel_accounts_checkbox),
("最大并行账号数", self.max_parallel_accounts_spin),
]
)
panel = QWidget()
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
panel_layout.addWidget(QLabel("AI 模型"))
panel_layout.addLayout(model_picker_layout)
panel_layout.addSpacing(14)
panel_layout.addWidget(QLabel("模型详情"))
panel_layout.addLayout(form)
panel_layout.addLayout(action_layout)
panel_layout.addWidget(self.test_result_label)
panel_layout.addSpacing(18)
panel_layout.addWidget(QLabel("角色与生成参数"))
panel_layout.addLayout(ai_form)
panel_layout.addSpacing(18)
panel_layout.addWidget(QLabel("路径与端口"))
panel_layout.addLayout(path_form)
panel_layout.addSpacing(18)
panel_layout.addWidget(QLabel("Shopee 更新安全"))
panel_layout.addLayout(update_form)
panel_layout.addWidget(self.save_config_button)
panel_layout.addStretch(1)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(panel)
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 _two_column_form(self, fields):
layout = QGridLayout()
layout.setHorizontalSpacing(18)
layout.setVerticalSpacing(8)
layout.setColumnStretch(1, 1)
layout.setColumnStretch(3, 1)
row = 0
column_pair = 0
for field in fields:
label = field[0]
widget = field[1]
full_width = len(field) > 2 and bool(field[2])
if full_width and column_pair:
row += 1
column_pair = 0
column = column_pair * 2
self._add_form_field(layout, row, column, label, widget, full_width)
if full_width:
row += 1
column_pair = 0
elif column_pair == 0:
column_pair = 1
else:
row += 1
column_pair = 0
return layout
def _add_form_field(self, layout, row, column, label, widget, full_width):
if full_width:
if label:
layout.addWidget(QLabel(label), row, 0)
layout.addWidget(widget, row, 1, 1, 3)
else:
layout.addWidget(widget, row, 0, 1, 4)
return
if label:
layout.addWidget(QLabel(label), row, column)
layout.addWidget(widget, row, column + 1)
else:
layout.addWidget(widget, row, column, 1, 2)
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)
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("设置已保存")
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": self.test_item_id_edit.text().strip(),
"allow_real_submit": self.allow_real_submit_checkbox.isChecked(),
"allow_cover_update": self.allow_cover_update_checkbox.isChecked(),
"max_items_per_run": self.max_items_per_run_spin.value(),
"close_success_tab": self.close_success_tab_checkbox.isChecked(),
"dry_run": self.dry_run_checkbox.isChecked(),
"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.test_item_id_edit.setText(str(update_cfg.get("test_item_id", "")))
self.allow_real_submit_checkbox.setChecked(
bool(update_cfg.get("allow_real_submit", False))
)
self.allow_cover_update_checkbox.setChecked(
bool(update_cfg.get("allow_cover_update", False))
)
self.max_items_per_run_spin.setValue(
max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
)
self.close_success_tab_checkbox.setChecked(
bool(update_cfg.get("close_success_tab", False))
)
self.dry_run_checkbox.setChecked(bool(update_cfg.get("dry_run", 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.db_path = db_path
self.config = appconfig.load_config() if config is None else config
self.status_callback = status_callback
self.account_rows = []
self.login_statuses = {}
self.threads = []
self.table = QTableWidget(0, len(self.COLUMNS))
self.table.setHorizontalHeaderLabels(self.COLUMNS)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.add_button = QPushButton("新增")
self.edit_button = QPushButton("编辑")
self.delete_button = QPushButton("删除")
self.launch_button = QPushButton("启动登录")
self.check_button = QPushButton("检测登录")
self.shortcut_button = QPushButton("快捷方式")
toolbar = QHBoxLayout()
for button in (
self.add_button,
self.edit_button,
self.delete_button,
self.launch_button,
self.check_button,
self.shortcut_button,
):
toolbar.addWidget(button)
toolbar.addStretch(1)
self.empty_label = QLabel("")
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(toolbar)
layout.addWidget(self.table, 1)
layout.addWidget(self.empty_label)
self.add_button.clicked.connect(self.add_account)
self.edit_button.clicked.connect(self.edit_account)
self.delete_button.clicked.connect(self.delete_account)
self.launch_button.clicked.connect(self.launch_login)
self.check_button.clicked.connect(self.check_login)
self.shortcut_button.clicked.connect(self.create_shortcut)
self.table.itemSelectionChanged.connect(self._update_button_state)
self.table.doubleClicked.connect(self.edit_account)
self.refresh_accounts()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def _selected_account(self):
selected = self.table.selectionModel().selectedRows()
if not selected:
return None
row = selected[0].row()
if row < 0 or row >= len(self.account_rows):
return None
return self.account_rows[row]
def _update_button_state(self):
has_selection = self._selected_account() is not None
for button in (
self.edit_button,
self.delete_button,
self.launch_button,
self.check_button,
self.shortcut_button,
):
button.setEnabled(has_selection)
def refresh_accounts(self):
try:
self.account_rows = accounts.list_accounts(
path=self.db_path,
config=self.config,
)
except Exception as exc:
self.account_rows = []
self._set_status(f"账号读取失败:{exc}")
self.table.setRowCount(len(self.account_rows))
for row, account in enumerate(self.account_rows):
status = self.login_statuses.get(account.alias, "未知")
values = [
account.account_name,
account.alias,
account.region_host,
str(account.debug_port),
status,
account.note or "",
]
for column, value in enumerate(values):
item = QTableWidgetItem(value)
self.table.setItem(row, column, item)
self.empty_label.setText("" if self.account_rows else "暂无账号")
self._update_button_state()
def _show_error(self, message):
QMessageBox.warning(self, "账号管理", str(message))
self._set_status(str(message))
def add_account(self, checked=False):
try:
default_port = accounts.next_debug_port(
path=self.db_path,
config=self.config,
)
except Exception:
default_port = appconfig.default_debug_port(self.config)
dialog = AccountDialog(self, default_port=default_port, config=self.config)
if dialog.exec() != QDialog.Accepted:
return
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
try:
accounts.launch_for_login(account, config=self.config)
except Exception as exc:
self._show_error(exc)
return
self.login_statuses[account.alias] = "已启动"
self.refresh_accounts()
self._set_status("Chrome 已启动,请人工登录")
def check_login(self, checked=False):
account = self._selected_account()
if account is None:
return
self.login_statuses[account.alias] = "检测中"
self.refresh_accounts()
worker = AccountLoginCheckWorker(
account,
db_path=self.db_path,
config=self.config,
)
worker.finished.connect(self._on_login_check_finished)
worker.failed.connect(
lambda _task_id, error, alias=account.alias: self._on_login_check_failed(
alias,
error,
)
)
thread = run_worker(worker, start=False)
thread.finished.connect(lambda: self._forget_thread(thread))
self.threads.append(thread)
thread.start()
def _forget_thread(self, thread):
if thread in self.threads:
self.threads.remove(thread)
def _on_login_check_finished(self, payload):
if payload.get("ok") is False and not payload.get("alias"):
return
alias = payload.get("alias")
status = payload.get("status") or {}
if alias:
self.login_statuses[alias] = accounts.login_status_text(status)
self.refresh_accounts()
self._set_status("登录状态已刷新")
def _on_login_check_failed(self, alias, error):
self.login_statuses[alias] = "检测失败"
self.refresh_accounts()
self._set_status(f"登录检测失败:{error}")
def create_shortcut(self, checked=False):
account = self._selected_account()
if account is None:
return
try:
shortcut_path = accounts.create_shortcut(account, config=self.config)
except Exception as exc:
self._show_error(exc)
return
self._set_status(f"快捷方式已生成:{shortcut_path}")
QMessageBox.information(self, "账号管理", f"快捷方式已生成:\n{shortcut_path}")
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() if config is None else config
self.db_path = _database_path(db_path, self.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.setWindowTitle("cmshopee")
self.resize(1180, 760)
self.tabs = QTabWidget()
self.tabs.setObjectName("mainTabs")
self.tabs.setStyleSheet(TAB_STYLE)
self.tabs.currentChanged.connect(self._on_tab_changed)
for title in TAB_TITLES:
self.tabs.addTab(self._build_tab(title), title)
self.setCentralWidget(self.tabs)
self.statusBar().showMessage("就绪")
def _build_tab(self, title):
if title == "① 导入采集":
return CollectTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
open_accounts_callback=lambda: self.open_accounts_tab(),
)
if title == "② AI生成":
return GenerateTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
)
if title == "③ 更新shopee":
return ApplyTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
open_accounts_callback=lambda: self.open_accounts_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 _on_tab_changed(self, index):
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")
def open_accounts_tab(self):
self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理"))
else:
class MainWindow(QMainWindow):
def __init__(self):
raise RuntimeError("PySide6 未安装,无法启动 GUI")
def _ensure_offscreen_for_headless_tests():
if "PYTEST_CURRENT_TEST" in os.environ and "QT_QPA_PLATFORM" not in os.environ:
os.environ["QT_QPA_PLATFORM"] = "offscreen"
def main() -> int:
if QT_IMPORT_ERROR is not None:
print("cmshopee GUI 无法启动:当前 Python 环境未安装 PySide6。")
return 1
_ensure_offscreen_for_headless_tests()
app = QApplication.instance() or QApplication(sys.argv)
window = MainWindow()
window.show()
return app.exec()