feat(collect): show per-item collection activity timer
This commit is contained in:
+218
-1
@@ -20,6 +20,35 @@ def CollectWorker(*args, **kwargs):
|
|||||||
def WriteBackWorker(*args, **kwargs):
|
def WriteBackWorker(*args, **kwargs):
|
||||||
return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *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):
|
class CollectTab(QWidget):
|
||||||
"""Tab 1: import Excel files and list imported tasks."""
|
"""Tab 1: import Excel files and list imported tasks."""
|
||||||
|
|
||||||
@@ -56,6 +85,15 @@ class CollectTab(QWidget):
|
|||||||
self.write_back_worker = None
|
self.write_back_worker = None
|
||||||
self.write_back_thread = None
|
self.write_back_thread = None
|
||||||
self.last_collect_run_id = 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.import_button = QPushButton("导入 Excel...")
|
||||||
self.refresh_button = QPushButton("刷新")
|
self.refresh_button = QPushButton("刷新")
|
||||||
@@ -101,6 +139,14 @@ class CollectTab(QWidget):
|
|||||||
self.summary_label = QLabel("未导入任务")
|
self.summary_label = QLabel("未导入任务")
|
||||||
self.summary_label.setTextFormat(Qt.RichText)
|
self.summary_label.setTextFormat(Qt.RichText)
|
||||||
self.batch_progress_label = _build_batch_progress_overview("collectBatchProgressOverview")
|
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.match_detail_label = QLabel("")
|
||||||
self.show_all_button = QPushButton("全部")
|
self.show_all_button = QPushButton("全部")
|
||||||
self.show_unmatched_button = QPushButton("未匹配(0)")
|
self.show_unmatched_button = QPushButton("未匹配(0)")
|
||||||
@@ -112,6 +158,12 @@ class CollectTab(QWidget):
|
|||||||
summary_layout.addWidget(self.show_all_button)
|
summary_layout.addWidget(self.show_all_button)
|
||||||
summary_layout.addWidget(self.show_unmatched_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.table = QTableView()
|
||||||
self.model = TaskTableModel(self.table)
|
self.model = TaskTableModel(self.table)
|
||||||
self.table.setModel(self.model)
|
self.table.setModel(self.model)
|
||||||
@@ -142,7 +194,7 @@ class CollectTab(QWidget):
|
|||||||
layout.addLayout(filter_layout)
|
layout.addLayout(filter_layout)
|
||||||
layout.addLayout(summary_layout)
|
layout.addLayout(summary_layout)
|
||||||
layout.addWidget(self.match_detail_label)
|
layout.addWidget(self.match_detail_label)
|
||||||
layout.addWidget(self.batch_progress_label)
|
layout.addLayout(batch_progress_layout)
|
||||||
layout.addWidget(self.empty_state_card)
|
layout.addWidget(self.empty_state_card)
|
||||||
layout.addWidget(self.table, 1)
|
layout.addWidget(self.table, 1)
|
||||||
layout.addWidget(QLabel("采集运行日志"))
|
layout.addWidget(QLabel("采集运行日志"))
|
||||||
@@ -168,6 +220,156 @@ class CollectTab(QWidget):
|
|||||||
def _set_status(self, message, level=None):
|
def _set_status(self, message, level=None):
|
||||||
_emit_status(self.status_callback, message, level=level)
|
_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):
|
def _on_collect_log(self, message):
|
||||||
self._append_collect_log(message)
|
self._append_collect_log(message)
|
||||||
self._set_status(message)
|
self._set_status(message)
|
||||||
@@ -522,6 +724,9 @@ class CollectTab(QWidget):
|
|||||||
config=self.config,
|
config=self.config,
|
||||||
diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR,
|
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.progress.connect(self._on_collect_progress)
|
||||||
worker.row_updated.connect(self._on_collect_row_updated)
|
worker.row_updated.connect(self._on_collect_row_updated)
|
||||||
worker.log.connect(self._on_collect_log)
|
worker.log.connect(self._on_collect_log)
|
||||||
@@ -534,11 +739,14 @@ class CollectTab(QWidget):
|
|||||||
self.collect_worker = worker
|
self.collect_worker = worker
|
||||||
self.collect_thread = thread
|
self.collect_thread = thread
|
||||||
self._set_collect_running(True)
|
self._set_collect_running(True)
|
||||||
|
self._start_collect_activity()
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def stop_collect(self, checked=False):
|
def stop_collect(self, checked=False):
|
||||||
if self.collect_worker is not None:
|
if self.collect_worker is not None:
|
||||||
self.collect_worker.cancel()
|
self.collect_worker.cancel()
|
||||||
|
self._collect_stop_requested = True
|
||||||
|
self._refresh_collect_activity()
|
||||||
self._set_status("正在停止采集...")
|
self._set_status("正在停止采集...")
|
||||||
|
|
||||||
def write_back_old_data(self, checked=False):
|
def write_back_old_data(self, checked=False):
|
||||||
@@ -616,6 +824,8 @@ class CollectTab(QWidget):
|
|||||||
|
|
||||||
def _forget_collect_thread(self, thread):
|
def _forget_collect_thread(self, thread):
|
||||||
if self.collect_thread is thread:
|
if self.collect_thread is thread:
|
||||||
|
if self._collect_elapsed_timer.isActive():
|
||||||
|
self._finish_collect_activity("error")
|
||||||
self.collect_thread = None
|
self.collect_thread = None
|
||||||
self.collect_worker = None
|
self.collect_worker = None
|
||||||
|
|
||||||
@@ -643,6 +853,12 @@ class CollectTab(QWidget):
|
|||||||
|
|
||||||
def _on_collect_finished(self, payload):
|
def _on_collect_finished(self, payload):
|
||||||
self._set_collect_running(False)
|
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.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id
|
||||||
self.refresh_tasks()
|
self.refresh_tasks()
|
||||||
self._load_latest_collect_run_log()
|
self._load_latest_collect_run_log()
|
||||||
@@ -747,6 +963,7 @@ class CollectTab(QWidget):
|
|||||||
|
|
||||||
def _on_collect_cancelled(self, payload):
|
def _on_collect_cancelled(self, payload):
|
||||||
self._set_collect_running(False)
|
self._set_collect_running(False)
|
||||||
|
self._finish_collect_activity("cancelled", payload)
|
||||||
self.refresh_tasks()
|
self.refresh_tasks()
|
||||||
self._set_status(
|
self._set_status(
|
||||||
"采集已停止:完成{done}/{total}".format(
|
"采集已停止:完成{done}/{total}".format(
|
||||||
|
|||||||
+115
-1
@@ -7,6 +7,11 @@ import re
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PySide6.QtCore import Signal
|
||||||
|
except ModuleNotFoundError: # pragma: no cover - GUI import guard
|
||||||
|
Signal = None
|
||||||
|
|
||||||
from .. import ai, image_studio, image_studio_export, image_studio_generation, image_studio_images
|
from .. import ai, image_studio, image_studio_export, image_studio_generation, image_studio_images
|
||||||
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
|
from ..collect_skip import ALIAS_UNMATCHED, LOGIN_REQUIRED, empty_skip_reason_counts
|
||||||
from .widgets import *
|
from .widgets import *
|
||||||
@@ -1743,6 +1748,9 @@ class ApplyWorker(BaseWorker):
|
|||||||
class CollectWorker(BaseWorker):
|
class CollectWorker(BaseWorker):
|
||||||
"""Collect old title and cover for imported tasks."""
|
"""Collect old title and cover for imported tasks."""
|
||||||
|
|
||||||
|
if Signal is not None:
|
||||||
|
activity = Signal(dict)
|
||||||
|
|
||||||
LOGIN_CHECK_ATTEMPTS = 3
|
LOGIN_CHECK_ATTEMPTS = 3
|
||||||
LOGIN_CHECK_RETRY_DELAY_SECONDS = 2.0
|
LOGIN_CHECK_RETRY_DELAY_SECONDS = 2.0
|
||||||
|
|
||||||
@@ -1785,6 +1793,11 @@ class CollectWorker(BaseWorker):
|
|||||||
skip_reason_counts = empty_skip_reason_counts()
|
skip_reason_counts = empty_skip_reason_counts()
|
||||||
|
|
||||||
self._run_id = self._create_run_log(eligible, batch_ids)
|
self._run_id = self._create_run_log(eligible, batch_ids)
|
||||||
|
self._emit_activity(
|
||||||
|
"preflight_started",
|
||||||
|
total=total,
|
||||||
|
step="preflight",
|
||||||
|
)
|
||||||
self._log_run_event(
|
self._log_run_event(
|
||||||
f"step=preflight result=start detail=采集运行开始 total={total}"
|
f"step=preflight result=start detail=采集运行开始 total={total}"
|
||||||
)
|
)
|
||||||
@@ -1822,9 +1835,16 @@ class CollectWorker(BaseWorker):
|
|||||||
level="warning",
|
level="warning",
|
||||||
)
|
)
|
||||||
|
|
||||||
for task in eligible:
|
for index, task in enumerate(eligible, start=1):
|
||||||
if self.should_cancel():
|
if self.should_cancel():
|
||||||
break
|
break
|
||||||
|
self._emit_activity(
|
||||||
|
"task_started",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="match_account",
|
||||||
|
)
|
||||||
account = account_by_alias.get(str(task.alias).strip())
|
account = account_by_alias.get(str(task.alias).strip())
|
||||||
if account is None:
|
if account is None:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
@@ -1842,6 +1862,14 @@ class CollectWorker(BaseWorker):
|
|||||||
task=task,
|
task=task,
|
||||||
level="warning",
|
level="warning",
|
||||||
)
|
)
|
||||||
|
self._emit_activity(
|
||||||
|
"task_finished",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="match_account",
|
||||||
|
result="skipped",
|
||||||
|
)
|
||||||
self._emit_progress(done, total, collected, skipped, failed)
|
self._emit_progress(done, total, collected, skipped, failed)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -1862,9 +1890,24 @@ class CollectWorker(BaseWorker):
|
|||||||
task=task,
|
task=task,
|
||||||
level="warning",
|
level="warning",
|
||||||
)
|
)
|
||||||
|
self._emit_activity(
|
||||||
|
"task_finished",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="check_login",
|
||||||
|
result="skipped",
|
||||||
|
)
|
||||||
self._emit_progress(done, total, collected, skipped, failed)
|
self._emit_progress(done, total, collected, skipped, failed)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
self._emit_activity(
|
||||||
|
"task_step",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="check_login",
|
||||||
|
)
|
||||||
status = self._confirmed_login_status(account, context="midrun", task=task)
|
status = self._confirmed_login_status(account, context="midrun", task=task)
|
||||||
if self._is_definitive_logged_out(status):
|
if self._is_definitive_logged_out(status):
|
||||||
alias = str(task.alias).strip()
|
alias = str(task.alias).strip()
|
||||||
@@ -1885,6 +1928,14 @@ class CollectWorker(BaseWorker):
|
|||||||
task=task,
|
task=task,
|
||||||
level="warning",
|
level="warning",
|
||||||
)
|
)
|
||||||
|
self._emit_activity(
|
||||||
|
"task_finished",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="check_login",
|
||||||
|
result="skipped",
|
||||||
|
)
|
||||||
self._emit_progress(done, total, collected, skipped, failed)
|
self._emit_progress(done, total, collected, skipped, failed)
|
||||||
continue
|
continue
|
||||||
if not status.get("logged_in"):
|
if not status.get("logged_in"):
|
||||||
@@ -1900,10 +1951,18 @@ class CollectWorker(BaseWorker):
|
|||||||
|
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
current_step = "db_write"
|
current_step = "db_write"
|
||||||
|
activity_result = "success"
|
||||||
|
|
||||||
def on_step(step):
|
def on_step(step):
|
||||||
nonlocal current_step
|
nonlocal current_step
|
||||||
current_step = str(step)
|
current_step = str(step)
|
||||||
|
self._emit_activity(
|
||||||
|
"task_step",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step=current_step,
|
||||||
|
)
|
||||||
self._log_run_event(
|
self._log_run_event(
|
||||||
"step={step} result=start detail=任务 {task_id} 商品 {item_id}".format(
|
"step={step} result=start detail=任务 {task_id} 商品 {item_id}".format(
|
||||||
step=current_step,
|
step=current_step,
|
||||||
@@ -1914,6 +1973,13 @@ class CollectWorker(BaseWorker):
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
self._emit_activity(
|
||||||
|
"task_step",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="prepare_task",
|
||||||
|
)
|
||||||
self._log_run_event(
|
self._log_run_event(
|
||||||
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 标记采集运行".format(
|
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 标记采集运行".format(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
@@ -1932,6 +1998,13 @@ class CollectWorker(BaseWorker):
|
|||||||
on_step=on_step,
|
on_step=on_step,
|
||||||
)
|
)
|
||||||
current_step = "db_write"
|
current_step = "db_write"
|
||||||
|
self._emit_activity(
|
||||||
|
"task_step",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step="save_result",
|
||||||
|
)
|
||||||
self._log_run_event(
|
self._log_run_event(
|
||||||
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存采集结果".format(
|
"step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存采集结果".format(
|
||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
@@ -1965,6 +2038,7 @@ class CollectWorker(BaseWorker):
|
|||||||
task=task,
|
task=task,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
activity_result = "failed"
|
||||||
failed += 1
|
failed += 1
|
||||||
error = str(exc) or exc.__class__.__name__
|
error = str(exc) or exc.__class__.__name__
|
||||||
safe_error = diagnostics.redact_log_text(error)
|
safe_error = diagnostics.redact_log_text(error)
|
||||||
@@ -1993,6 +2067,14 @@ class CollectWorker(BaseWorker):
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
done += 1
|
done += 1
|
||||||
|
self._emit_activity(
|
||||||
|
"task_finished",
|
||||||
|
task=task,
|
||||||
|
index=index,
|
||||||
|
total=total,
|
||||||
|
step=current_step,
|
||||||
|
result=activity_result,
|
||||||
|
)
|
||||||
self._emit_progress(done, total, collected, skipped, failed)
|
self._emit_progress(done, total, collected, skipped, failed)
|
||||||
|
|
||||||
summary = self._summary(
|
summary = self._summary(
|
||||||
@@ -2115,6 +2197,38 @@ class CollectWorker(BaseWorker):
|
|||||||
payload["reason"] = reason
|
payload["reason"] = reason
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
def _emit_activity(
|
||||||
|
self,
|
||||||
|
state,
|
||||||
|
*,
|
||||||
|
task=None,
|
||||||
|
index=0,
|
||||||
|
total=0,
|
||||||
|
step=None,
|
||||||
|
result=None,
|
||||||
|
):
|
||||||
|
signal = getattr(self, "activity", None)
|
||||||
|
if signal is None:
|
||||||
|
return
|
||||||
|
payload = {
|
||||||
|
"state": str(state),
|
||||||
|
"index": int(index or 0),
|
||||||
|
"total": int(total or 0),
|
||||||
|
}
|
||||||
|
if task is not None:
|
||||||
|
payload.update(
|
||||||
|
{
|
||||||
|
"task_id": getattr(task, "id", None),
|
||||||
|
"item_id": str(getattr(task, "item_id", "") or ""),
|
||||||
|
"alias": str(getattr(task, "alias", "") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if step:
|
||||||
|
payload["step"] = str(step)
|
||||||
|
if result:
|
||||||
|
payload["result"] = str(result)
|
||||||
|
signal.emit(payload)
|
||||||
|
|
||||||
def _emit_progress(self, done, total, collected, skipped, failed):
|
def _emit_progress(self, done, total, collected, skipped, failed):
|
||||||
self.progress.emit(
|
self.progress.emit(
|
||||||
{
|
{
|
||||||
|
|||||||
+8
-1
@@ -3,7 +3,7 @@ id: T-628
|
|||||||
title: 采集当前记录阶段与单条耗时指示器
|
title: 采集当前记录阶段与单条耗时指示器
|
||||||
phase: 2
|
phase: 2
|
||||||
deps: [T-620]
|
deps: [T-620]
|
||||||
status: TODO
|
status: DONE
|
||||||
created: 2026-07-14
|
created: 2026-07-14
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -86,3 +86,10 @@ created: 2026-07-14
|
|||||||
- 不修改任务 `stage/status`、SQLite schema、Excel schema、回写流程或批次统计口径。
|
- 不修改任务 `stage/status`、SQLite schema、Excel schema、回写流程或批次统计口径。
|
||||||
- 不把计时器放进 Worker 线程,不让 GUI 每秒查询 SQLite,也不把每秒 tick 写入运行日志。
|
- 不把计时器放进 Worker 线程,不让 GUI 每秒查询 SQLite,也不把每秒 tick 写入运行日志。
|
||||||
- 不改变②AI生成、③更新蝦皮及⑥商品套图的计时显示。
|
- 不改变②AI生成、③更新蝦皮及⑥商品套图的计时显示。
|
||||||
|
|
||||||
|
## 执行记录
|
||||||
|
|
||||||
|
- 2026-07-14:`CollectWorker` 新增独立 `activity` 结构化信号,在前置检查、每条记录开始、采集步骤变化及单条结束时发送稳定 payload;每条记录进入循环即发 `task_started`,登录检查和 `editor.collect()` 步骤只更新阶段,不改变原有进度、日志、状态写库和终态信号。
|
||||||
|
- 2026-07-14:①「导入采集」在批次进度同行右侧新增固定宽度运行指示器,显示前置检查、`N/总数`、当前中文阶段和本条 `MM:SS`;基于 `time.monotonic()` 每秒刷新,每条开始重置,步骤变化不重置,停止请求期间继续显示实际等待,完成、阻断、停止和异常后冻结中文终态。
|
||||||
|
- 2026-07-14:新增 Worker 活动事件顺序和 GUI 计时生命周期测试,覆盖三条记录的成功/略过事件、步骤不重置、下一条归零、停止等待、终态冻结、小时格式及中文 tooltip;用户可见终态不展示底层英文异常。
|
||||||
|
- 验证:当前工作区全套 GUI 测试除任务开始前已存在的默认封面提示词 `papa1.txt` 改名造成的 2 项旧断言外,采集相关测试全部通过;在仅覆盖 T-628 四个文件的隔离 worktree 中运行 `python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`(461 项通过)及 `git diff --check`,结果全部通过。
|
||||||
|
|||||||
+151
-4
@@ -65,6 +65,7 @@ from app.gui import (
|
|||||||
)
|
)
|
||||||
from app.gui import file_manager
|
from app.gui import file_manager
|
||||||
import app.gui.workers as gui_workers
|
import app.gui.workers as gui_workers
|
||||||
|
from app.gui.tabs.collect import _format_collect_elapsed
|
||||||
from app.gui.tabs.generate import CoverGalleryDialog, OriginalImageDialog
|
from app.gui.tabs.generate import CoverGalleryDialog, OriginalImageDialog
|
||||||
from app.gui.main_window import _fit_and_center_window
|
from app.gui.main_window import _fit_and_center_window
|
||||||
|
|
||||||
@@ -7391,6 +7392,130 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
|
|
||||||
self.assert_removed(temp_dir)
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_collect_activity_tracks_step_resets_each_task_and_freezes_on_stop(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
tab = CollectTab(config=self.make_config(temp_dir))
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
|
||||||
|
self.assertEqual("collectActivityLabel", tab.collect_activity_label.objectName())
|
||||||
|
self.assertTrue(tab.collect_activity_label.isHidden())
|
||||||
|
self.assertEqual("1:01:01", _format_collect_elapsed(3661))
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=100.0):
|
||||||
|
tab._start_collect_activity()
|
||||||
|
self.assertTrue(tab._collect_elapsed_timer.isActive())
|
||||||
|
self.assertIn("正在检查账号", tab.collect_activity_label.text())
|
||||||
|
self.assertIn("00:00", tab.collect_activity_label.text())
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=105.0):
|
||||||
|
tab._on_collect_activity(
|
||||||
|
{
|
||||||
|
"state": "preflight_started",
|
||||||
|
"total": 2,
|
||||||
|
"step": "preflight",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertIn("正在检查账号", tab.collect_activity_label.text())
|
||||||
|
self.assertIn("00:05", tab.collect_activity_label.text())
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=110.0):
|
||||||
|
tab._on_collect_activity(
|
||||||
|
{
|
||||||
|
"state": "task_started",
|
||||||
|
"index": 1,
|
||||||
|
"total": 2,
|
||||||
|
"task_id": 1,
|
||||||
|
"item_id": "51100639510",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"step": "match_account",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"正在采集 1/2 · 匹配账号 · 本条 00:00",
|
||||||
|
tab.collect_activity_label.text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=117.0):
|
||||||
|
tab._on_collect_activity(
|
||||||
|
{
|
||||||
|
"state": "task_step",
|
||||||
|
"index": 1,
|
||||||
|
"total": 2,
|
||||||
|
"task_id": 1,
|
||||||
|
"item_id": "51100639510",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"step": "wait_ready",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"正在采集 1/2 · 等待商品页加载 · 本条 00:07",
|
||||||
|
tab.collect_activity_label.text(),
|
||||||
|
)
|
||||||
|
self.assertIn("商品ID:51100639510", tab.collect_activity_label.toolTip())
|
||||||
|
self.assertIn("当前阶段:等待商品页加载", tab.collect_activity_label.toolTip())
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=120.0):
|
||||||
|
tab._on_collect_activity(
|
||||||
|
{
|
||||||
|
"state": "task_started",
|
||||||
|
"index": 2,
|
||||||
|
"total": 2,
|
||||||
|
"task_id": 2,
|
||||||
|
"item_id": "51100639511",
|
||||||
|
"alias": "alias-a",
|
||||||
|
"step": "check_login",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
"正在采集 2/2 · 检查账号登录 · 本条 00:00",
|
||||||
|
tab.collect_activity_label.text(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=125.0):
|
||||||
|
tab._collect_stop_requested = True
|
||||||
|
tab._refresh_collect_activity()
|
||||||
|
self.assertEqual("正在停止 · 本条 00:05", tab.collect_activity_label.text())
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=130.0):
|
||||||
|
tab._finish_collect_activity("cancelled")
|
||||||
|
self.assertEqual("采集已停止 · 总用时 00:30", tab.collect_activity_label.text())
|
||||||
|
self.assertFalse(tab._collect_elapsed_timer.isActive())
|
||||||
|
|
||||||
|
frozen_text = tab.collect_activity_label.text()
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=999.0):
|
||||||
|
tab._refresh_collect_activity()
|
||||||
|
self.assertEqual(frozen_text, tab.collect_activity_label.text())
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
|
def test_collect_activity_terminal_states_use_chinese_semantics(self):
|
||||||
|
with self.make_temp_dir() as temp_dir:
|
||||||
|
tab = CollectTab(config=self.make_config(temp_dir))
|
||||||
|
self.addCleanup(tab.close)
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=10.0):
|
||||||
|
tab._start_collect_activity()
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=15.0):
|
||||||
|
tab._finish_collect_activity("blocked")
|
||||||
|
self.assertEqual("采集未开始 · 检查未通过", tab.collect_activity_label.text())
|
||||||
|
self.assertFalse(tab._collect_elapsed_timer.isActive())
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=20.0):
|
||||||
|
tab._start_collect_activity()
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=25.0):
|
||||||
|
tab._finish_collect_activity("error", {"error": "后台线程异常"})
|
||||||
|
self.assertEqual("采集已结束 · 请查看运行日志", tab.collect_activity_label.text())
|
||||||
|
self.assertEqual("采集异常结束,请查看下方采集运行日志", tab.collect_activity_label.toolTip())
|
||||||
|
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=30.0):
|
||||||
|
tab._start_collect_activity()
|
||||||
|
with mock.patch("app.gui.tabs.collect.time.monotonic", return_value=40.0):
|
||||||
|
tab._finish_collect_activity("finished")
|
||||||
|
self.assertEqual("采集完成 · 总用时 00:10", tab.collect_activity_label.text())
|
||||||
|
self.assertFalse(tab._collect_elapsed_timer.isActive())
|
||||||
|
|
||||||
|
self.assert_removed(temp_dir)
|
||||||
|
|
||||||
def test_collect_tab_soft_deletes_batch_and_refreshes_workflow_tabs(self):
|
def test_collect_tab_soft_deletes_batch_and_refreshes_workflow_tabs(self):
|
||||||
with self.make_temp_dir() as temp_dir:
|
with self.make_temp_dir() as temp_dir:
|
||||||
cfg = self.make_config(temp_dir)
|
cfg = self.make_config(temp_dir)
|
||||||
@@ -7706,14 +7831,17 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
"old_cover_path": task["old_cover_path"],
|
"old_cover_path": task["old_cover_path"],
|
||||||
}
|
}
|
||||||
|
|
||||||
with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \
|
worker = CollectWorker(
|
||||||
mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect:
|
|
||||||
summary = CollectWorker(
|
|
||||||
tasks,
|
tasks,
|
||||||
db_path=cfg["db_path"],
|
db_path=cfg["db_path"],
|
||||||
config=cfg,
|
config=cfg,
|
||||||
preflight=False,
|
preflight=False,
|
||||||
).execute()
|
)
|
||||||
|
activity_events = []
|
||||||
|
worker.activity.connect(activity_events.append)
|
||||||
|
with mock.patch("app.gui.accounts.detect_login", side_effect=fake_login), \
|
||||||
|
mock.patch("app.gui.editor.collect", side_effect=fake_collect) as collect:
|
||||||
|
summary = worker.execute()
|
||||||
|
|
||||||
self.assertTrue(summary["ok"])
|
self.assertTrue(summary["ok"])
|
||||||
self.assertEqual(3, summary["total"])
|
self.assertEqual(3, summary["total"])
|
||||||
@@ -7728,6 +7856,25 @@ class GuiTests(TempDirMixin, unittest.TestCase):
|
|||||||
self.assertEqual([batch_id], summary["batch_ids"])
|
self.assertEqual([batch_id], summary["batch_ids"])
|
||||||
self.assertIsInstance(summary["run_id"], int)
|
self.assertIsInstance(summary["run_id"], int)
|
||||||
collect.assert_called_once()
|
collect.assert_called_once()
|
||||||
|
self.assertEqual("preflight_started", activity_events[0]["state"])
|
||||||
|
self.assertEqual(3, activity_events[0]["total"])
|
||||||
|
task_starts = [event for event in activity_events if event["state"] == "task_started"]
|
||||||
|
self.assertEqual([1, 2, 3], [event["index"] for event in task_starts])
|
||||||
|
self.assertEqual(
|
||||||
|
["51100639510", "51100639511", "51100639512"],
|
||||||
|
[event["item_id"] for event in task_starts],
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
any(
|
||||||
|
event["state"] == "task_step" and event.get("step") == "download_cover"
|
||||||
|
for event in activity_events
|
||||||
|
)
|
||||||
|
)
|
||||||
|
task_finishes = [event for event in activity_events if event["state"] == "task_finished"]
|
||||||
|
self.assertEqual(
|
||||||
|
["success", "skipped", "skipped"],
|
||||||
|
[event["result"] for event in task_finishes],
|
||||||
|
)
|
||||||
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
updated = db.list_tasks(batch_id=batch_id, path=cfg["db_path"])
|
||||||
by_alias = {task.alias: task for task in updated}
|
by_alias = {task.alias: task for task in updated}
|
||||||
self.assertEqual("collected", by_alias["alias-a"].stage)
|
self.assertEqual("collected", by_alias["alias-a"].stage)
|
||||||
|
|||||||
Reference in New Issue
Block a user