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

1111 lines
47 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 ..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_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.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.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.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"
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.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):
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,
)
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 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.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:
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),
)
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))
skip_counts = normalize_skip_reason_counts(
payload.get("skip_reason_counts"),
skipped_total=skipped,
)
if not launched and not reused and not login_required and skipped == 0:
return
lines = [message]
skip_summary = format_skip_reason_summary(skip_counts, skipped_total=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 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} 条任务别名未匹配账号,阶段显示为“略过”"
)