"""Tab 1: Excel import and data collection UI.""" from __future__ import annotations from ...collect_skip import ( ALIAS_UNMATCHED, LOGIN_REQUIRED, format_skip_reason_summary, normalize_skip_reason_counts, ) from ... import product_status from ..models import TaskTableModel from ..widgets import * from ..workers import CollectWorker as _RealCollectWorker, WriteBackWorker as _RealWriteBackWorker def CollectWorker(*args, **kwargs): return _call_package_attr("CollectWorker", _RealCollectWorker, *args, **kwargs) def WriteBackWorker(*args, **kwargs): return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs) COLLECT_ACTIVITY_STEP_LABELS = { "preflight": "检查账号", "match_account": "匹配账号", "check_login": "检查账号登录", "prepare_task": "准备采集", "open_product": "打开商品页", "wait_ready": "等待商品页加载", "read_product_status": "读取商品状态", "read_title": "读取标题", "read_cover": "读取封面", "download_cover": "下载封面", "save_result": "保存采集结果", "db_write": "保存采集结果", } def _collect_activity_step_label(step): return COLLECT_ACTIVITY_STEP_LABELS.get(str(step or ""), "处理当前商品") def _format_collect_elapsed(seconds): total = max(0, int(seconds or 0)) hours, remainder = divmod(total, 3600) minutes, seconds = divmod(remainder, 60) if hours: return f"{hours}:{minutes:02d}:{seconds:02d}" return f"{minutes:02d}:{seconds:02d}" class CollectTab(QWidget): """Tab 1: import Excel files and list imported tasks.""" STATUS_FILTERS = [ ("全部状态", "all"), ("待采集", "to_collect"), ("已采集", "collected"), ("已生成", "generated"), ("已更新", "applied"), ("失败", "failed"), ("略过", "skipped"), ] def __init__( self, parent=None, db_path=None, config=None, status_callback=None, open_accounts_callback=None, refresh_workflow_callback=None, ): super().__init__(parent) self.config = appconfig.load_config() if config is None else config self.db_path = _database_path(db_path, self.config) self.status_callback = status_callback self.open_accounts_callback = open_accounts_callback self.refresh_workflow_callback = refresh_workflow_callback self.current_batch_id = None self.has_import_result = False self.last_import_stats = None self.collect_worker = None self.collect_thread = None self.write_back_worker = None self.write_back_thread = None self.last_collect_run_id = None self._collect_run_started_at = None self._collect_task_started_at = None self._collect_task_elapsed_seconds = 0 self._collect_activity_payload = {} self._collect_terminal_text = "" self._collect_stop_requested = False self._collect_elapsed_timer = QTimer(self) self._collect_elapsed_timer.setInterval(1000) self._collect_elapsed_timer.timeout.connect(self._refresh_collect_activity) self.import_button = QPushButton("导入 Excel...") self.refresh_button = QPushButton("刷新") self.collect_button = QPushButton("采集旧标题/旧封面") self.stop_collect_button = QPushButton("停止") self.write_back_button = QPushButton("回写旧数据到 Excel") self.stop_collect_button.setEnabled(False) self.batch_filter = QComboBox() self.batch_filter.setObjectName("collectBatchFilter") self.shop_filter = QComboBox() self.shop_filter.setObjectName("collectShopFilter") self.item_filter = QLineEdit() self.item_filter.setObjectName("collectItemFilter") self.item_filter.setPlaceholderText("商品ID") self.status_filter = QComboBox() self.status_filter.setObjectName("collectStatusFilter") for label, value in self.STATUS_FILTERS: self.status_filter.addItem(label, value) self.product_status_filter = QComboBox() self.product_status_filter.setObjectName("collectProductStatusFilter") for label, value in product_status.PRODUCT_STATUS_FILTER_ITEMS: self.product_status_filter.addItem(label, value) self.delete_batch_button = QPushButton("删除批次") self.delete_batch_button.setObjectName("deleteBatchButton") self.delete_batch_button.setStyleSheet(_danger_outline_button_style("deleteBatchButton")) self.delete_batch_button.setEnabled(False) toolbar = QHBoxLayout() toolbar.addWidget(self.import_button) toolbar.addWidget(self.refresh_button) toolbar.addWidget(self.collect_button) toolbar.addWidget(self.stop_collect_button) toolbar.addWidget(self.write_back_button) toolbar.addStretch(1) filter_layout = QHBoxLayout() filter_layout.addWidget(QLabel("批次")) filter_layout.addWidget(self.batch_filter, 2) filter_layout.addWidget(QLabel("店铺")) filter_layout.addWidget(self.shop_filter, 1) filter_layout.addWidget(QLabel("商品ID")) filter_layout.addWidget(self.item_filter, 1) filter_layout.addWidget(QLabel("商品状态")) filter_layout.addWidget(self.product_status_filter, 1) filter_layout.addWidget(QLabel("处理状态")) filter_layout.addWidget(self.status_filter, 1) filter_layout.addWidget(self.delete_batch_button) self.summary_label = QLabel("未导入任务") self.summary_label.setTextFormat(Qt.RichText) self.batch_progress_label = _build_batch_progress_overview("collectBatchProgressOverview") self.collect_activity_label = QLabel("") self.collect_activity_label.setObjectName("collectActivityLabel") self.collect_activity_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) activity_sample = "正在采集 999/999 · 等待商品页加载 · 本条 99:59" activity_width = self.collect_activity_label.fontMetrics().horizontalAdvance(activity_sample) + 24 self.collect_activity_label.setFixedWidth(activity_width) self.collect_activity_label.setVisible(False) self._set_collect_activity_style("muted") self.match_detail_label = QLabel("") self.show_all_button = QPushButton("全部") self.show_unmatched_button = QPushButton("未匹配(0)") self.show_unmatched_button.setObjectName("showUnmatchedButton") summary_layout = QHBoxLayout() summary_layout.addWidget(self.summary_label) summary_layout.addStretch(1) summary_layout.addWidget(self.show_all_button) summary_layout.addWidget(self.show_unmatched_button) batch_progress_layout = QHBoxLayout() batch_progress_layout.setContentsMargins(0, 0, 0, 0) batch_progress_layout.setSpacing(8) batch_progress_layout.addWidget(self.batch_progress_label, 1) batch_progress_layout.addWidget(self.collect_activity_label) self.table = QTableView() self.model = TaskTableModel(self.table) self.table.setModel(self.model) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) for column in (3, 4): self.table.horizontalHeader().setSectionResizeMode(column, QHeaderView.ResizeToContents) self.table.verticalHeader().setVisible(False) self.run_log_view = QPlainTextEdit() self.run_log_view.setObjectName("collectRunLogView") self.run_log_view.setReadOnly(True) self.run_log_view.setMaximumHeight(128) self.run_log_view.setPlaceholderText("采集运行日志") self.empty_label = QLabel("") ( self.empty_state_card, self.empty_state_label, self.empty_state_button, ) = _build_empty_state_card("collectEmptyStateCard") if self.open_accounts_callback is not None: self.empty_state_button.clicked.connect(self.open_accounts_callback) layout = QVBoxLayout(self) layout.setContentsMargins(18, 18, 18, 18) layout.addLayout(toolbar) layout.addLayout(filter_layout) layout.addLayout(summary_layout) layout.addWidget(self.match_detail_label) layout.addLayout(batch_progress_layout) layout.addWidget(self.empty_state_card) layout.addWidget(self.table, 1) layout.addWidget(QLabel("采集运行日志")) layout.addWidget(self.run_log_view) layout.addWidget(self.empty_label) self.import_button.clicked.connect(self.import_excel) self.refresh_button.clicked.connect(self.refresh_tasks) self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) self.item_filter.textChanged.connect(self.refresh_tasks) self.product_status_filter.currentIndexChanged.connect(self.refresh_tasks) self.status_filter.currentIndexChanged.connect(self.refresh_tasks) self.delete_batch_button.clicked.connect(self.delete_current_batch) self.table.customContextMenuRequested.connect(self._show_task_context_menu) self.collect_button.clicked.connect(self.collect_old_data) self.stop_collect_button.clicked.connect(self.stop_collect) self.write_back_button.clicked.connect(self.write_back_old_data) self.show_all_button.clicked.connect(self.show_all_tasks) self.show_unmatched_button.clicked.connect(self.show_unmatched_tasks) self.refresh_tasks() self._load_latest_collect_run_log() def _set_status(self, message, level=None): _emit_status(self.status_callback, message, level=level) def _set_collect_activity_style(self, level): color = _status_level_color(level) self.collect_activity_label.setStyleSheet( "QLabel#collectActivityLabel { " "background: #f6f8fa; border: 1px solid #d0d7de; " "border-radius: 6px; padding: 8px 10px; " f"color: {color}; font-weight: 600; " "}" ) def _start_collect_activity(self): now = time.monotonic() self._collect_run_started_at = now self._collect_task_started_at = None self._collect_task_elapsed_seconds = 0 self._collect_activity_payload = { "state": "preflight_started", "total": 0, "step": "preflight", } self._collect_terminal_text = "" self._collect_stop_requested = False self._refresh_collect_activity(now=now) if not self._collect_elapsed_timer.isActive(): self._collect_elapsed_timer.start() def _on_collect_activity(self, payload): event = dict(payload or {}) state = str(event.get("state") or "") now = time.monotonic() if self._collect_run_started_at is None: self._collect_run_started_at = now self._collect_terminal_text = "" if not self._collect_elapsed_timer.isActive(): self._collect_elapsed_timer.start() if state == "preflight_started": self._collect_activity_payload = event elif state == "task_started": self._collect_task_started_at = now self._collect_task_elapsed_seconds = 0 self._collect_activity_payload = event elif state in {"task_step", "task_finished"}: current = dict(self._collect_activity_payload) current.update(event) self._collect_activity_payload = current if state == "task_finished" and self._collect_task_started_at is not None: self._collect_task_elapsed_seconds = max( 0, int(now - self._collect_task_started_at), ) self._collect_task_started_at = None self._refresh_collect_activity(now=now) def _refresh_collect_activity(self, now=None): if self._collect_terminal_text: self.collect_activity_label.setVisible(True) self.collect_activity_label.setText(self._collect_terminal_text) return if self._collect_run_started_at is None: self.collect_activity_label.setVisible(False) return current_time = time.monotonic() if now is None else now run_elapsed = max(0, int(current_time - self._collect_run_started_at)) if self._collect_task_started_at is not None: task_elapsed = max(0, int(current_time - self._collect_task_started_at)) else: task_elapsed = self._collect_task_elapsed_seconds event = self._collect_activity_payload state = str(event.get("state") or "") index = int(event.get("index") or 0) total = int(event.get("total") or 0) progress = f"{index}/{total}" if index and total else str(index or total or "") step_label = _collect_activity_step_label(event.get("step")) if self._collect_stop_requested: if index: text = f"正在停止 · 本条 {_format_collect_elapsed(task_elapsed)}" else: text = f"正在停止 · 已等待 {_format_collect_elapsed(run_elapsed)}" level = "warning" elif state in {"task_started", "task_step"}: text = ( f"正在采集 {progress} · {step_label} · " f"本条 {_format_collect_elapsed(task_elapsed)}" ) level = "info" elif state == "task_finished": result_label = { "success": "已完成", "failed": "失败", "skipped": "略过", }.get(str(event.get("result") or ""), "已结束") text = ( f"第 {progress} 条 · {result_label} · " f"本条 {_format_collect_elapsed(task_elapsed)}" ) level = "danger" if event.get("result") == "failed" else "muted" else: text = f"正在检查账号 · {_format_collect_elapsed(run_elapsed)}" level = "info" tooltip_parts = [] if event.get("item_id"): tooltip_parts.append(f"商品ID:{event.get('item_id')}") if event.get("alias"): tooltip_parts.append(f"账号别名:{event.get('alias')}") if state in {"task_started", "task_step"}: tooltip_parts.append(f"当前阶段:{step_label}") self.collect_activity_label.setToolTip("\n".join(tooltip_parts)) self._set_collect_activity_style(level) self.collect_activity_label.setText(text) self.collect_activity_label.setVisible(True) def _finish_collect_activity(self, outcome, payload=None): now = time.monotonic() if self._collect_run_started_at is None: total_elapsed = 0 else: total_elapsed = max(0, int(now - self._collect_run_started_at)) self._collect_elapsed_timer.stop() self._collect_run_started_at = None self._collect_task_started_at = None self._collect_task_elapsed_seconds = 0 self._collect_activity_payload = {} self._collect_stop_requested = False if outcome == "blocked": text = "采集未开始 · 检查未通过" level = "warning" tooltip = "采集前检查未通过,请按弹窗提示处理" elif outcome == "cancelled": text = f"采集已停止 · 总用时 {_format_collect_elapsed(total_elapsed)}" level = "warning" tooltip = "本轮采集已停止" elif outcome == "error": text = "采集已结束 · 请查看运行日志" level = "danger" tooltip = "采集异常结束,请查看下方采集运行日志" else: text = f"采集完成 · 总用时 {_format_collect_elapsed(total_elapsed)}" level = "success" tooltip = "本轮采集已经完成" self._collect_terminal_text = text self.collect_activity_label.setToolTip(tooltip) self._set_collect_activity_style(level) self.collect_activity_label.setText(text) self.collect_activity_label.setVisible(True) def _on_collect_log(self, message): self._append_collect_log(message) self._set_status(message) def _append_collect_log(self, message): self.run_log_view.appendPlainText(str(message)) def _load_latest_collect_run_log(self): try: logs = db.list_run_logs(limit=1, run_type="collect", path=self.db_path) if not logs: return events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path) except Exception: return lines = [ f"{event.created_at} [{event.level}] {event.message}" for event in reversed(events) ] self.run_log_view.setPlainText("\n".join(lines)) scroll_bar = self.run_log_view.verticalScrollBar() scroll_bar.setValue(scroll_bar.maximum()) def _log_collect_run_event(self, run_id, message, level="info"): safe_message = diagnostics.redact_log_text(message) try: db.add_run_log_event(run_id, safe_message, level=level, path=self.db_path) except Exception: return self._append_collect_log(safe_message) def _show_error(self, message): text = str(message) self._set_status(text, level="danger") QTimer.singleShot(0, lambda: QMessageBox.warning(self, "导入采集", text)) def _show_account_guide(self, message): full_message = ( f"{message}\n\n" "本轮采集已中止。\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 _choose_excel_files(self): files, _selected_filter = QFileDialog.getOpenFileNames( self, "选择 Excel 文件", "", "Excel 文件 (*.xlsx *.xlsm)", ) return files def import_excel(self, checked=False): file_paths = self._choose_excel_files() if not file_paths: return run_id = _safe_create_run_log( "import", db_path=self.db_path, total=len(file_paths), options={"files": file_paths}, ) started = time.monotonic() _safe_add_run_log_event( run_id, f"step=select_files result=success detail=选择 Excel 文件 {len(file_paths)} 个", db_path=self.db_path, ) try: _safe_add_run_log_event( run_id, "step=parse_file result=start detail=开始解析 Excel 并写入 SQLite", db_path=self.db_path, ) result = excel.import_tasks(file_paths, path=self.db_path) except Exception as exc: elapsed_ms = _elapsed_ms(started) safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) _safe_add_run_log_event( run_id, f"step=import result=failed detail={safe_error} elapsed_ms={elapsed_ms}", db_path=self.db_path, level="error", ) _safe_write_diagnostic_log( "Excel导入失败", level="ERROR", step="import", elapsed_ms=elapsed_ms, payload={"files": file_paths, "error": safe_error}, exc=exc, log_dir=diagnostics.DEFAULT_LOG_DIR, ) _safe_finish_run_log( run_id, db_path=self.db_path, status="failed", done=0, failed_count=1, summary_json={"ok": False, "error": safe_error, "elapsed_ms": elapsed_ms}, ) self._show_error(safe_error) return elapsed_ms = _elapsed_ms(started) self.has_import_result = True self.last_import_stats = result.get("stats") or {} self.current_batch_id = result.get("batch_id") file_errors = self.last_import_stats.get("file_errors") or [] row_errors = self.last_import_stats.get("row_errors") or [] for item in file_errors: missing = ",".join(item.get("missing_columns") or []) detail = "file={file} sheet={sheet} error={error}{missing}".format( file=os.path.basename(str(item.get("file") or "")), sheet=item.get("sheet") or "", error=item.get("error") or "", missing=f" missing={missing}" if missing else "", ) _safe_add_run_log_event( run_id, f"step=parse_file result=failed detail={detail}", db_path=self.db_path, level="error", ) for item in row_errors: detail = "file={file} sheet={sheet} row={row} error={error}".format( file=os.path.basename(str(item.get("file") or "")), sheet=item.get("sheet") or "", row=item.get("row") or "", error=item.get("error") or "", ) _safe_add_run_log_event( run_id, f"step=row_validate result=failed detail={detail}", db_path=self.db_path, level="warning", ) _safe_add_run_log_event( run_id, "step=db_insert result=success detail=batch_id={batch_id} files={files} total={total} valid={valid} invalid={invalid} inserted={inserted} elapsed_ms={elapsed_ms}".format( batch_id=self.current_batch_id or "", files=self.last_import_stats.get("files", 0), total=self.last_import_stats.get("total", 0), valid=self.last_import_stats.get("valid", 0), invalid=self.last_import_stats.get("invalid", 0), inserted=self.last_import_stats.get("inserted", 0), elapsed_ms=elapsed_ms, ), db_path=self.db_path, ) _safe_finish_run_log( run_id, db_path=self.db_path, status="done", done=self.last_import_stats.get("files", 0), success_count=self.last_import_stats.get("inserted", 0), failed_count=len(file_errors) + len(row_errors), summary_json={ "ok": True, "batch_id": self.current_batch_id, "stats": self.last_import_stats, "elapsed_ms": elapsed_ms, }, ) self.refresh_tasks() self._set_status( "导入完成:有效{valid},无效{invalid},入库{inserted},未匹配{unmatched}".format( valid=self.last_import_stats.get("valid", 0), invalid=self.last_import_stats.get("invalid", 0), inserted=self.last_import_stats.get("inserted", 0), unmatched=self.model.unmatched_count(), ) ) def refresh_tasks(self, checked=False): try: db.init_db(self.db_path) batches = db.list_batches(path=self.db_path) selected_batch = self.batch_filter.currentData() selected_shop = self.shop_filter.currentData() selected_status = self.status_filter.currentData() or "all" selected_product_status = self.product_status_filter.currentData() or "all" item_query = self.item_filter.text().strip() if self.current_batch_id and self.batch_filter.findData(self.current_batch_id) < 0: selected_batch = self.current_batch_id self._populate_batch_filter(batches, selected_batch) selected_batch = self.batch_filter.currentData() self.current_batch_id = selected_batch task_rows = db.list_tasks(batch_id=selected_batch, path=self.db_path) account_rows = accounts.list_accounts(path=self.db_path, config=self.config) self._populate_shop_filter(task_rows, account_rows, selected_shop) selected_shop = self.shop_filter.currentData() filtered_rows = [ task for task in task_rows if self._matches_shop(task, selected_shop) and self._matches_item(task, item_query) and self._matches_product_status(task, selected_product_status) and self._matches_status(task, selected_status, account_rows) ] except Exception as exc: self.model.set_tasks([], []) self.empty_label.setText("任务读取失败") _set_batch_progress_overview(self.batch_progress_label, []) _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) self._set_status(f"任务读取失败:{exc}") return self.model.set_tasks(filtered_rows, account_rows) self._update_summary(task_rows, account_rows) _set_batch_progress_overview(self.batch_progress_label, task_rows) self._update_empty_state(task_rows, account_rows) self._update_delete_batch_button() def _populate_batch_filter(self, batches, selected_batch): 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 _batch_label(self, batch): source_files = batch.source_files first_file = os.path.basename(source_files[0]) if source_files else batch.id return f"{batch.created_at} · {first_file}" def _populate_shop_filter(self, task_rows, account_rows, selected_shop): aliases = {str(task.alias).strip() for task in task_rows if str(task.alias).strip()} previous = selected_shop if selected_shop in aliases else None account_by_alias = { str(account.alias).strip(): account for account in account_rows if str(account.alias).strip() } self.shop_filter.blockSignals(True) self.shop_filter.clear() self.shop_filter.addItem("全部店铺", None) for alias in sorted(aliases): self.shop_filter.addItem(self._shop_label(alias, account_by_alias), alias) index = self.shop_filter.findData(previous) self.shop_filter.setCurrentIndex(index if index >= 0 else 0) self.shop_filter.blockSignals(False) def _shop_label(self, alias, account_by_alias): account = account_by_alias.get(alias) if account is not None: return f"{account.account_name} ({alias})" return alias def _matches_shop(self, task, selected_shop): return selected_shop is None or str(task.alias).strip() == selected_shop def _matches_item(self, task, item_query): if not item_query: return True return item_query in str(getattr(task, "item_id", "")) def _matches_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, account_rows): if selected_status in (None, "all"): return True if selected_status == "to_collect": return task.stage == "imported" and task.status in {"pending", "success"} if selected_status in {"collected", "generated", "applied"}: return task.stage == selected_status if selected_status == "failed": return task.status == "failed" if selected_status == "skipped": return task.status == "skipped" or self._is_unmatched_task(task, account_rows) return True def _is_unmatched_task(self, task, account_rows): aliases = { str(account.alias).strip() for account in account_rows if str(account.alias).strip() } return str(task.alias).strip() not in aliases def _selected_batch_id(self): return self.batch_filter.currentData() def _update_delete_batch_button(self): running = bool(self.collect_thread or self.write_back_thread) self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id())) def _show_task_context_menu(self, position): index = self.table.indexAt(position) if not index.isValid(): return self.table.selectRow(index.row()) self.table.setCurrentIndex(index) task = self.model.task_at(index.row()) if task is None: return menu = QMenu(self.table) delete_action = menu.addAction("删除本条记录") delete_action.setEnabled( not bool(self.collect_thread or self.write_back_thread) and getattr(task, "status", "") != "running" ) delete_action.triggered.connect(self.delete_selected_task) menu.exec(self.table.viewport().mapToGlobal(position)) def delete_selected_task(self, checked=False): if self.collect_thread or self.write_back_thread: self._set_status("采集或回写正在进行,不能删除记录", level="warning") return index = self.table.currentIndex() task = self.model.task_at(index.row()) if index.isValid() else None 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"店铺:{task.account_name or task.alias}({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 delete_current_batch(self, checked=False): batch_id = self._selected_batch_id() if not batch_id: self._set_status("请先选择一个具体批次") return batch = db.get_batch(batch_id, path=self.db_path) if batch is None: self._set_status("批次不存在或已删除") self.current_batch_id = None self.refresh_tasks() return tasks = db.list_tasks(batch_id=batch_id, path=self.db_path) committed_count = sum(1 for task in tasks if int(getattr(task, "committed", 0) or 0) == 1) lines = [ f"确定要软删除批次 {self._batch_label(batch)} 吗?", f"任务数:{len(tasks)}", f"已提交线上:{committed_count}", "", "软删除后,该批次不会再出现在①/②/③页面、筛选、采集、生成、更新或回写入口中。", "软删除只隐藏本地批次,不会回滚蝦皮线上修改,不删除原始 Excel,也不删除本地图片。", ] answer = QMessageBox.question( self, "删除批次", "\n".join(lines), QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: self._set_status("已取消删除批次") return try: result = db.delete_batch(batch_id, reason="用户在导入采集页软删除", path=self.db_path) except Exception as exc: QMessageBox.warning(self, "删除批次", str(exc)) self._set_status(f"删除批次失败:{exc}") return self.current_batch_id = None self.has_import_result = False self.refresh_tasks() if self.refresh_workflow_callback is not None: self.refresh_workflow_callback() message = "已软删除批次:任务{task_count},已提交线上{committed_count}".format( task_count=result.get("task_count", 0), committed_count=result.get("committed_count", 0), ) self._set_status(message) QMessageBox.information(self, "删除批次", message) def collect_old_data(self, checked=False): tasks = list(self.model.tasks) if not tasks: self._set_status("没有可采集任务") return collect_scope = self._choose_collect_scope() if collect_scope is None: self._set_status("已取消采集") return worker = CollectWorker( tasks, db_path=self.db_path, config=self.config, diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, collect_scope=collect_scope, ) activity_signal = getattr(worker, "activity", None) if activity_signal is not None: activity_signal.connect(self._on_collect_activity) worker.progress.connect(self._on_collect_progress) worker.row_updated.connect(self._on_collect_row_updated) worker.log.connect(self._on_collect_log) worker.failed.connect(self._on_collect_failed) worker.finished.connect(self._on_collect_finished) worker.cancelled.connect(self._on_collect_cancelled) self.run_log_view.clear() thread = run_worker(worker, thread_name="CollectWorker", start=False) thread.finished.connect(lambda: self._forget_collect_thread(thread)) self.collect_worker = worker self.collect_thread = thread self._set_collect_running(True) self._start_collect_activity() thread.start() def _choose_collect_scope(self): box = ProductStatusScopeDialog( title="选择采集范围", text="请选择本轮要采集的商品范围。", informative_text=( "程序会逐个打开商品详情页检测状态并保存结果。" "采集架上商品仅继续采集检测结果为正常的商品;" "采集全部商品还包括未上架、审核中和状态未知商品," "可能增加采集时间,但本步骤不消耗 AI 点数。" ), normal_text="采集架上商品", all_text="采集全部商品", normal_value=product_status.COLLECT_SCOPE_NORMAL_ONLY, all_value=product_status.COLLECT_SCOPE_ALL, normal_object_name="collectNormalOnlyButton", all_object_name="collectAllStatusesButton", cancel_object_name="collectScopeCancelButton", parent=self, ) box.exec() return box.choice() def stop_collect(self, checked=False): if self.collect_worker is not None: self.collect_worker.cancel() self._collect_stop_requested = True self._refresh_collect_activity() self._set_status("正在停止采集...") def write_back_old_data(self, checked=False): batch_id = self._active_batch_id() if not batch_id: self._set_status("没有可回写批次") return self._start_write_back(batch_id) def _start_write_back(self, batch_id, auto=False): if self.write_back_thread is not None: self._set_status("Excel 回写正在进行...") return False worker = WriteBackWorker( batch_id, db_path=self.db_path, diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, ) worker.failed.connect( lambda task_id, error, auto=auto: self._on_write_back_failed( task_id, error, auto=auto, ) ) worker.finished.connect( lambda payload, auto=auto: self._on_write_back_finished( payload, auto=auto, ) ) thread = run_worker(worker, thread_name="WriteBackWorker", start=False) thread.finished.connect(lambda: self._forget_write_back_thread(thread)) self.write_back_worker = worker self.write_back_thread = thread self._set_write_back_running(True) self._set_status("正在自动回写旧数据到 Excel..." if auto else "正在回写旧数据到 Excel...") thread.start() return True def _active_batch_id(self): if self.current_batch_id: return self.current_batch_id batch_ids = { task.batch_id for task in self.model.all_tasks if getattr(task, "batch_id", None) } if len(batch_ids) == 1: return next(iter(batch_ids)) return None def _set_collect_running(self, running): self.import_button.setEnabled(not running) self.refresh_button.setEnabled(not running) self.collect_button.setEnabled(not running) self.write_back_button.setEnabled(not running) self.stop_collect_button.setEnabled(running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) self.item_filter.setEnabled(not running) self.product_status_filter.setEnabled(not running) self.status_filter.setEnabled(not running) self._update_delete_batch_button() def _set_write_back_running(self, running): self.import_button.setEnabled(not running) self.refresh_button.setEnabled(not running) self.collect_button.setEnabled(not running) self.write_back_button.setEnabled(not running) self.batch_filter.setEnabled(not running) self.shop_filter.setEnabled(not running) self.item_filter.setEnabled(not running) self.product_status_filter.setEnabled(not running) self.status_filter.setEnabled(not running) self._update_delete_batch_button() def _forget_collect_thread(self, thread): if self.collect_thread is thread: if self._collect_elapsed_timer.isActive(): self._finish_collect_activity("error") self.collect_thread = None self.collect_worker = None def _forget_write_back_thread(self, thread): if self.write_back_thread is thread: self.write_back_thread = None self.write_back_worker = None def _on_collect_progress(self, payload): self._set_status( "采集进度:{done}/{total},成功{collected},略过{skipped},失败{failed}".format( done=payload.get("done", 0), total=payload.get("total", 0), collected=payload.get("collected", 0), skipped=payload.get("skipped", 0), failed=payload.get("failed", 0), ) ) def _on_collect_row_updated(self, task_id, fields): self.refresh_tasks() def _on_collect_failed(self, task_id, error): self._set_status(f"任务 {task_id} 采集失败:{error}") def _on_collect_finished(self, payload): self._set_collect_running(False) if payload.get("blocked"): self._finish_collect_activity("blocked", payload) elif payload.get("error"): self._finish_collect_activity("error", payload) else: self._finish_collect_activity("finished", payload) self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id self.refresh_tasks() self._load_latest_collect_run_log() if payload.get("blocked"): self._show_collect_blocked(payload) return message = "采集完成:成功{collected},略过{skipped},失败{failed}".format( collected=payload.get("collected", 0), skipped=payload.get("skipped", 0), failed=payload.get("failed", 0), ) status_counts = payload.get("product_status_counts") or {} status_skipped = int(payload.get("status_scope_skipped", 0) or 0) if status_counts: message += ";状态正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}".format( normal=status_counts.get("normal", 0), unlisted=status_counts.get("unlisted", 0), reviewing=status_counts.get("reviewing", 0), unknown=status_counts.get("unknown", 0), ) if status_skipped: message += f";按范围略过{status_skipped}" self._show_collect_account_summary(payload, message) if payload.get("collected", 0) > 0: batch_id = self._active_batch_id() if batch_id and self._start_write_back(batch_id, auto=True): if self.last_collect_run_id: self._log_collect_run_event( self.last_collect_run_id, "step=excel_write_back result=start detail=采集成功后自动回写旧数据到 Excel", ) self._set_status(f"{message},正在自动回写 Excel...") return if not batch_id: self._set_status(f"{message},但没有可回写批次") return self._set_status(f"{message},Excel 回写已在进行") return self._set_status(message) def _show_collect_blocked(self, payload): lines = ["采集前检查未通过。"] if payload.get("no_accounts"): lines.append("当前没有配置账号。") not_running = payload.get("not_running") or [] if not_running: lines.append( "以下账号 Chrome 未启动或调试端口不可访问:" + "、".join(self._account_label(item) for item in not_running) ) launch_failed = payload.get("launch_failed") or [] if launch_failed: lines.append( "以下账号 Chrome 启动失败:" + "、".join(self._account_label(item) for item in launch_failed) ) 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_collect_account_summary(self, payload, message): launched = payload.get("launched_accounts") or [] reused = payload.get("reused_accounts") or [] login_required = payload.get("login_required_accounts") or [] skipped = max(0, int(payload.get("skipped", 0) or 0)) status_scope_skipped = int(payload.get("status_scope_skipped", 0) or 0) account_skipped = max(0, skipped - status_scope_skipped) skip_counts = normalize_skip_reason_counts( payload.get("skip_reason_counts"), skipped_total=account_skipped, ) if ( not launched and not reused and not login_required and account_skipped == 0 and status_scope_skipped == 0 ): return lines = [message] skip_summary = format_skip_reason_summary( skip_counts, skipped_total=account_skipped, ) if skip_summary: lines.append(skip_summary) if skip_counts[ALIAS_UNMATCHED] > 0: lines.append("请检查 Excel 别名是否与账号管理中的账号别名一致。") if launched: lines.append( "本轮已自动启动账号 Chrome:" + "、".join(self._account_label(item) for item in launched) ) if login_required: lines.append( "以下账号需要补登录:" + "、".join(self._account_label(item) for item in login_required) ) if skip_counts[LOGIN_REQUIRED] > 0: lines.append("请到账号管理完成对应账号登录后,再重新采集略过任务。") if status_scope_skipped: lines.append(f"本轮按范围略过{status_scope_skipped}个非正常状态商品,未下载标题和封面。") if launched or reused or login_required: lines.append("采集结束后不会自动关闭账号 Chrome,请按需自行关闭。") text = "\n".join(lines) if login_required or skip_counts[LOGIN_REQUIRED] > 0: QMessageBox.warning(self, "采集完成", text) else: QMessageBox.information(self, "采集完成", text) def _account_label(self, item): if isinstance(item, dict): name = item.get("account_name") or item.get("alias") or "" alias = item.get("alias") or "" reason = item.get("reason") else: name = getattr(item, "account_name", "") or getattr(item, "alias", "") alias = getattr(item, "alias", "") reason = getattr(item, "reason", None) label = f"{name}({alias})" if alias and name != alias else (name or alias) return f"{label}: {reason}" if reason else label def _on_collect_cancelled(self, payload): self._set_collect_running(False) self._finish_collect_activity("cancelled", payload) self.refresh_tasks() self._set_status( "采集已停止:完成{done}/{total}".format( done=payload.get("done", 0), total=payload.get("total", 0), ) ) def _on_write_back_failed(self, task_id, error, auto=False): message = f"Excel {'自动' if auto else ''}回写失败:{error}" if "被占用" in str(error): if auto: message += "\n请关闭原 Excel 后点击「回写旧数据到 Excel」手动重试;SQLite 已保留采集结果,也可另存副本。" else: message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。" QMessageBox.warning(self, "回写旧数据", message) self._set_status(message.replace("\n", " ")) if auto and self.last_collect_run_id: self._log_collect_run_event( self.last_collect_run_id, f"step=excel_write_back result=failed detail={error}", level="error", ) def _on_write_back_finished(self, payload, auto=False): self._set_write_back_running(False) if payload.get("ok") is False: error = payload.get("error") or "未知错误" retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else "" self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}") if auto and self.last_collect_run_id: self._log_collect_run_event( self.last_collect_run_id, f"step=excel_write_back result=failed detail={error}", level="error", ) return self.refresh_tasks() self._set_status( "Excel {prefix}回写完成:文件{files},行{rows}".format( prefix="自动" if auto else "", files=payload.get("files", 0), rows=payload.get("rows", 0), ) ) if auto and self.last_collect_run_id: self._log_collect_run_event( self.last_collect_run_id, "step=excel_write_back result=success detail=旧数据已回写 Excel", ) def show_all_tasks(self, checked=False): self.model.set_filter_mode("all") self._update_empty_label(len(self.model.all_tasks)) def show_unmatched_tasks(self, checked=False): self.model.set_filter_mode("unmatched") self._update_empty_label(len(self.model.all_tasks)) def _update_summary(self, task_rows, account_rows): stats = self.last_import_stats or {} unmatched = self._unmatched_count(task_rows, account_rows) matched = len(task_rows) - unmatched files = stats.get("files", 0 if not task_rows else 1) total = stats.get("total", len(task_rows)) valid = stats.get("valid", len(task_rows)) invalid = stats.get("invalid", 0) invalid_text = _danger_metric_text(f"无效{invalid}", invalid > 0) unmatched_text = _danger_metric_text(f"未匹配{unmatched}", unmatched > 0) self.summary_label.setText( f"{files} 文件 · {total} 行 · 有效{valid}/{invalid_text} · 匹配{matched} · {unmatched_text}" ) self.match_detail_label.setText(self._match_detail(task_rows, account_rows)) self.show_unmatched_button.setText(f"未匹配({unmatched})") self.show_unmatched_button.setEnabled(unmatched > 0) self.show_unmatched_button.setStyleSheet( _danger_outline_button_style("showUnmatchedButton") if unmatched > 0 else "" ) if unmatched == 0 and self.model.filter_mode == "unmatched": self.model.set_filter_mode("all") def _match_detail(self, task_rows, account_rows): account_by_alias = { str(account.alias).strip(): account for account in account_rows if str(account.alias).strip() } counts = {} for task in task_rows: account = account_by_alias.get(str(task.alias).strip()) if account is None: continue name = account.account_name or account.alias counts[name] = counts.get(name, 0) + 1 if not counts: return "匹配明细:无" parts = [f"{name}{count}" for name, count in sorted(counts.items())] return "匹配明细:" + " · ".join(parts) def _unmatched_count(self, task_rows, account_rows): aliases = { str(account.alias).strip() for account in account_rows if str(account.alias).strip() } return sum(1 for task in task_rows if str(task.alias).strip() not in aliases) def _update_empty_state(self, task_rows, account_rows): if not account_rows: self.empty_label.setText("") _set_empty_state( self.empty_state_card, self.empty_state_label, self.empty_state_button, "第一步:前往『账号管理』配置并登录账号,再回到①导入 Excel。", self.open_accounts_callback is not None, ) return if not task_rows: self.empty_label.setText("") _set_empty_state( self.empty_state_card, self.empty_state_label, self.empty_state_button, "还没有导入任务。请点击「导入 Excel...」导入待处理商品。", ) return _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) self._update_empty_label(len(task_rows)) def _update_empty_label(self, total_rows): if total_rows == 0: self.empty_label.setText("暂无任务") return if self.model.rowCount() == 0 and self.model.filter_mode == "unmatched": self.empty_label.setText("当前筛选没有未匹配任务") return if self.model.rowCount() == 0: self.empty_label.setText("当前筛选没有匹配任务") return unmatched = self.model.unmatched_count() self.empty_label.setText( "" if unmatched == 0 else f"{unmatched} 条任务别名未匹配账号,阶段显示为“略过”" )