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

1373 lines
59 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tab 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,
StatusRecheckWorker as _RealStatusRecheckWorker,
WriteBackWorker as _RealWriteBackWorker,
)
def CollectWorker(*args, **kwargs):
return _call_package_attr("CollectWorker", _RealCollectWorker, *args, **kwargs)
def StatusRecheckWorker(*args, **kwargs):
return _call_package_attr(
"StatusRecheckWorker",
_RealStatusRecheckWorker,
*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.status_recheck_worker = None
self.status_recheck_thread = None
self.write_back_worker = None
self.write_back_thread = None
self.last_collect_run_id = None
self.last_status_recheck_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_operation_label = "采集"
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.status_recheck_button = QPushButton("重新检测商品状态")
self.status_recheck_button.setObjectName("statusRecheckButton")
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.status_recheck_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.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.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.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.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.status_recheck_button.clicked.connect(self.recheck_product_status)
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, operation_label="采集"):
now = time.monotonic()
self._collect_operation_label = str(operation_label or "采集")
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"正在{self._collect_operation_label} {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:
if self._collect_operation_label == "采集":
text = f"正在检查账号 · {_format_collect_elapsed(run_elapsed)}"
else:
text = f"正在{self._collect_operation_label}前检查账号 · {_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 = f"{self._collect_operation_label}未开始 · 检查未通过"
level = "warning"
tooltip = f"{self._collect_operation_label}前检查未通过,请按弹窗提示处理"
elif outcome == "cancelled":
text = f"{self._collect_operation_label}已停止 · 总用时 {_format_collect_elapsed(total_elapsed)}"
level = "warning"
tooltip = f"本轮{self._collect_operation_label}已停止"
elif outcome == "error":
text = f"{self._collect_operation_label}已结束 · 请查看运行日志"
level = "danger"
if self._collect_operation_label == "采集":
tooltip = "采集异常结束,请查看下方采集运行日志"
else:
tooltip = f"{self._collect_operation_label}异常结束,请查看下方运行日志"
else:
text = f"{self._collect_operation_label}完成 · 总用时 {_format_collect_elapsed(total_elapsed)}"
level = "success"
tooltip = f"本轮{self._collect_operation_label}已经完成"
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, run_type="collect"):
try:
logs = db.list_run_logs(limit=1, run_type=run_type, 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, operation_label="采集"):
full_message = (
f"{message}\n\n"
f"本轮{operation_label}已中止。\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"
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):
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_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.status_recheck_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}",
"",
"软删除后,该批次不会再出现在①/②/③页面、筛选、采集、生成、更新或回写入口中。",
"软删除只隐藏本地批次,不会回滚蝦皮线上修改,不删除原始 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):
if self._collect_operation_running() or self.write_back_thread is not None:
self._set_status("当前批处理尚未结束,请稍后再试")
return
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 recheck_product_status(self, checked=False):
if self._collect_operation_running() or self.write_back_thread is not None:
self._set_status("当前批处理尚未结束,请稍后再试")
return
tasks = list(self.model.tasks)
if not tasks:
self._set_status("当前筛选结果没有可重新检测商品状态的任务")
return
answer = QMessageBox.question(
self,
"重新检测商品状态",
"将重新打开当前筛选结果中的 {count} 条商品详情页,只读取并保存商品状态。\n\n"
"不会读取或覆盖旧标题、旧封面、新标题、新封面;不会改变任务阶段、结果、线上提交标记或 Excel。\n\n"
"确定开始重新检测吗?".format(count=len(tasks)),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消重新检测商品状态")
return
worker = StatusRecheckWorker(
tasks,
db_path=self.db_path,
config=self.config,
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
)
activity_signal = getattr(worker, "activity", None)
if activity_signal is not None:
activity_signal.connect(self._on_collect_activity)
worker.progress.connect(self._on_status_recheck_progress)
worker.row_updated.connect(self._on_status_recheck_row_updated)
worker.log.connect(self._on_collect_log)
worker.failed.connect(self._on_status_recheck_failed)
worker.finished.connect(self._on_status_recheck_finished)
worker.cancelled.connect(self._on_status_recheck_cancelled)
self.run_log_view.clear()
thread = run_worker(worker, thread_name="StatusRecheckWorker", start=False)
thread.finished.connect(lambda: self._forget_status_recheck_thread(thread))
self.status_recheck_worker = worker
self.status_recheck_thread = thread
self._set_collect_running(True)
self._start_collect_activity("商品状态重检")
self._set_status(f"正在重新检测 {len(tasks)} 条商品状态...")
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):
worker = self.collect_worker or self.status_recheck_worker
if worker is not None:
worker.cancel()
self._collect_stop_requested = True
self._refresh_collect_activity()
self._set_status(f"正在停止{self._collect_operation_label}...")
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._collect_operation_running():
self._set_status("当前批处理尚未结束,暂不能回写 Excel")
return 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.status_recheck_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.status_recheck_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:
if self._collect_elapsed_timer.isActive():
self._finish_collect_activity("error")
self.collect_thread = None
self.collect_worker = None
def _forget_status_recheck_thread(self, thread):
if self.status_recheck_thread is thread:
if self._collect_elapsed_timer.isActive():
self._finish_collect_activity("error")
self.status_recheck_thread = None
self.status_recheck_worker = None
def _collect_operation_running(self):
return bool(self.collect_thread or self.status_recheck_thread)
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_status_recheck_progress(self, payload):
self._set_status(
"商品状态重检进度:{done}/{total},成功{rechecked},略过{skipped},失败{failed}".format(
done=payload.get("done", 0),
total=payload.get("total", 0),
rechecked=payload.get("rechecked", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
)
def _on_status_recheck_row_updated(self, task_id, fields):
self.refresh_tasks()
if self.refresh_workflow_callback is not None:
self.refresh_workflow_callback()
def _on_status_recheck_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 _on_status_recheck_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_status_recheck_run_id = (
payload.get("run_id") or self.last_status_recheck_run_id
)
self.refresh_tasks()
if self.refresh_workflow_callback is not None:
self.refresh_workflow_callback()
self._load_latest_collect_run_log("status_recheck")
if payload.get("blocked"):
self._show_status_recheck_blocked(payload)
return
message = "商品状态重检完成:成功{rechecked},略过{skipped},失败{failed}".format(
rechecked=payload.get("rechecked", 0),
skipped=payload.get("skipped", 0),
failed=payload.get("failed", 0),
)
counts = payload.get("product_status_counts") or {}
if counts:
message += ";正常{normal},未上架{unlisted},审核中{reviewing},状态未知{unknown}".format(
normal=counts.get("normal", 0),
unlisted=counts.get("unlisted", 0),
reviewing=counts.get("reviewing", 0),
unknown=counts.get("unknown", 0),
)
self._show_status_recheck_account_summary(payload, message)
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_status_recheck_blocked(self, payload):
lines = ["商品状态重检前检查未通过。"]
if payload.get("no_accounts"):
lines.append("当前没有配置账号。")
launch_failed = payload.get("launch_failed") or []
if launch_failed:
lines.append(
"以下账号 Chrome 启动失败:"
+ "、".join(self._account_label(item) for item in launch_failed)
)
self._show_account_guide("\n".join(lines), operation_label="商品状态重检")
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 _show_status_recheck_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 []
skip_counts = normalize_skip_reason_counts(
payload.get("skip_reason_counts"),
skipped_total=int(payload.get("skipped", 0) or 0),
)
lines = [message]
skip_summary = format_skip_reason_summary(
skip_counts,
skipped_total=int(payload.get("skipped", 0) or 0),
)
if skip_summary:
lines.append(skip_summary)
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 login_required or skip_counts[LOGIN_REQUIRED] > 0:
lines.append("请到账号管理完成对应账号登录后,再重新检测商品状态。")
if launched or reused:
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_status_recheck_cancelled(self, payload):
self._set_collect_running(False)
self._finish_collect_activity("cancelled", payload)
self.refresh_tasks()
if self.refresh_workflow_callback is not None:
self.refresh_workflow_callback()
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} 条任务别名未匹配账号,阶段显示为“略过”"
)