"""Tab 1: Excel import and data collection UI.""" from __future__ import annotations 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) 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.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.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.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.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) self.table = QTableView() self.model = TaskTableModel(self.table) self.table.setModel(self.model) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.table.verticalHeader().setVisible(False) self.run_log_view = QPlainTextEdit() self.run_log_view.setObjectName("collectRunLogView") self.run_log_view.setReadOnly(True) self.run_log_view.setMaximumHeight(128) self.run_log_view.setPlaceholderText("采集运行日志") self.empty_label = QLabel("") ( 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.addWidget(self.batch_progress_label) 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.status_filter.currentIndexChanged.connect(self.refresh_tasks) self.delete_batch_button.clicked.connect(self.delete_current_batch) self.collect_button.clicked.connect(self.collect_old_data) self.stop_collect_button.clicked.connect(self.stop_collect) self.write_back_button.clicked.connect(self.write_back_old_data) self.show_all_button.clicked.connect(self.show_all_tasks) self.show_unmatched_button.clicked.connect(self.show_unmatched_tasks) self.refresh_tasks() self._load_latest_collect_run_log() def _set_status(self, message): if self.status_callback is not None: self.status_callback(message) def _on_collect_log(self, message): self._append_collect_log(message) self._set_status(message) def _append_collect_log(self, message): self.run_log_view.appendPlainText(str(message)) def _load_latest_collect_run_log(self): try: logs = db.list_run_logs(limit=1, run_type="collect", path=self.db_path) if not logs: return events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path) except Exception: return lines = [ f"{event.created_at} [{event.level}] {event.message}" for event in 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): QMessageBox.warning(self, "导入采集", str(message)) self._set_status(str(message)) def _show_account_guide(self, message): full_message = ( f"{message}\n\n" "本轮采集已中止,不会自动打开账号 Chrome。\n" "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" ) QMessageBox.warning(self, "账号未就绪", full_message) self._set_status(full_message.replace("\n", " ")) if self.open_accounts_callback is not None: self.open_accounts_callback() def _choose_excel_files(self): files, _selected_filter = QFileDialog.getOpenFileNames( self, "选择 Excel 文件", "", "Excel 文件 (*.xlsx *.xlsm)", ) return files def import_excel(self, checked=False): file_paths = self._choose_excel_files() if not file_paths: return 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" 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_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): batch_ids = {batch.id for batch in batches} previous = selected_batch if selected_batch in batch_ids else None self.batch_filter.blockSignals(True) self.batch_filter.clear() self.batch_filter.addItem("全部批次", None) for batch in batches: self.batch_filter.addItem(self._batch_label(batch), batch.id) index = self.batch_filter.findData(previous) self.batch_filter.setCurrentIndex(index if index >= 0 else 0) self.batch_filter.blockSignals(False) def _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_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 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}", "", "软删除后,该批次不会再出现在①/②/③页面、筛选、采集、生成、更新或回写入口中。", "软删除只隐藏本地批次,不会回滚 Shopee 线上修改,不删除原始 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 worker = CollectWorker( tasks, db_path=self.db_path, config=self.config, diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, ) worker.progress.connect(self._on_collect_progress) worker.row_updated.connect(self._on_collect_row_updated) worker.log.connect(self._on_collect_log) worker.failed.connect(self._on_collect_failed) worker.finished.connect(self._on_collect_finished) worker.cancelled.connect(self._on_collect_cancelled) self.run_log_view.clear() thread = run_worker(worker, thread_name="CollectWorker", start=False) thread.finished.connect(lambda: self._forget_collect_thread(thread)) self.collect_worker = worker self.collect_thread = thread self._set_collect_running(True) thread.start() def stop_collect(self, checked=False): if self.collect_worker is not None: self.collect_worker.cancel() self._set_status("正在停止采集...") def write_back_old_data(self, checked=False): batch_id = self._active_batch_id() if not batch_id: self._set_status("没有可回写批次") return self._start_write_back(batch_id) def _start_write_back(self, batch_id, auto=False): if self.write_back_thread is not None: self._set_status("Excel 回写正在进行...") return False worker = WriteBackWorker( batch_id, db_path=self.db_path, 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.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.status_filter.setEnabled(not running) self._update_delete_batch_button() def _forget_collect_thread(self, thread): if self.collect_thread is thread: self.collect_thread = None self.collect_worker = None def _forget_write_back_thread(self, thread): if self.write_back_thread is thread: self.write_back_thread = None self.write_back_worker = None def _on_collect_progress(self, payload): self._set_status( "采集进度:{done}/{total},成功{collected},略过{skipped},失败{failed}".format( done=payload.get("done", 0), total=payload.get("total", 0), collected=payload.get("collected", 0), skipped=payload.get("skipped", 0), failed=payload.get("failed", 0), ) ) def _on_collect_row_updated(self, task_id, fields): self.refresh_tasks() def _on_collect_failed(self, task_id, error): self._set_status(f"任务 {task_id} 采集失败:{error}") def _on_collect_finished(self, payload): self._set_collect_running(False) self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id self.refresh_tasks() self._load_latest_collect_run_log() if payload.get("blocked"): self._show_collect_blocked(payload) return message = "采集完成:成功{collected},略过{skipped},失败{failed}".format( collected=payload.get("collected", 0), skipped=payload.get("skipped", 0), failed=payload.get("failed", 0), ) if payload.get("collected", 0) > 0: batch_id = self._active_batch_id() if batch_id and self._start_write_back(batch_id, auto=True): if self.last_collect_run_id: self._log_collect_run_event( self.last_collect_run_id, "step=excel_write_back result=start detail=采集成功后自动回写旧数据到 Excel", ) self._set_status(f"{message},正在自动回写 Excel...") return if not batch_id: self._set_status(f"{message},但没有可回写批次") return self._set_status(f"{message},Excel 回写已在进行") return self._set_status(message) def _show_collect_blocked(self, payload): lines = ["采集前检查未通过。"] if payload.get("no_accounts"): lines.append("当前没有配置账号。") not_running = payload.get("not_running") or [] if not_running: lines.append( "以下账号 Chrome 未启动或调试端口不可访问:" + "、".join(self._account_label(item) for item in not_running) ) logged_out = payload.get("logged_out") or [] if logged_out: lines.append( "以下账号未登录 Shopee:" + "、".join(self._account_label(item) for item in logged_out) ) self._show_account_guide("\n".join(lines)) def _account_label(self, item): if isinstance(item, dict): name = item.get("account_name") or item.get("alias") or "" alias = item.get("alias") or "" reason = item.get("reason") else: name = getattr(item, "account_name", "") or getattr(item, "alias", "") alias = getattr(item, "alias", "") reason = getattr(item, "reason", None) label = f"{name}({alias})" if alias and name != alias else (name or alias) return f"{label}: {reason}" if reason else label def _on_collect_cancelled(self, payload): self._set_collect_running(False) self.refresh_tasks() self._set_status( "采集已停止:完成{done}/{total}".format( done=payload.get("done", 0), total=payload.get("total", 0), ) ) def _on_write_back_failed(self, task_id, error, auto=False): message = f"Excel {'自动' if auto else ''}回写失败:{error}" if "被占用" in str(error): if auto: message += "\n请关闭原 Excel 后点击「回写旧数据到 Excel」手动重试;SQLite 已保留采集结果,也可另存副本。" else: message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。" QMessageBox.warning(self, "回写旧数据", message) self._set_status(message.replace("\n", " ")) if auto and self.last_collect_run_id: self._log_collect_run_event( self.last_collect_run_id, f"step=excel_write_back result=failed detail={error}", level="error", ) def _on_write_back_finished(self, payload, auto=False): self._set_write_back_running(False) if payload.get("ok") is False: error = payload.get("error") or "未知错误" retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else "" self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}") if auto and self.last_collect_run_id: self._log_collect_run_event( self.last_collect_run_id, f"step=excel_write_back result=failed detail={error}", level="error", ) return self.refresh_tasks() self._set_status( "Excel {prefix}回写完成:文件{files},行{rows}".format( prefix="自动" if auto else "", files=payload.get("files", 0), rows=payload.get("rows", 0), ) ) if auto and self.last_collect_run_id: self._log_collect_run_event( self.last_collect_run_id, "step=excel_write_back result=success detail=旧数据已回写 Excel", ) def show_all_tasks(self, checked=False): self.model.set_filter_mode("all") self._update_empty_label(len(self.model.all_tasks)) def show_unmatched_tasks(self, checked=False): self.model.set_filter_mode("unmatched") self._update_empty_label(len(self.model.all_tasks)) def _update_summary(self, task_rows, account_rows): stats = self.last_import_stats or {} unmatched = self._unmatched_count(task_rows, account_rows) matched = len(task_rows) - unmatched files = stats.get("files", 0 if not task_rows else 1) total = stats.get("total", len(task_rows)) valid = stats.get("valid", len(task_rows)) invalid = stats.get("invalid", 0) 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} 条任务别名未匹配账号,阶段显示为“略过”" )