"""Tab 3: Shopee update UI.""" from __future__ import annotations from ... import product_status 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.""" 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, ): 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.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( _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.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.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" 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, "第一步:前往『账号管理』配置并登录账号,再回到③更新蝦皮。", 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 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)}", "", "将保留新标题和新封面路径,只把本地状态退回可更新。", "不会触碰蝦皮,也不会自动回写 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 _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, 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._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.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.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)