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

911 lines
39 KiB
Python
Raw Normal View History

2026-07-02 16:47:37 +08:00
"""Tab 3: Shopee update UI."""
from __future__ import annotations
from ..models import ApplyTaskTableModel
from ..widgets import *
from ..workers import ApplyWorker as _RealApplyWorker, 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)
class ApplyTab(QWidget):
"""Tab 3: list generated tasks and confirm the update scope."""
2026-07-09 17:44:03 +08:00
UPDATE_MODE_ITEMS = [
("只更新标题", "title"),
("只更新封面", "cover"),
("更新标题和封面", "title_cover"),
]
2026-07-02 16:47:37 +08:00
STATUS_FILTERS = [
("已生成", "generated"),
("失败", "failed"),
("已更新", "applied"),
("略过", "skipped"),
("全部状态", "all"),
]
def __init__(
self,
parent=None,
db_path=None,
config=None,
status_callback=None,
open_accounts_callback=None,
open_settings_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
2026-07-09 17:44:03 +08:00
self.config_path = self.config.get("config_path") or appconfig.CONFIG_PATH
2026-07-02 16:47:37 +08:00
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.open_settings_callback = open_settings_callback
self.apply_worker = None
self.apply_thread = None
self.result_write_back_worker = None
self.result_write_back_thread = None
self.last_apply_summary = None
self.batch_filter = QComboBox()
self.batch_filter.setObjectName("applyBatchFilter")
self.shop_filter = QComboBox()
self.shop_filter.setObjectName("applyShopFilter")
self.item_filter = QLineEdit()
self.item_filter.setObjectName("applyItemFilter")
self.item_filter.setPlaceholderText("商品ID")
self.status_filter = QComboBox()
self.status_filter.setObjectName("applyStatusFilter")
for label, value in self.STATUS_FILTERS:
self.status_filter.addItem(label, value)
self.refresh_button = QPushButton("刷新")
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("批次"))
filter_layout.addWidget(self.batch_filter, 2)
filter_layout.addWidget(QLabel("店铺"))
filter_layout.addWidget(self.shop_filter, 1)
filter_layout.addWidget(QLabel("商品ID"))
filter_layout.addWidget(self.item_filter, 1)
filter_layout.addWidget(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
self.batch_progress_label = _build_batch_progress_overview("applyBatchProgressOverview")
self.risk_label = QLabel("可先点击「检查本轮更新」确认当前筛选范围;点击「开始更新」后会再次确认并按批提交线上。")
(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
) = _build_empty_state_card("applyEmptyStateCard")
if self.open_accounts_callback is not None:
self.empty_state_button.clicked.connect(self.open_accounts_callback)
self.task_table = QTableView()
self.model = ApplyTaskTableModel(self.task_table)
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.task_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.task_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False)
self.run_log_view = QPlainTextEdit()
self.run_log_view.setObjectName("applyRunLogView")
self.run_log_view.setReadOnly(True)
self.run_log_view.setMaximumHeight(128)
self.run_log_view.setPlaceholderText("运行日志")
self.preview_update_button = QPushButton("检查本轮更新")
self.preview_update_button.setObjectName("previewUpdateButton")
self.start_update_button = QPushButton("开始更新")
self.start_update_button.setObjectName("startUpdateButton")
self.start_update_button.setMinimumWidth(118)
self.start_update_button.setStyleSheet(
2026-07-07 09:21:19 +08:00
_warning_outline_button_style("startUpdateButton")
2026-07-02 16:47:37 +08:00
)
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)
2026-07-09 17:44:03 +08:00
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"),
)
2026-07-02 16:47:37 +08:00
action_layout = QHBoxLayout()
2026-07-09 17:44:03 +08:00
action_layout.addWidget(QLabel("更新内容"))
action_layout.addWidget(self.update_mode_combo)
action_layout.addSpacing(16)
2026-07-02 16:47:37 +08:00
action_layout.addWidget(self.preview_update_button)
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
action_layout.addStretch(1)
action_layout.addWidget(self.write_back_button)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(filter_layout)
layout.addWidget(self.risk_label)
layout.addWidget(self.summary_label)
layout.addWidget(self.batch_progress_label)
layout.addWidget(self.empty_state_card)
layout.addWidget(self.task_table, 1)
layout.addWidget(QLabel("运行日志"))
layout.addWidget(self.run_log_view)
layout.addLayout(action_layout)
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
self.item_filter.textChanged.connect(self.refresh_tasks)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.preview_update_button.clicked.connect(self.preview_update)
self.start_update_button.clicked.connect(self.start_update)
2026-07-09 17:44:03 +08:00
self.update_mode_combo.currentIndexChanged.connect(self._on_update_mode_changed)
2026-07-02 16:47:37 +08:00
self.stop_update_button.clicked.connect(self.stop_update)
self.reset_update_button.clicked.connect(self.reset_apply_status)
self.task_table.customContextMenuRequested.connect(self.show_task_context_menu)
self.write_back_button.clicked.connect(self.write_back_results)
self.refresh_tasks()
2026-07-02 17:32:51 +08:00
self._show_current_run_log_empty()
2026-07-02 16:47:37 +08:00
2026-07-07 15:42:19 +08:00
def _set_status(self, message, level=None):
_emit_status(self.status_callback, message, level=level)
2026-07-02 16:47:37 +08:00
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
batches = db.list_batches(path=self.db_path)
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
selected_batch = self.batch_filter.currentData()
selected_shop = self.shop_filter.currentData()
selected_status = self.status_filter.currentData() or "generated"
item_query = self.item_filter.text().strip()
self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData()
all_batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path)
batch_tasks = [
task for task in all_batch_tasks
if self._is_update_task(task)
]
self._populate_shop_filter(batch_tasks, account_rows, selected_shop)
selected_shop = self.shop_filter.currentData()
filtered_tasks = [
task for task in batch_tasks
if self._matches_shop(task, selected_shop)
and self._matches_item(task, item_query)
and self._matches_status(task, selected_status)
]
except Exception as exc:
self.model.set_tasks([], [])
self.summary_label.setText("任务读取失败")
_set_batch_progress_overview(self.batch_progress_label, [])
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
self._set_status(f"更新任务读取失败:{exc}")
return
self.model.set_tasks(filtered_tasks, account_rows)
self.summary_label.setText(
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
)
_set_batch_progress_overview(self.batch_progress_label, all_batch_tasks)
self._update_empty_state(batch_tasks, filtered_tasks, account_rows)
self._update_write_back_button()
def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows):
if not account_rows:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
2026-07-07 14:58:54 +08:00
"第一步:前往『④账号管理』配置并登录账号,再回到③更新蝦皮。",
2026-07-02 16:47:37 +08:00
self.open_accounts_callback is not None,
)
return
if not batch_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"还没有可更新任务。请先在②AI生成完成新标题或新封面。",
)
return
if not filtered_tasks:
_set_empty_state(
self.empty_state_card,
self.empty_state_label,
self.empty_state_button,
"当前筛选没有可更新任务,请调整批次、店铺、商品ID或状态筛选。",
)
return
_set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button)
def start_update(self, checked=False):
self._start_update(dry_run=False)
def preview_update(self, checked=False):
self._start_update(dry_run=True)
def _start_update(self, dry_run=False):
if self.apply_thread is not None:
self._set_status("更新正在进行...")
return
tasks = [
task for task in self.model.tasks
2026-07-09 17:44:03 +08:00
if self._is_update_candidate_task(task)
2026-07-02 16:47:37 +08:00
]
if not tasks:
self._set_status("当前筛选结果没有可更新任务")
return
update_cfg = self._shopee_update_config()
2026-07-09 17:44:03 +08:00
update_mode = self._current_update_mode()
content_error = self._update_content_error(tasks, update_mode)
if content_error:
QMessageBox.warning(self, "更新内容未生成", content_error)
self._set_status(content_error.replace("\n", " "))
return
2026-07-02 16:47:37 +08:00
dry_run = bool(dry_run)
answer = QMessageBox.question(
self,
"确认检查本轮更新" if dry_run else "确认开始更新",
self._confirmation_message(tasks, dry_run=dry_run),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新")
return
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
worker = ApplyWorker(
tasks,
db_path=self.db_path,
config=self.config,
dry_run=dry_run,
2026-07-09 17:44:03 +08:00
update_mode=update_mode,
2026-07-02 16:47:37 +08:00
max_parallel_accounts=max(
1,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
),
batch_size=batch_size,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
worker.progress.connect(self._on_apply_progress)
worker.row_updated.connect(self._on_apply_row_updated)
worker.log.connect(self._on_apply_log)
worker.failed.connect(self._on_apply_failed)
worker.finished.connect(self._on_apply_finished)
worker.cancelled.connect(self._on_apply_cancelled)
thread = run_worker(worker, thread_name="ApplyWorker", start=False)
thread.finished.connect(lambda: self._forget_apply_thread(thread))
self.apply_worker = worker
self.apply_thread = thread
self._set_apply_running(True)
2026-07-02 17:32:51 +08:00
self._reset_run_log(tasks, dry_run=dry_run, batch_size=batch_size)
2026-07-02 16:47:37 +08:00
if dry_run:
self._set_status(f"开始检查本轮更新:{len(tasks)} 条")
else:
self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行")
thread.start()
def stop_update(self, checked=False):
if self.apply_worker is not None:
self.apply_worker.cancel()
self._set_status("正在停止更新...")
def write_back_results(self, checked=False):
batch_ids = self._active_batch_ids()
if not batch_ids:
self._set_status("没有可回写结果的批次")
return
self._start_result_write_back(batch_ids, auto=False)
def show_task_context_menu(self, position):
index = self.task_table.indexAt(position)
if index.isValid():
self.task_table.setCurrentIndex(index)
menu = QMenu(self)
reset_action = menu.addAction("重置更新状态")
reset_action.setEnabled(
self.apply_thread is None
and self.result_write_back_thread is None
and self._selected_task() is not None
)
reset_action.triggered.connect(self.reset_apply_status)
menu.exec(self.task_table.viewport().mapToGlobal(position))
def reset_apply_status(self, checked=False):
if self.apply_thread is not None or self.result_write_back_thread is not None:
self._set_status("更新或回写正在进行,不能重置")
return
task = self._selected_task()
if task is None:
self._set_status("请选择要重置更新状态的任务")
return
if not (getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)):
self._set_status("选中任务没有新标题或新封面,不能重置为可更新")
return
lines = [
"确定重置当前选中任务的本地更新状态吗?",
"",
f"商品ID:{task.item_id}",
f"店铺:{self.model.account_name_for(task)}",
"",
"将保留新标题和新封面路径,只把本地状态退回可更新。",
2026-07-07 14:58:54 +08:00
"不会触碰蝦皮,也不会自动回写 Excel。",
2026-07-02 16:47:37 +08:00
]
if getattr(task, "committed", 0):
lines.extend([
"",
2026-07-07 14:58:54 +08:00
"注意:该记录已经提交过线上。本地重置不会回滚蝦皮,重复更新会再次提交线上。",
2026-07-02 16:47:37 +08:00
])
answer = QMessageBox.question(
self,
"重置更新状态",
"\n".join(lines),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消重置更新状态")
return
try:
db.reset_apply_status(task.id, path=self.db_path)
message = (
"action=reset_apply_status step=db_write result=success "
f"detail=退回可更新 task_id={task.id}"
)
run_id = _write_reset_run_log(self.db_path, task, "reset_apply_status", message)
except Exception as exc:
QMessageBox.warning(self, "重置更新状态", str(exc))
self._set_status(f"重置更新状态失败:{exc}")
return
self.refresh_tasks()
self._append_run_log(message)
self._set_status(
f"已重置更新状态:商品 {task.item_id},run_id={run_id}"
)
def _selected_task(self):
index = self.task_table.currentIndex()
if index.isValid():
return self.model.task_at(index.row())
if self.model.rowCount() > 0:
return self.model.task_at(0)
return None
2026-07-09 17:44:03 +08:00
def _is_update_candidate_task(self, task):
2026-07-02 16:47:37 +08:00
return (
getattr(task, "stage", None) == "generated"
and getattr(task, "status", None) in {"success", "pending", "failed"}
)
def _populate_batch_filter(self, batches, selected_batch):
had_previous_items = self.batch_filter.count() > 0
2026-07-02 16:47:37 +08:00
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,
)
)
2026-07-02 16:47:37 +08:00
self.batch_filter.blockSignals(False)
def _populate_shop_filter(self, tasks, account_rows, selected_shop):
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
aliases = []
for task in tasks:
alias = str(task.alias).strip()
if alias and alias not in aliases:
aliases.append(alias)
previous = selected_shop if selected_shop in aliases else None
self.shop_filter.blockSignals(True)
self.shop_filter.clear()
self.shop_filter.addItem("全部店铺", None)
for alias in sorted(aliases, key=lambda value: self._shop_label(value, account_by_alias)):
self.shop_filter.addItem(self._shop_label(alias, account_by_alias), alias)
index = self.shop_filter.findData(previous)
self.shop_filter.setCurrentIndex(index if index >= 0 else 0)
self.shop_filter.blockSignals(False)
def _batch_label(self, batch):
source_files = batch.source_files
first_file = os.path.basename(source_files[0]) if source_files else batch.id
return f"{batch.created_at} · {first_file}"
def _shop_label(self, alias, account_by_alias):
account = account_by_alias.get(alias)
if account is not None:
return f"{account.account_name} ({alias})"
return alias
def _status_label(self):
return self.status_filter.currentText() or "已生成"
def _batch_filter_label(self):
return self.batch_filter.currentText() or "全部批次"
def _shop_filter_label(self):
return self.shop_filter.currentText() or "全部店铺"
def _item_filter_label(self):
return self.item_filter.text().strip() or "全部商品"
def _is_update_task(self, task):
if task.stage in {"generated", "applied"}:
return True
return bool((task.new_title or task.new_cover_path) and task.status in {"failed", "skipped"})
def _matches_shop(self, task, selected_shop):
return selected_shop is None or str(task.alias).strip() == selected_shop
def _matches_item(self, task, item_query):
if not item_query:
return True
return item_query in str(getattr(task, "item_id", ""))
def _matches_status(self, task, selected_status):
if selected_status in (None, "all"):
return True
if selected_status == "generated":
return task.stage == "generated" and task.status in {"success", "pending"}
if selected_status == "failed":
return task.status == "failed"
if selected_status == "applied":
return task.stage == "applied"
if selected_status == "skipped":
return task.status == "skipped"
return True
def _confirmation_message(self, tasks, dry_run=False):
update_cfg = self._shopee_update_config()
2026-07-09 17:44:03 +08:00
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 ""
2026-07-02 16:47:37 +08:00
batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1))
batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0
2026-07-10 10:12:15 +08:00
max_parallel_accounts = max(
1,
int(update_cfg.get("max_parallel_accounts", 1) or 1),
)
2026-07-02 16:47:37 +08:00
parallel_text = (
2026-07-10 10:12:15 +08:00
f"最多 {max_parallel_accounts} 个账号"
if max_parallel_accounts > 1
2026-07-02 16:47:37 +08:00
else "关闭"
)
intro = (
"即将检查当前筛选结果。\n\n"
if dry_run
2026-07-07 14:58:54 +08:00
else "即将按当前筛选结果分批更新蝦皮线上商品。\n\n"
2026-07-02 16:47:37 +08:00
)
return (
intro
+ f"批次:{self._batch_filter_label()}\n"
+ f"店铺:{self._shop_filter_label()}\n"
+ f"商品ID:{self._item_filter_label()}\n"
+ f"状态:{self._status_label()}\n"
2026-07-09 17:44:03 +08:00
+ f"更新内容:{update_mode_text}\n"
2026-07-02 16:47:37 +08:00
+ f"任务数:{len(tasks)}\n"
+ f"预计批次:{batch_count}\n\n"
2026-07-10 10:12:15 +08:00
+ "执行设置:"
2026-07-02 16:47:37 +08:00
+ f"每批最大更新条数={batch_size},"
2026-07-10 10:12:15 +08:00
+ "成功后自动关闭程序新开编辑页,"
2026-07-02 16:47:37 +08:00
+ f"多账号并行={parallel_text}\n\n"
2026-07-09 17:44:03 +08:00
+ cover_warning
+ ("\n\n" if cover_warning else "")
2026-07-02 16:47:37 +08:00
+ (
2026-07-07 14:58:54 +08:00
"检查只写运行日志,不打开蝦皮、不点击「更新」、不改任务状态。"
2026-07-02 16:47:37 +08:00
if dry_run
else f"确认后会打开商品编辑页、替换标题/允许时替换封面,并按每批最多 {batch_size} 条点击「更新」提交线上;点击停止后不再开始下一条或下一批。"
)
)
def _shopee_update_config(self):
2026-07-09 17:44:03 +08:00
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 _update_content_error(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()
]
if not missing_title and not missing_cover:
return None
lines = []
mode_text = self._update_mode_label(update_mode)
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 _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 "无"
2026-07-02 16:47:37 +08:00
def _set_apply_running(self, running):
self.preview_update_button.setEnabled(not running)
self.start_update_button.setEnabled(not running)
self.stop_update_button.setEnabled(running)
self.reset_update_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
2026-07-09 17:44:03 +08:00
self.update_mode_combo.setEnabled(not running)
2026-07-02 16:47:37 +08:00
self._update_write_back_button()
def _set_result_write_back_running(self, running):
self.preview_update_button.setEnabled(not running)
self.start_update_button.setEnabled(not running)
self.reset_update_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self.shop_filter.setEnabled(not running)
self.item_filter.setEnabled(not running)
self.status_filter.setEnabled(not running)
2026-07-09 17:44:03 +08:00
self.update_mode_combo.setEnabled(not running)
2026-07-02 16:47:37 +08:00
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))
2026-07-02 17:32:51 +08:00
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):
self.run_log_view.clear()
action = "检查" if dry_run else "更新"
2026-07-09 17:44:03 +08:00
mode_text = self._update_mode_label(self._current_update_mode())
2026-07-02 17:32:51 +08:00
self._append_run_log(
2026-07-09 17:44:03 +08:00
f"本轮{action}开始:任务 {len(tasks)} 条,更新内容:{mode_text},每批 {batch_size} 条"
2026-07-02 17:32:51 +08:00
)
2026-07-02 16:47:37 +08:00
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(
2026-07-07 14:58:54 +08:00
"以下账号未登录蝦皮:"
2026-07-02 16:47:37 +08:00
+ "、".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"
2026-07-07 14:58:54 +08:00
"请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录蝦皮。"
2026-07-02 16:47:37 +08:00
)
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 = [
2026-07-07 14:58:54 +08:00
"检查本轮更新完成,未打开蝦皮、未提交线上、未改任务状态。"
2026-07-02 16:47:37 +08:00
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)