843 lines
35 KiB
Python
843 lines
35 KiB
Python
"""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, level=None):
|
||
_emit_status(self.status_callback, message, level=level)
|
||
|
||
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,并确认已人工登录蝦皮。"
|
||
)
|
||
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,
|
||
)
|
||
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(
|
||
"以下账号未登录蝦皮:"
|
||
+ "、".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} 条任务别名未匹配账号,阶段显示为“略过”"
|
||
)
|