Files
cmshoppe/app/gui/tabs/apply.py
T

1236 lines
53 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.
"""Tab 3: Shopee update UI."""
from __future__ import annotations
from PySide6.QtCore import QModelIndex, Signal
from ... import product_status
from ..models import ApplyTaskTableModel
from ..widgets import *
from ..workers import (
ApplyWorker as _RealApplyWorker,
ProductTabOpenWorker as _RealProductTabOpenWorker,
WriteBackWorker as _RealWriteBackWorker,
)
def ApplyWorker(*args, **kwargs):
return _call_package_attr("ApplyWorker", _RealApplyWorker, *args, **kwargs)
def WriteBackWorker(*args, **kwargs):
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs)
def ProductTabOpenWorker(*args, **kwargs):
return _call_package_attr(
"ProductTabOpenWorker", _RealProductTabOpenWorker, *args, **kwargs
)
class _ApplyTaskTableView(QTableView):
"""任务表仅向外发出左键双击事件,供人工查看商品页。"""
leftDoubleClicked = Signal(QModelIndex)
def mouseDoubleClickEvent(self, event):
super().mouseDoubleClickEvent(event)
if event.button() != Qt.LeftButton:
return
index = self.indexAt(event.position().toPoint())
if index.isValid():
self.leftDoubleClicked.emit(index)
class ApplyTab(QWidget):
"""Tab 3: list generated tasks and confirm the update scope."""
UPDATE_MODE_ITEMS = [
("只更新标题", "title"),
("只更新封面", "cover"),
("更新标题和封面", "title_cover"),
]
STATUS_FILTERS = [
("已生成", "generated"),
("失败", "failed"),
("已更新", "applied"),
("略过", "skipped"),
("全部状态", "all"),
]
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
open_accounts_callback=None,
open_settings_callback=None,
refresh_workflow_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.config_path = self.config.get("config_path") or appconfig.CONFIG_PATH
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.open_settings_callback = open_settings_callback
self.refresh_workflow_callback = refresh_workflow_callback
self.apply_worker = None
self.apply_thread = None
self.result_write_back_worker = None
self.result_write_back_thread = None
self.product_tab_open_worker = None
self.product_tab_open_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.product_status_filter = QComboBox()
self.product_status_filter.setObjectName("applyProductStatusFilter")
for label, value in product_status.PRODUCT_STATUS_FILTER_ITEMS:
self.product_status_filter.addItem(label, value)
self.product_status_filter.setCurrentIndex(
self.product_status_filter.findData(product_status.STATUS_NORMAL)
)
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.product_status_filter, 1)
filter_layout.addWidget(QLabel("更新状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
self.batch_progress_label = _build_batch_progress_overview("applyBatchProgressOverview")
self.risk_label = QLabel("可先点击「检查本轮更新」确认当前筛选范围;点击「开始更新」后会再次确认并按批提交线上。")
(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
) = _build_empty_state_card("applyEmptyStateCard")
if self.open_accounts_callback is not None:
self.empty_state_button.clicked.connect(self.open_accounts_callback)
self.task_table = _ApplyTaskTableView()
self.model = ApplyTaskTableModel(self.task_table)
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.task_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.task_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False)
self.run_log_view = QPlainTextEdit()
self.run_log_view.setObjectName("applyRunLogView")
self.run_log_view.setReadOnly(True)
self.run_log_view.setMaximumHeight(128)
self.run_log_view.setPlaceholderText("运行日志")
self.preview_update_button = QPushButton("检查本轮更新")
self.preview_update_button.setObjectName("previewUpdateButton")
self.start_update_button = QPushButton("开始更新")
self.start_update_button.setObjectName("startUpdateButton")
self.start_update_button.setMinimumWidth(118)
self.start_update_button.setStyleSheet(
_warning_outline_button_style("startUpdateButton")
)
self.stop_update_button = QPushButton("停止")
self.reset_update_button = QPushButton("重置更新状态")
self.reset_update_button.setObjectName("resetUpdateButton")
self.reset_update_button.setVisible(False)
self.write_back_button = QPushButton("回写结果到 Excel")
self.stop_update_button.setEnabled(False)
self.write_back_button.setEnabled(False)
self.update_mode_combo = QComboBox()
self.update_mode_combo.setObjectName("applyUpdateModeCombo")
self.update_mode_combo.setToolTip("选择本轮要更新标题、封面,或同时更新标题和封面")
for label, value in self.UPDATE_MODE_ITEMS:
self.update_mode_combo.addItem(label, value)
self._set_combo_by_data(
self.update_mode_combo,
self._shopee_update_config().get("update_mode", "title"),
)
action_layout = QHBoxLayout()
action_layout.addWidget(QLabel("更新内容"))
action_layout.addWidget(self.update_mode_combo)
action_layout.addSpacing(16)
action_layout.addWidget(self.preview_update_button)
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
action_layout.addStretch(1)
action_layout.addWidget(self.write_back_button)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(filter_layout)
layout.addWidget(self.risk_label)
layout.addWidget(self.summary_label)
layout.addWidget(self.batch_progress_label)
layout.addWidget(self.empty_state_card)
layout.addWidget(self.task_table, 1)
layout.addWidget(QLabel("运行日志"))
layout.addWidget(self.run_log_view)
layout.addLayout(action_layout)
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
self.item_filter.textChanged.connect(self.refresh_tasks)
self.product_status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.preview_update_button.clicked.connect(self.preview_update)
self.start_update_button.clicked.connect(self.start_update)
self.update_mode_combo.currentIndexChanged.connect(self._on_update_mode_changed)
self.stop_update_button.clicked.connect(self.stop_update)
self.reset_update_button.clicked.connect(self.reset_apply_status)
self.task_table.leftDoubleClicked.connect(self.open_or_focus_selected_product)
self.task_table.customContextMenuRequested.connect(self.show_task_context_menu)
self.write_back_button.clicked.connect(self.write_back_results)
self.refresh_tasks()
self._show_current_run_log_empty()
def _set_status(self, message, level=None):
_emit_status(self.status_callback, message, level=level)
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"
selected_product_status = self.product_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()
all_batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path)
batch_tasks = [
task for task in all_batch_tasks
if self._is_update_task(task)
]
self._populate_shop_filter(batch_tasks, account_rows, selected_shop)
selected_shop = self.shop_filter.currentData()
filtered_tasks = [
task for task in batch_tasks
if self._matches_shop(task, selected_shop)
and self._matches_item(task, item_query)
and self._matches_product_status(task, selected_product_status)
and self._matches_status(task, selected_status)
]
except Exception as exc:
self.model.set_tasks([], [])
self.summary_label.setText("任务读取失败")
_set_batch_progress_overview(self.batch_progress_label, [])
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
self._set_status(f"更新任务读取失败:{exc}")
return
self.model.set_tasks(filtered_tasks, account_rows)
self.summary_label.setText(
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
)
_set_batch_progress_overview(self.batch_progress_label, all_batch_tasks)
self._update_empty_state(batch_tasks, filtered_tasks, account_rows)
self._update_write_back_button()
def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows):
if not account_rows:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"第一步:前往『账号管理』配置并登录账号,再回到③更新蝦皮。",
self.open_accounts_callback is not None,
)
return
if not batch_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"还没有可更新任务。请先在②AI生成完成新标题或新封面。",
)
return
if not filtered_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"当前筛选没有可更新任务,请调整批次、店铺、商品ID或状态筛选。",
)
return
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
def start_update(self, checked=False):
self._start_update(dry_run=False)
def preview_update(self, checked=False):
self._start_update(dry_run=True)
def _start_update(self, dry_run=False):
if self.apply_thread is not None:
self._set_status("更新正在进行...")
return
base_candidates = self._update_candidates()
if not base_candidates:
self._set_status("当前筛选结果没有可更新任务")
return
update_cfg = self._shopee_update_config()
update_mode = self._current_update_mode()
update_plan = product_status.build_apply_plan(base_candidates, update_mode)
executable_tasks = update_plan["executable"]
if not executable_tasks:
preflight_error = self._update_preflight_error(update_plan, update_mode)
title = (
"商品状态不允许更新"
if update_plan["status_scope_excluded"]
else "更新内容未生成"
)
QMessageBox.warning(self, title, preflight_error)
self._set_status(preflight_error.replace("\n", " "))
return
dry_run = bool(dry_run)
answer = QMessageBox.question(
self,
"确认检查本轮更新" if dry_run else "确认开始更新",
self._confirmation_message(
executable_tasks,
dry_run=dry_run,
content_plan=update_plan,
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新")
return
current_plan = product_status.build_apply_plan(
self._update_candidates(),
update_mode,
)
if current_plan["fingerprint"] != update_plan["fingerprint"]:
self._set_status("当前任务数据已变化,请重新开始更新")
return
executable_tasks = current_plan["executable"]
if not executable_tasks:
preflight_error = self._update_preflight_error(current_plan, update_mode)
self._set_status(preflight_error.replace("\n", " "))
return
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
worker = ApplyWorker(
executable_tasks,
db_path=self.db_path,
config=self.config,
dry_run=dry_run,
update_mode=update_mode,
max_parallel_accounts=max(
1,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
),
batch_size=batch_size,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
product_status_counts=current_plan["status_counts"],
status_scope_excluded=current_plan["status_scope_excluded"],
content_scope_excluded=current_plan["content_scope_excluded"],
apply_plan_fingerprint=current_plan["fingerprint"],
)
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._reset_run_log(
executable_tasks,
dry_run=dry_run,
batch_size=batch_size,
content_plan=current_plan,
)
skip_status = self._content_skip_status(current_plan)
if dry_run:
self._set_status(f"开始检查本轮更新:{len(executable_tasks)} 条{skip_status}")
else:
self._set_status(
f"开始更新:{len(executable_tasks)} 条,按每批最多 {batch_size} 条执行{skip_status}"
)
thread.start()
def stop_update(self, checked=False):
if self.apply_worker is not None:
self.apply_worker.cancel()
self._set_status("正在停止更新...")
def write_back_results(self, checked=False):
batch_ids = self._active_batch_ids()
if not batch_ids:
self._set_status("没有可回写结果的批次")
return
self._start_result_write_back(batch_ids, auto=False)
def show_task_context_menu(self, position):
index = self.task_table.indexAt(position)
if not index.isValid():
return
self.task_table.selectRow(index.row())
self.task_table.setCurrentIndex(index)
task = self.model.task_at(index.row())
if task is None:
return
menu = QMenu(self.task_table)
reset_action = menu.addAction("重置更新状态")
reset_action.setEnabled(
self.apply_thread is None
and self.result_write_back_thread is None
and getattr(task, "status", "") != "running"
)
reset_action.triggered.connect(self.reset_apply_status)
menu.addSeparator()
delete_action = menu.addAction("删除本条记录")
delete_action.setEnabled(
self.apply_thread is None
and self.result_write_back_thread is None
and getattr(task, "status", "") != "running"
)
delete_action.triggered.connect(self.delete_selected_task)
menu.exec(self.task_table.viewport().mapToGlobal(position))
def delete_selected_task(self, checked=False):
if self.apply_thread is not None or self.result_write_back_thread is not None:
self._set_status("更新或回写正在进行,不能删除记录", level="warning")
return
task = self._selected_task()
if task is None:
self._set_status("请选择要删除的记录", level="warning")
return
if getattr(task, "status", "") == "running":
self._set_status("任务正在处理,不能删除", level="warning")
return
batch = db.get_batch(task.batch_id, path=self.db_path)
batch_text = self._batch_label(batch) if batch is not None else task.batch_id
lines = [
"确定要删除本条本地记录吗?",
f"商品ID:{task.item_id}",
f"店铺:{self.model.account_name_for(task)}({task.alias})",
f"所属批次:{batch_text}",
]
if int(getattr(task, "committed", 0) or 0) == 1:
lines.extend(
[
"",
"该商品已提交过蝦皮。本次仅删除本地记录,不会回滚蝦皮线上商品。",
]
)
lines.extend(
[
"",
"删除后,该记录不会出现在①、②、③的列表、筛选、采集、生成、更新或回写范围。",
"不会删除原 Excel、本地图片或账号 Chrome 数据。",
]
)
answer = QMessageBox.question(
self,
"删除本条记录",
"\n".join(lines),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消删除本条记录")
return
try:
result = db.delete_task(
task.id,
reason="用户在更新蝦皮页删除单条记录",
path=self.db_path,
)
except Exception as exc:
QMessageBox.warning(self, "删除本条记录", str(exc))
self._set_status(f"删除本条记录失败:{exc}", level="danger")
return
self.refresh_tasks()
if self.refresh_workflow_callback is not None:
self.refresh_workflow_callback()
message = f"已删除本地记录:商品 {result['item_id']}"
self._set_status(message, level="success")
QMessageBox.information(self, "删除本条记录", message)
def open_or_focus_selected_product(self, index):
if self.apply_thread is not None or self.result_write_back_thread is not None:
self._set_status("更新或回写正在进行,暂不能打开商品详情页", level="warning")
return
if self.product_tab_open_thread is not None:
self._set_status("正在打开商品详情页,请稍候", level="info")
return
if not index.isValid():
self._set_status("请选择要查看的商品记录", level="warning")
return
self.task_table.selectRow(index.row())
self.task_table.setCurrentIndex(index)
task = self.model.task_at(index.row())
if task is None:
self._set_status("未找到要查看的商品记录", level="warning")
return
worker = ProductTabOpenWorker(
task.alias,
task.item_id,
db_path=self.db_path,
config=self.config,
)
worker.finished.connect(self._on_product_tab_open_finished)
worker.failed.connect(self._on_product_tab_open_failed)
thread = run_worker(worker, thread_name="ProductTabOpenWorker", start=False)
thread.finished.connect(lambda: self._forget_product_tab_open_thread(thread))
self.product_tab_open_worker = worker
self.product_tab_open_thread = thread
self._set_status(f"正在打开商品 {task.item_id} 的详情页...", level="info")
thread.start()
def _on_product_tab_open_finished(self, payload):
if not payload.get("ok"):
self._set_status(
payload.get("message") or "打开商品详情页失败,请检查账号登录状态。",
level="warning",
)
return
action = "已打开新页面" if payload.get("created") else "已聚焦现有页面"
account_name = payload.get("account_name") or payload.get("alias") or "账号"
self._set_status(
f"商品 {payload.get('item_id')}:{account_name}{action}",
level="success",
)
def _on_product_tab_open_failed(self, task_id, error):
self._set_status("打开商品详情页失败,请检查账号 Chrome 和登录状态。", level="danger")
def _forget_product_tab_open_thread(self, thread):
if self.product_tab_open_thread is thread:
self.product_tab_open_thread = None
self.product_tab_open_worker = None
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)}",
"",
"将保留新标题和新封面路径,只把本地状态退回可更新。",
"不会触碰蝦皮,也不会自动回写 Excel。",
]
if getattr(task, "committed", 0):
lines.extend([
"",
"注意:该记录已经提交过线上。本地重置不会回滚蝦皮,重复更新会再次提交线上。",
])
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_update_candidate_task(self, task):
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) in {"success", "pending", "failed"}
)
def _update_candidates(self):
return [
task
for task in self.model.tasks
if self._is_update_candidate_task(task)
]
def _populate_batch_filter(self, batches, selected_batch):
had_previous_items = self.batch_filter.count() > 0
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)
self.batch_filter.setCurrentIndex(
_batch_filter_current_index(
self.batch_filter,
batches,
selected_batch,
had_previous_items,
)
)
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 _product_status_filter_label(self):
return self.product_status_filter.currentText() or "全部商品状态"
def _is_update_task(self, task):
if task.stage in {"generated", "applied"}:
return True
return bool((task.new_title or task.new_cover_path) and task.status in {"failed", "skipped"})
def _matches_shop(self, task, selected_shop):
return selected_shop is None or str(task.alias).strip() == selected_shop
def _matches_item(self, task, item_query):
if not item_query:
return True
return item_query in str(getattr(task, "item_id", ""))
def _matches_product_status(self, task, selected_product_status):
if selected_product_status in (None, "all"):
return True
return product_status.raw_status(getattr(task, "product_status", None)) == selected_product_status
def _matches_status(self, task, selected_status):
if selected_status in (None, "all"):
return True
if selected_status == "generated":
return task.stage == "generated" and task.status in {"success", "pending"}
if selected_status == "failed":
return task.status == "failed"
if selected_status == "applied":
return task.stage == "applied"
if selected_status == "skipped":
return task.status == "skipped"
return True
def _confirmation_message(self, tasks, dry_run=False, content_plan=None):
update_cfg = self._shopee_update_config()
update_mode = self._current_update_mode()
update_mode_text = self._update_mode_label(update_mode)
cover_warning = "\n本轮将更新线上封面,请确认封面内容无误。" if appconfig.update_mode_includes_cover(update_mode) else ""
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0
max_parallel_accounts = max(
1,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
)
parallel_text = (
f"最多 {max_parallel_accounts} 个账号"
if max_parallel_accounts > 1
else "关闭"
)
intro = (
"即将检查当前筛选结果。\n\n"
if dry_run
else "即将按当前筛选结果分批更新蝦皮线上商品。\n\n"
)
skipped_text = self._content_skip_text(content_plan)
skipped_block = f"\n{skipped_text}\n" if skipped_text else ""
return (
intro
+ f"批次:{self._batch_filter_label()}\n"
+ f"店铺:{self._shop_filter_label()}\n"
+ f"商品ID:{self._item_filter_label()}\n"
+ f"商品状态:{self._product_status_filter_label()}\n"
+ f"更新状态:{self._status_label()}\n"
+ f"更新内容:{update_mode_text}\n"
+ f"任务数:{len(tasks)}\n"
+ f"预计批次:{batch_count}\n"
+ skipped_block
+ "\n"
+ "执行设置:"
+ f"每批最大更新条数={batch_size},"
+ "成功后自动关闭程序新开编辑页,"
+ f"多账号并行={parallel_text}\n\n"
+ cover_warning
+ ("\n\n" if cover_warning else "")
+ (
"检查只写运行日志,不打开蝦皮、不点击「更新」、不改任务状态。"
if dry_run
else f"确认后会打开商品编辑页、替换标题/允许时替换封面,并按每批最多 {batch_size} 条点击「更新」提交线上;点击停止后不再开始下一条或下一批。"
)
)
def _shopee_update_config(self):
return appconfig.shopee_update_config(self.config)
def _current_update_mode(self):
return appconfig.normalize_update_mode(
self.update_mode_combo.currentData(),
allow_cover_update=False,
)
def _update_mode_label(self, mode):
mode = appconfig.normalize_update_mode(mode)
return {
"title": "只更新标题",
"cover": "只更新封面",
"title_cover": "更新标题和封面",
}.get(mode, "只更新标题")
def _set_combo_by_data(self, combo, value):
index = combo.findData(value)
combo.setCurrentIndex(index if index >= 0 else 0)
def _on_update_mode_changed(self, index=None):
previous = self._shopee_update_config().get("update_mode", "title")
if self._save_update_mode_setting(show_status=True):
return
self.update_mode_combo.blockSignals(True)
self._set_combo_by_data(self.update_mode_combo, previous)
self.update_mode_combo.blockSignals(False)
def _save_update_mode_setting(self, show_status=True):
update_mode = self._current_update_mode()
update_cfg = self._shopee_update_config()
update_cfg["update_mode"] = update_mode
payload = {
key: value
for key, value in self.config.items()
if key not in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
}
payload["shopee_update"] = update_cfg
try:
saved = appconfig.save_config(payload, path=self.config_path)
except Exception as exc:
self._set_status(f"更新内容设置保存失败:{exc}")
return False
internal = {
key: value
for key, value in self.config.items()
if key in {"config_path", "ai_models_path", "cmhub_config_path", "data_dir"}
}
self.config.clear()
self.config.update(saved)
self.config.update(internal)
if self.config_path != appconfig.CONFIG_PATH:
self.config["config_path"] = self.config_path
if show_status:
self._set_status(f"更新内容已设置为:{self._update_mode_label(update_mode)}")
return True
def _partition_update_content_tasks(self, tasks, update_mode):
missing_title = [
task for task in tasks
if appconfig.update_mode_includes_title(update_mode)
and not str(getattr(task, "new_title", "") or "").strip()
]
missing_cover = [
task for task in tasks
if appconfig.update_mode_includes_cover(update_mode)
and not str(getattr(task, "new_cover_path", "") or "").strip()
]
skipped = {id(task) for task in missing_title + missing_cover}
return {
"executable": [task for task in tasks if id(task) not in skipped],
"missing_title": missing_title,
"missing_cover": missing_cover,
}
def _update_preflight_error(self, update_plan, update_mode):
if update_plan["executable"]:
return None
lines = []
status_text = self._status_skip_text(update_plan)
if status_text:
lines.append("当前筛选结果没有状态正常且可更新的商品。")
lines.append(status_text)
lines.append("未上架、审核中或状态未知的商品为安全起见不会更新。")
content_error = self._update_content_error(update_plan, update_mode)
if content_error:
if lines:
lines.append("")
lines.append(content_error)
return "\n".join(lines) or "当前筛选结果没有可更新任务。"
def _update_content_error(self, tasks_or_plan, update_mode):
if isinstance(tasks_or_plan, dict):
content_plan = tasks_or_plan
else:
content_plan = self._partition_update_content_tasks(tasks_or_plan, update_mode)
if content_plan["executable"]:
return None
lines = []
mode_text = self._update_mode_label(update_mode)
missing_title = content_plan["missing_title"]
missing_cover = content_plan["missing_cover"]
if missing_title:
lines.append(f"当前筛选结果中有 {len(missing_title)} 条缺少新标题,不能执行“{mode_text}”。")
lines.append("请先回到②AI生成选择“只生成标题”或“生成标题和封面”。")
lines.append(f"示例商品ID:{self._sample_item_ids(missing_title)}")
if missing_cover:
if lines:
lines.append("")
lines.append(f"当前筛选结果中有 {len(missing_cover)} 条缺少新封面,不能执行“{mode_text}”。")
lines.append("请先回到②AI生成选择“只生成封面”或“生成标题和封面”。")
lines.append(f"示例商品ID:{self._sample_item_ids(missing_cover)}")
return "\n".join(lines)
def _content_skip_text(self, content_plan):
if not content_plan:
return ""
status_text = self._status_skip_text(content_plan)
missing_title = content_plan.get("missing_title", [])
missing_cover = content_plan.get("missing_cover", [])
skipped_count = len({id(task) for task in missing_title + missing_cover})
lines = [status_text] if status_text else []
if skipped_count:
lines.append(f"本轮将跳过 {skipped_count} 条缺少所选更新内容的记录:")
if missing_title:
lines.append(
f"缺少新标题:{len(missing_title)} 条(示例商品ID:{self._sample_item_ids(missing_title)})"
)
if missing_cover:
lines.append(
f"缺少新封面:{len(missing_cover)} 条(示例商品ID:{self._sample_item_ids(missing_cover)})"
)
return "\n".join(lines)
def _content_skip_status(self, content_plan):
if not content_plan:
return ""
status_skipped = int(content_plan.get("status_scope_excluded", 0) or 0)
missing_title = content_plan.get("missing_title", [])
missing_cover = content_plan.get("missing_cover", [])
skipped_count = len({id(task) for task in missing_title + missing_cover})
if not skipped_count and not status_skipped:
return ""
parts = []
if status_skipped:
parts.append(f"状态异常 {status_skipped}")
if skipped_count:
parts.append(
f"缺标题 {len(missing_title)} / 缺封面 {len(missing_cover)}"
)
return ",跳过 {total} 条({details})".format(
total=status_skipped + skipped_count,
details=";".join(parts),
)
def _status_skip_text(self, update_plan):
if not update_plan:
return ""
rows = [
("未上架", update_plan.get("unlisted", [])),
("审核中", update_plan.get("reviewing", [])),
("状态未知", update_plan.get("unknown", [])),
]
skipped = sum(len(tasks) for _label, tasks in rows)
if not skipped:
return ""
lines = [f"本轮将排除 {skipped} 条商品状态异常记录:"]
for label, tasks in rows:
if tasks:
lines.append(
f"{label}:{len(tasks)} 条(示例商品ID:{self._sample_item_ids(tasks)})"
)
return "\n".join(lines)
def _sample_item_ids(self, tasks):
values = [
str(getattr(task, "item_id", "") or "").strip()
for task in tasks[:5]
]
values = [value for value in values if value]
suffix = f" 等 {len(tasks)} 条" if len(tasks) > 5 else ""
return "、".join(values) + suffix if values else "无"
def _set_apply_running(self, running):
self.preview_update_button.setEnabled(not running)
self.start_update_button.setEnabled(not running)
self.stop_update_button.setEnabled(running)
self.reset_update_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.product_status_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.update_mode_combo.setEnabled(not running)
self._update_write_back_button()
def _set_result_write_back_running(self, running):
self.preview_update_button.setEnabled(not running)
self.start_update_button.setEnabled(not running)
self.reset_update_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.product_status_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
self.update_mode_combo.setEnabled(not running)
self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids()))
def _forget_apply_thread(self, thread):
if self.apply_thread is thread:
self.apply_thread = None
self.apply_worker = None
def _forget_result_write_back_thread(self, thread):
if self.result_write_back_thread is thread:
self.result_write_back_thread = None
self.result_write_back_worker = None
self._update_write_back_button()
def _on_apply_progress(self, payload):
self._set_status("更新进度:" + self._apply_progress_text(payload))
def _on_apply_log(self, message):
self._append_run_log(message)
self._set_status(message)
def _on_apply_row_updated(self, task_id, fields):
self.refresh_tasks()
def _on_apply_failed(self, task_id, error):
self._set_status(f"任务 {task_id} 更新失败:{error}")
def _on_apply_finished(self, payload):
self._set_apply_running(False)
self.refresh_tasks()
if payload.get("blocked"):
self._show_apply_blocked(payload)
return
self.last_apply_summary = dict(payload)
prefix = "检查本轮更新完成:" if payload.get("dry_run") else "更新完成:"
message = prefix + self._apply_progress_text(payload)
batch_ids = payload.get("batch_ids") or self._active_batch_ids()
if (not payload.get("dry_run")) and payload.get("done", 0) > 0 and batch_ids:
if self._start_result_write_back(
batch_ids,
auto=True,
apply_summary=payload,
):
self._set_status(f"{message},正在自动回写结果到 Excel...")
return
self._set_status(message)
self._show_apply_summary(payload)
def _on_apply_cancelled(self, payload):
self._set_apply_running(False)
self.refresh_tasks()
self._set_status("更新已停止:" + self._apply_progress_text(payload))
def _apply_progress_text(self, payload):
success_label = "可更新" if payload.get("dry_run") else "成功"
return "完成{done}/{total},{success_label}{applied},略过{skipped},失败{failed}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
success_label=success_label,
applied=payload.get("applied", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
def _append_run_log(self, message):
self.run_log_view.appendPlainText(str(message))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def _show_current_run_log_empty(self):
self.run_log_view.setPlainText("本轮日志会在开始运行后显示")
def _reset_run_log(self, tasks, dry_run=False, batch_size=1, content_plan=None):
self.run_log_view.clear()
action = "检查" if dry_run else "更新"
mode_text = self._update_mode_label(self._current_update_mode())
self._append_run_log(
f"本轮{action}开始:任务 {len(tasks)} 条,更新内容:{mode_text},每批 {batch_size} 条"
)
skip_status = self._content_skip_status(content_plan)
if skip_status:
excluded_label = (
"预检排除记录"
if content_plan and content_plan.get("status_scope_excluded")
else "缺失记录"
)
self._append_run_log(
f"本轮预检:可执行 {len(tasks)} 条{skip_status};{excluded_label}未进入本轮执行。"
)
def _load_latest_run_log(self):
try:
logs = db.list_run_logs(limit=1, run_type="apply", path=self.db_path)
if not logs:
return
events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path)
except Exception:
return
lines = [
f"{event.created_at} [{event.level}] {event.message}"
for event in reversed(events)
]
self.run_log_view.setPlainText("\n".join(lines))
scroll_bar = self.run_log_view.verticalScrollBar()
scroll_bar.setValue(scroll_bar.maximum())
def _show_apply_blocked(self, payload):
lines = ["更新前检查未通过。"]
if payload.get("no_accounts"):
lines.append("当前没有配置账号。")
duplicate_ports = payload.get("duplicate_ports") or []
if duplicate_ports:
for item in duplicate_ports:
lines.append(
"以下账号调试端口冲突:端口 {port} -> {aliases}".format(
port=item.get("debug_port"),
aliases="、".join(item.get("aliases") or []),
)
)
not_running = payload.get("not_running") or []
if not_running:
lines.append(
"以下账号 Chrome 未启动或调试端口不可访问:"
+ "、".join(self._account_label(item) for item in not_running)
)
logged_out = payload.get("logged_out") or []
if logged_out:
lines.append(
"以下账号未登录蝦皮:"
+ "、".join(self._account_label(item) for item in logged_out)
)
self._show_account_guide("\n".join(lines))
def _show_account_guide(self, message):
full_message = (
f"{message}\n\n"
"本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n"
"请先到「账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录蝦皮。"
)
QMessageBox.warning(self, "账号未就绪", full_message)
self._set_status(full_message.replace("\n", " "))
if self.open_accounts_callback is not None:
self.open_accounts_callback()
def _account_label(self, item):
if isinstance(item, dict):
name = item.get("account_name") or item.get("alias") or ""
alias = item.get("alias") or ""
reason = item.get("reason")
else:
name = getattr(item, "account_name", "") or getattr(item, "alias", "")
alias = getattr(item, "alias", "")
reason = getattr(item, "reason", None)
label = f"{name}({alias})" if alias and name != alias else (name or alias)
return f"{label}: {reason}" if reason else label
def _active_batch_ids(self):
selected_batch = self.batch_filter.currentData()
if selected_batch:
return [selected_batch]
batch_ids = []
for task in self.model.tasks:
batch_id = getattr(task, "batch_id", None)
if batch_id and batch_id not in batch_ids:
batch_ids.append(batch_id)
return batch_ids
def _update_write_back_button(self):
if getattr(self, "write_back_button", None) is None:
return
enabled = (
self.apply_thread is None
and self.result_write_back_thread is None
and bool(self._active_batch_ids())
)
self.write_back_button.setEnabled(enabled)
def _start_result_write_back(self, batch_ids, auto=False, apply_summary=None):
if self.result_write_back_thread is not None:
self._set_status("Excel 结果回写正在进行...")
return False
worker = WriteBackWorker(
batch_ids,
db_path=self.db_path,
mode="results",
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
worker.failed.connect(
lambda task_id, error, auto=auto, apply_summary=apply_summary:
self._on_result_write_back_failed(
task_id,
error,
auto=auto,
apply_summary=apply_summary,
)
)
worker.finished.connect(
lambda payload, auto=auto, apply_summary=apply_summary:
self._on_result_write_back_finished(
payload,
auto=auto,
apply_summary=apply_summary,
)
)
thread = run_worker(worker, thread_name="ResultWriteBackWorker", start=False)
thread.finished.connect(lambda: self._forget_result_write_back_thread(thread))
self.result_write_back_worker = worker
self.result_write_back_thread = thread
self._set_result_write_back_running(True)
self._set_status("正在自动回写更新结果到 Excel..." if auto else "正在回写更新结果到 Excel...")
thread.start()
return True
def _on_result_write_back_failed(self, task_id, error, auto=False, apply_summary=None):
message = f"Excel {'自动' if auto else ''}回写更新结果失败:{error}"
if "被占用" in str(error):
message += "\n请关闭原 Excel 后点击「回写结果到 Excel」手动重试;SQLite 已保留更新结果。"
if auto and apply_summary:
message = self._apply_summary_message(apply_summary, error=message)
QMessageBox.warning(self, "回写结果到 Excel", message)
self._set_status(message.replace("\n", " "))
def _on_result_write_back_finished(self, payload, auto=False, apply_summary=None):
self._set_result_write_back_running(False)
self.refresh_tasks()
if payload.get("ok") is False:
error = payload.get("error") or "未知错误"
retry_hint = ",可点击「回写结果到 Excel」手动重试" if auto else ""
self._set_status(f"Excel {'自动' if auto else ''}回写更新结果失败:{error}{retry_hint}")
return
self._set_status(
"Excel {prefix}回写更新结果完成:文件{files},行{rows}".format(
prefix="自动" if auto else "",
files=payload.get("files", 0),
rows=payload.get("rows", 0),
)
)
if auto and apply_summary:
self._show_apply_summary(apply_summary, write_back_payload=payload)
elif not auto:
QMessageBox.information(
self,
"回写结果到 Excel",
"结果回写完成:文件{files},行{rows}".format(
files=payload.get("files", 0),
rows=payload.get("rows", 0),
),
)
def _show_apply_summary(self, apply_summary, write_back_payload=None):
QMessageBox.information(
self,
"检查本轮更新完成" if apply_summary.get("dry_run") else "更新完成",
self._apply_summary_message(apply_summary, write_back_payload),
)
def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None):
dry_run = bool(apply_summary.get("dry_run"))
lines = [
"检查本轮更新完成,未打开蝦皮、未提交线上、未改任务状态。"
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)