fix: 任务执行前检查 Android 设备连接 (#84)
This commit is contained in:
@@ -120,6 +120,32 @@ class AndroidDeviceService:
|
|||||||
)
|
)
|
||||||
return enriched
|
return enriched
|
||||||
|
|
||||||
|
def require_connected(self, serial: str) -> AndroidDevice:
|
||||||
|
"""确认已保存设备仍由 ADB 识别且状态可用。"""
|
||||||
|
|
||||||
|
self._validate_saved_serial(serial)
|
||||||
|
device = next(
|
||||||
|
(item for item in self._list_devices() if item.serial == serial),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if device is None:
|
||||||
|
connection = "Wi-Fi" if ":" in serial else "USB"
|
||||||
|
raise AndroidDeviceSearchError(
|
||||||
|
f"{connection} Android 设备 {serial} 未连接,"
|
||||||
|
"请连接设备后重试"
|
||||||
|
)
|
||||||
|
if device.status == "unauthorized":
|
||||||
|
raise AndroidDeviceSearchError(
|
||||||
|
f"Android 设备 {serial} 未授权,"
|
||||||
|
"请在手机上允许 USB 调试后重试"
|
||||||
|
)
|
||||||
|
if device.status != "device":
|
||||||
|
raise AndroidDeviceSearchError(
|
||||||
|
f"Android 设备 {serial} 当前为 {device.status} 状态,"
|
||||||
|
"请重新连接后重试"
|
||||||
|
)
|
||||||
|
return device
|
||||||
|
|
||||||
def is_package_installed(
|
def is_package_installed(
|
||||||
self,
|
self,
|
||||||
serial: str,
|
serial: str,
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ class PDDTaskPage(QWidget):
|
|||||||
refreshRequested = pyqtSignal()
|
refreshRequested = pyqtSignal()
|
||||||
rerunRequested = pyqtSignal(str)
|
rerunRequested = pyqtSignal(str)
|
||||||
rerunCancelRequested = pyqtSignal()
|
rerunCancelRequested = pyqtSignal()
|
||||||
|
openSettingsRequested = pyqtSignal()
|
||||||
detailRequested = pyqtSignal(str)
|
detailRequested = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
|
|||||||
+112
-15
@@ -20,7 +20,7 @@
|
|||||||
只有必须让用户当场做决定时才用模态对话框。
|
只有必须让用户当场做决定时才用模态对话框。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Optional
|
from typing import Callable, Dict, Optional
|
||||||
|
|
||||||
from PyQt5.QtCore import (
|
from PyQt5.QtCore import (
|
||||||
QCoreApplication,
|
QCoreApplication,
|
||||||
@@ -32,6 +32,10 @@ from PyQt5.QtCore import (
|
|||||||
)
|
)
|
||||||
from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox, PushButton
|
from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox, PushButton
|
||||||
|
|
||||||
|
from .android_device_service import (
|
||||||
|
AndroidDeviceSearchError,
|
||||||
|
AndroidDeviceService,
|
||||||
|
)
|
||||||
from .admin_gateway import (
|
from .admin_gateway import (
|
||||||
AdminGatewayError,
|
AdminGatewayError,
|
||||||
ClientInfo,
|
ClientInfo,
|
||||||
@@ -101,6 +105,7 @@ class ClaimTaskWorker(QObject):
|
|||||||
localSaveFailed = pyqtSignal(str, str)
|
localSaveFailed = pyqtSignal(str, str)
|
||||||
retryableFailed = pyqtSignal(str)
|
retryableFailed = pyqtSignal(str)
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
deviceUnavailable = pyqtSignal(str)
|
||||||
outcome = pyqtSignal(str, str, str)
|
outcome = pyqtSignal(str, str, str)
|
||||||
completed = pyqtSignal()
|
completed = pyqtSignal()
|
||||||
|
|
||||||
@@ -114,6 +119,7 @@ class ClaimTaskWorker(QObject):
|
|||||||
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
||||||
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
||||||
selected_task_id: str = "",
|
selected_task_id: str = "",
|
||||||
|
device_connection_checker: Optional[Callable[[str], None]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._gateway = gateway
|
self._gateway = gateway
|
||||||
@@ -125,6 +131,10 @@ class ClaimTaskWorker(QObject):
|
|||||||
self._purchase_adapter_factory = purchase_adapter_factory
|
self._purchase_adapter_factory = purchase_adapter_factory
|
||||||
self._purchase_reconcile_factory = purchase_reconcile_factory
|
self._purchase_reconcile_factory = purchase_reconcile_factory
|
||||||
self._selected_task_id = selected_task_id
|
self._selected_task_id = selected_task_id
|
||||||
|
self._device_connection_checker = (
|
||||||
|
device_connection_checker
|
||||||
|
or AndroidDeviceService().require_connected
|
||||||
|
)
|
||||||
|
|
||||||
def cancel(self) -> None:
|
def cancel(self) -> None:
|
||||||
"""阻止尚未开始的领取;已领取的任务仍必须保存到本地。"""
|
"""阻止尚未开始的领取;已领取的任务仍必须保存到本地。"""
|
||||||
@@ -147,6 +157,10 @@ class ClaimTaskWorker(QObject):
|
|||||||
client_settings.client_name,
|
client_settings.client_name,
|
||||||
)
|
)
|
||||||
if self._selected_task_id:
|
if self._selected_task_id:
|
||||||
|
self._device_connection_checker(android_serial or "")
|
||||||
|
self._task_repository.prepare_collect_rerun(
|
||||||
|
self._selected_task_id
|
||||||
|
)
|
||||||
service = CollectTaskService(
|
service = CollectTaskService(
|
||||||
self._gateway,
|
self._gateway,
|
||||||
self._task_repository,
|
self._task_repository,
|
||||||
@@ -166,10 +180,14 @@ class ClaimTaskWorker(QObject):
|
|||||||
purchase_adapter_factory=self._purchase_adapter_factory,
|
purchase_adapter_factory=self._purchase_adapter_factory,
|
||||||
purchase_reconcile_factory=self._purchase_reconcile_factory,
|
purchase_reconcile_factory=self._purchase_reconcile_factory,
|
||||||
cancelled=lambda: self._cancelled,
|
cancelled=lambda: self._cancelled,
|
||||||
|
device_connection_checker=self._device_connection_checker,
|
||||||
)
|
)
|
||||||
result = dispatcher.execute_one()
|
result = dispatcher.execute_one()
|
||||||
if not self._cancelled or result.kind == "cancelled":
|
if not self._cancelled or result.kind == "cancelled":
|
||||||
self.outcome.emit(result.kind, result.message, result.task_id)
|
self.outcome.emit(result.kind, result.message, result.task_id)
|
||||||
|
except AndroidDeviceSearchError as exc:
|
||||||
|
if not self._cancelled:
|
||||||
|
self.deviceUnavailable.emit(str(exc))
|
||||||
except AdminGatewayError as exc:
|
except AdminGatewayError as exc:
|
||||||
if not self._cancelled:
|
if not self._cancelled:
|
||||||
request_hint = (
|
request_hint = (
|
||||||
@@ -200,6 +218,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
collect_service_factory: Optional[CollectServiceFactory] = None,
|
collect_service_factory: Optional[CollectServiceFactory] = None,
|
||||||
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
||||||
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
||||||
|
device_connection_checker: Optional[Callable[[str], None]] = None,
|
||||||
next_task_delay_ms: int = 500,
|
next_task_delay_ms: int = 500,
|
||||||
no_task_delay_ms: int = 5_000,
|
no_task_delay_ms: int = 5_000,
|
||||||
retry_delays_ms: tuple[int, ...] = (5_000, 10_000, 20_000, 30_000),
|
retry_delays_ms: tuple[int, ...] = (5_000, 10_000, 20_000, 30_000),
|
||||||
@@ -214,6 +233,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._claim_worker: Optional[ClaimTaskWorker] = None
|
self._claim_worker: Optional[ClaimTaskWorker] = None
|
||||||
self._rerun_cancel_requested = False
|
self._rerun_cancel_requested = False
|
||||||
self._rerun_feedback: Optional[InfoBar] = None
|
self._rerun_feedback: Optional[InfoBar] = None
|
||||||
|
self._device_feedback: Optional[InfoBar] = None
|
||||||
self._auto_fetch_running = False
|
self._auto_fetch_running = False
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
self._cycle_next_delay_ms: Optional[int] = None
|
self._cycle_next_delay_ms: Optional[int] = None
|
||||||
@@ -223,6 +243,10 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._collect_service_factory = collect_service_factory
|
self._collect_service_factory = collect_service_factory
|
||||||
self._purchase_adapter_factory = purchase_adapter_factory
|
self._purchase_adapter_factory = purchase_adapter_factory
|
||||||
self._purchase_reconcile_factory = purchase_reconcile_factory
|
self._purchase_reconcile_factory = purchase_reconcile_factory
|
||||||
|
self._device_connection_checker = (
|
||||||
|
device_connection_checker
|
||||||
|
or AndroidDeviceService().require_connected
|
||||||
|
)
|
||||||
if next_task_delay_ms < 0 or no_task_delay_ms <= 0:
|
if next_task_delay_ms < 0 or no_task_delay_ms <= 0:
|
||||||
raise ValueError("自动获取等待时间配置无效")
|
raise ValueError("自动获取等待时间配置无效")
|
||||||
if not retry_delays_ms or any(value <= 0 for value in retry_delays_ms):
|
if not retry_delays_ms or any(value <= 0 for value in retry_delays_ms):
|
||||||
@@ -325,8 +349,8 @@ class PDDTaskPageEvent(QObject):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
if not android_serial:
|
if not android_serial:
|
||||||
self._show_rerun_warning(
|
self._show_device_unavailable(
|
||||||
"重新执行不可用", "请先在设置页选择并保存 Android 设备。"
|
"请先在设置页选择并保存 Android 设备"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
@@ -353,17 +377,6 @@ class PDDTaskPageEvent(QObject):
|
|||||||
if not dialog.exec():
|
if not dialog.exec():
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
|
||||||
self._repository.prepare_collect_rerun(task_id)
|
|
||||||
except (CollectRerunError, ValueError) as exc:
|
|
||||||
self._show_rerun_warning("不能重新执行", str(exc))
|
|
||||||
self._reload()
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
self._show_rerun_warning(
|
|
||||||
"重新执行失败", "无法更新本地任务状态,请检查数据库后重试。"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
self._start_rerun_worker(task_id)
|
self._start_rerun_worker(task_id)
|
||||||
|
|
||||||
def _start_rerun_worker(self, task_id: str) -> None:
|
def _start_rerun_worker(self, task_id: str) -> None:
|
||||||
@@ -371,10 +384,13 @@ class PDDTaskPageEvent(QObject):
|
|||||||
|
|
||||||
assert self._claim_gateway is not None
|
assert self._claim_gateway is not None
|
||||||
self._close_rerun_feedback()
|
self._close_rerun_feedback()
|
||||||
|
self._close_device_feedback()
|
||||||
self._rerun_cancel_requested = False
|
self._rerun_cancel_requested = False
|
||||||
self._claim_busy = True
|
self._claim_busy = True
|
||||||
self._page.set_rerun_running(True)
|
self._page.set_rerun_running(True)
|
||||||
self._page.set_engine_status(f"正在重新采集任务 {task_id}…")
|
self._page.set_engine_status(
|
||||||
|
f"正在检查 Android 设备并准备重新采集任务 {task_id}…"
|
||||||
|
)
|
||||||
self._reload()
|
self._reload()
|
||||||
|
|
||||||
thread = QThread(self)
|
thread = QThread(self)
|
||||||
@@ -385,11 +401,13 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._selected_android_device_service,
|
self._selected_android_device_service,
|
||||||
self._collect_service_factory,
|
self._collect_service_factory,
|
||||||
selected_task_id=task_id,
|
selected_task_id=task_id,
|
||||||
|
device_connection_checker=self._device_connection_checker,
|
||||||
)
|
)
|
||||||
worker.moveToThread(thread)
|
worker.moveToThread(thread)
|
||||||
thread.started.connect(worker.run)
|
thread.started.connect(worker.run)
|
||||||
worker.retryableFailed.connect(self._on_rerun_failed)
|
worker.retryableFailed.connect(self._on_rerun_failed)
|
||||||
worker.failed.connect(self._on_rerun_failed)
|
worker.failed.connect(self._on_rerun_failed)
|
||||||
|
worker.deviceUnavailable.connect(self._on_rerun_device_unavailable)
|
||||||
worker.outcome.connect(self._on_rerun_outcome)
|
worker.outcome.connect(self._on_rerun_outcome)
|
||||||
worker.completed.connect(thread.quit)
|
worker.completed.connect(thread.quit)
|
||||||
worker.completed.connect(worker.deleteLater)
|
worker.completed.connect(worker.deleteLater)
|
||||||
@@ -443,6 +461,16 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._page.set_engine_status(message)
|
self._page.set_engine_status(message)
|
||||||
self._show_rerun_error("重新采集失败", message)
|
self._show_rerun_error("重新采集失败", message)
|
||||||
|
|
||||||
|
@pyqtSlot(str)
|
||||||
|
def _on_rerun_device_unavailable(self, message: str) -> None:
|
||||||
|
if self._closing or self._rerun_cancel_requested:
|
||||||
|
return
|
||||||
|
content = message or "Android 设备未连接,任务未开始"
|
||||||
|
self._page.set_engine_status(
|
||||||
|
f"Android 设备不可用,重新采集未开始:{content}"
|
||||||
|
)
|
||||||
|
self._show_device_unavailable(content)
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def _on_rerun_thread_finished(self) -> None:
|
def _on_rerun_thread_finished(self) -> None:
|
||||||
cancel_requested = self._rerun_cancel_requested
|
cancel_requested = self._rerun_cancel_requested
|
||||||
@@ -528,6 +556,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._show_claim_error("领取任务失败", message)
|
self._show_claim_error("领取任务失败", message)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self._close_device_feedback()
|
||||||
self._auto_fetch_running = True
|
self._auto_fetch_running = True
|
||||||
self._stop_requested = False
|
self._stop_requested = False
|
||||||
self._retry_count = 0
|
self._retry_count = 0
|
||||||
@@ -560,6 +589,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
collect_service_factory=self._collect_service_factory,
|
collect_service_factory=self._collect_service_factory,
|
||||||
purchase_adapter_factory=self._purchase_adapter_factory,
|
purchase_adapter_factory=self._purchase_adapter_factory,
|
||||||
purchase_reconcile_factory=self._purchase_reconcile_factory,
|
purchase_reconcile_factory=self._purchase_reconcile_factory,
|
||||||
|
device_connection_checker=self._device_connection_checker,
|
||||||
)
|
)
|
||||||
worker.moveToThread(thread)
|
worker.moveToThread(thread)
|
||||||
thread.started.connect(worker.run)
|
thread.started.connect(worker.run)
|
||||||
@@ -569,6 +599,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
worker.localSaveFailed.connect(self._on_claimed_task_save_failed)
|
worker.localSaveFailed.connect(self._on_claimed_task_save_failed)
|
||||||
worker.retryableFailed.connect(self._on_claim_retryable_failed)
|
worker.retryableFailed.connect(self._on_claim_retryable_failed)
|
||||||
worker.failed.connect(self._on_claim_failed)
|
worker.failed.connect(self._on_claim_failed)
|
||||||
|
worker.deviceUnavailable.connect(self._on_claim_device_unavailable)
|
||||||
worker.outcome.connect(self._on_collect_outcome)
|
worker.outcome.connect(self._on_collect_outcome)
|
||||||
worker.completed.connect(thread.quit)
|
worker.completed.connect(thread.quit)
|
||||||
worker.completed.connect(worker.deleteLater)
|
worker.completed.connect(worker.deleteLater)
|
||||||
@@ -682,6 +713,16 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._show_claim_error("领取任务失败", content)
|
self._show_claim_error("领取任务失败", content)
|
||||||
self._stop_after_current(content)
|
self._stop_after_current(content)
|
||||||
|
|
||||||
|
@pyqtSlot(str)
|
||||||
|
def _on_claim_device_unavailable(self, message: str) -> None:
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
|
content = message or "Android 设备未连接,任务未开始"
|
||||||
|
status = f"自动获取:已停止 · Android 设备不可用:{content}"
|
||||||
|
self._page.set_engine_status(status)
|
||||||
|
self._show_device_unavailable(content)
|
||||||
|
self._stop_after_current(status)
|
||||||
|
|
||||||
@pyqtSlot(str, str, str)
|
@pyqtSlot(str, str, str)
|
||||||
def _on_collect_outcome(self, kind: str, message: str, task_id: str) -> None:
|
def _on_collect_outcome(self, kind: str, message: str, task_id: str) -> None:
|
||||||
if self._closing:
|
if self._closing:
|
||||||
@@ -753,6 +794,53 @@ class PDDTaskPageEvent(QObject):
|
|||||||
parent=self._page,
|
parent=self._page,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _show_device_unavailable(self, content: str) -> None:
|
||||||
|
"""显示一条可进入设置且不会堆叠的设备错误。"""
|
||||||
|
|
||||||
|
self._close_device_feedback()
|
||||||
|
bar = InfoBar.error(
|
||||||
|
title="Android 设备不可用",
|
||||||
|
content=content,
|
||||||
|
isClosable=True,
|
||||||
|
duration=-1,
|
||||||
|
position=InfoBarPosition.TOP_RIGHT,
|
||||||
|
parent=self._page,
|
||||||
|
)
|
||||||
|
self._device_feedback = bar
|
||||||
|
settings_button = PushButton("打开设置", bar)
|
||||||
|
settings_button.setAccessibleName("打开 Android 设备设置")
|
||||||
|
settings_button.clicked.connect(
|
||||||
|
lambda _checked=False, current=bar: self._open_device_settings(current)
|
||||||
|
)
|
||||||
|
close_button = PushButton("关闭提示", bar)
|
||||||
|
close_button.setAccessibleName("关闭 Android 设备提示")
|
||||||
|
close_button.clicked.connect(
|
||||||
|
lambda _checked=False, current=bar: self._close_device_feedback(current)
|
||||||
|
)
|
||||||
|
bar.addWidget(settings_button)
|
||||||
|
bar.addWidget(close_button)
|
||||||
|
bar.destroyed.connect(
|
||||||
|
lambda _object=None, current=bar: self._forget_device_feedback(current)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_device_settings(self, bar: InfoBar) -> None:
|
||||||
|
self._page.openSettingsRequested.emit()
|
||||||
|
self._close_device_feedback(bar)
|
||||||
|
|
||||||
|
def _close_device_feedback(self, expected: Optional[InfoBar] = None) -> None:
|
||||||
|
bar = self._device_feedback
|
||||||
|
if bar is None or (expected is not None and bar is not expected):
|
||||||
|
return
|
||||||
|
self._device_feedback = None
|
||||||
|
try:
|
||||||
|
bar.close()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _forget_device_feedback(self, bar: InfoBar) -> None:
|
||||||
|
if self._device_feedback is bar:
|
||||||
|
self._device_feedback = None
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def _on_claim_thread_finished(self) -> None:
|
def _on_claim_thread_finished(self) -> None:
|
||||||
self._claim_worker = None
|
self._claim_worker = None
|
||||||
@@ -849,6 +937,7 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._stop_requested = True
|
self._stop_requested = True
|
||||||
self._next_cycle_timer.stop()
|
self._next_cycle_timer.stop()
|
||||||
self._close_rerun_feedback()
|
self._close_rerun_feedback()
|
||||||
|
self._close_device_feedback()
|
||||||
|
|
||||||
for window in list(self._detail_windows.values()):
|
for window in list(self._detail_windows.values()):
|
||||||
window.close()
|
window.close()
|
||||||
@@ -868,6 +957,14 @@ class PDDTaskPageEvent(QObject):
|
|||||||
self._on_claimed_task_save_failed,
|
self._on_claimed_task_save_failed,
|
||||||
),
|
),
|
||||||
(worker.failed, self._on_claim_failed),
|
(worker.failed, self._on_claim_failed),
|
||||||
|
(
|
||||||
|
worker.deviceUnavailable,
|
||||||
|
self._on_claim_device_unavailable,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
worker.deviceUnavailable,
|
||||||
|
self._on_rerun_device_unavailable,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
worker.retryableFailed,
|
worker.retryableFailed,
|
||||||
self._on_claim_retryable_failed,
|
self._on_claim_retryable_failed,
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ from .admin_gateway import (
|
|||||||
ClientInfo,
|
ClientInfo,
|
||||||
)
|
)
|
||||||
from .collect_task_service import CollectServiceFactory, CollectTaskService
|
from .collect_task_service import CollectServiceFactory, CollectTaskService
|
||||||
|
from .android_device_service import (
|
||||||
|
AndroidDeviceSearchError,
|
||||||
|
AndroidDeviceService,
|
||||||
|
)
|
||||||
from .purchase_task_service import (
|
from .purchase_task_service import (
|
||||||
PurchaseAdapterFactory,
|
PurchaseAdapterFactory,
|
||||||
PurchaseTaskService,
|
PurchaseTaskService,
|
||||||
@@ -126,6 +130,7 @@ class TaskDispatcher:
|
|||||||
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
purchase_adapter_factory: Optional[PurchaseAdapterFactory] = None,
|
||||||
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
purchase_reconcile_factory: Optional[PurchaseReconcileFactory] = None,
|
||||||
cancelled: Callable[[], bool] = lambda: False,
|
cancelled: Callable[[], bool] = lambda: False,
|
||||||
|
device_connection_checker: Optional[Callable[[str], None]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._gateway = gateway
|
self._gateway = gateway
|
||||||
self._repository = repository
|
self._repository = repository
|
||||||
@@ -135,6 +140,10 @@ class TaskDispatcher:
|
|||||||
self._purchase_factory = purchase_adapter_factory
|
self._purchase_factory = purchase_adapter_factory
|
||||||
self._reconcile_factory = purchase_reconcile_factory
|
self._reconcile_factory = purchase_reconcile_factory
|
||||||
self._cancelled = cancelled
|
self._cancelled = cancelled
|
||||||
|
self._device_connection_checker = (
|
||||||
|
device_connection_checker
|
||||||
|
or AndroidDeviceService().require_connected
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def purchase_ready(self) -> bool:
|
def purchase_ready(self) -> bool:
|
||||||
@@ -169,7 +178,10 @@ class TaskDispatcher:
|
|||||||
if pending is not None:
|
if pending is not None:
|
||||||
return self._submit_pending(pending)
|
return self._submit_pending(pending)
|
||||||
if not self._device_address:
|
if not self._device_address:
|
||||||
raise ValueError("请先在设置页选择并保存 Android 设备")
|
raise AndroidDeviceSearchError(
|
||||||
|
"请先在设置页选择并保存 Android 设备"
|
||||||
|
)
|
||||||
|
self._device_connection_checker(self._device_address)
|
||||||
|
|
||||||
reconcile_task = self._repository.next_purchase_reconcile_task()
|
reconcile_task = self._repository.next_purchase_reconcile_task()
|
||||||
if reconcile_task is not None:
|
if reconcile_task is not None:
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class MainWindow(FluentWindow):
|
|||||||
purchase_adapter_factory=create_u2_purchase_adapter,
|
purchase_adapter_factory=create_u2_purchase_adapter,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.pddTaskPage.openSettingsRequested.connect(
|
||||||
|
lambda: self.switchTo(self.settingsPage)
|
||||||
|
)
|
||||||
self.addSubInterface(self.pddTaskPage, FIF.HOME, "pdd")
|
self.addSubInterface(self.pddTaskPage, FIF.HOME, "pdd")
|
||||||
self.addSubInterface(
|
self.addSubInterface(
|
||||||
self.settingsPage,
|
self.settingsPage,
|
||||||
|
|||||||
@@ -72,6 +72,39 @@ USB-002 unauthorized usb:1-2 transport_id:3
|
|||||||
self.assertEqual(len(commands), 2)
|
self.assertEqual(len(commands), 2)
|
||||||
self.assertIn("ro.build.version.release", commands[1])
|
self.assertIn("ro.build.version.release", commands[1])
|
||||||
|
|
||||||
|
def test_require_connected_accepts_exact_usb_and_wifi_serial(self):
|
||||||
|
output = (
|
||||||
|
"List of devices attached\n"
|
||||||
|
"USB-001 device model:Phone\n"
|
||||||
|
"192.168.0.173:5555 device model:Phone\n"
|
||||||
|
)
|
||||||
|
service = AndroidDeviceService(
|
||||||
|
lambda command, _timeout: completed(command, output)
|
||||||
|
)
|
||||||
|
|
||||||
|
usb = service.require_connected("USB-001")
|
||||||
|
wifi = service.require_connected("192.168.0.173:5555")
|
||||||
|
|
||||||
|
self.assertEqual(usb.serial, "USB-001")
|
||||||
|
self.assertEqual(wifi.serial, "192.168.0.173:5555")
|
||||||
|
|
||||||
|
def test_require_connected_rejects_missing_offline_and_unauthorized(self):
|
||||||
|
cases = (
|
||||||
|
("USB-MISSING", "", "USB.*未连接"),
|
||||||
|
("192.168.0.173:5555", "offline", "offline"),
|
||||||
|
("USB-002", "unauthorized", "未授权"),
|
||||||
|
)
|
||||||
|
for serial, status, expected in cases:
|
||||||
|
with self.subTest(serial=serial, status=status):
|
||||||
|
line = f"{serial} {status}\n" if status else ""
|
||||||
|
service = AndroidDeviceService(
|
||||||
|
lambda command, _timeout, value=line: completed(
|
||||||
|
command, "List of devices attached\n" + value
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaisesRegex(AndroidDeviceSearchError, expected):
|
||||||
|
service.require_connected(serial)
|
||||||
|
|
||||||
def test_restore_saved_wifi_connects_before_search(self):
|
def test_restore_saved_wifi_connects_before_search(self):
|
||||||
commands = []
|
commands = []
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from src.android_device_service import AndroidDeviceSearchError
|
||||||
from src.pdd_ui import PDDTaskPage
|
from src.pdd_ui import PDDTaskPage
|
||||||
from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt
|
from src.admin_gateway import AdminGatewayError, AdminTask, SubmissionReceipt
|
||||||
from src.pdd_ui_event import (
|
from src.pdd_ui_event import (
|
||||||
@@ -238,8 +239,14 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
|||||||
self.temp_directory = tempfile.TemporaryDirectory()
|
self.temp_directory = tempfile.TemporaryDirectory()
|
||||||
self.db_path = Path(self.temp_directory.name) / "client.db"
|
self.db_path = Path(self.temp_directory.name) / "client.db"
|
||||||
self.repository = TaskRepository(self.db_path)
|
self.repository = TaskRepository(self.db_path)
|
||||||
|
self.device_checker_patch = patch(
|
||||||
|
"src.pdd_ui_event.AndroidDeviceService.require_connected",
|
||||||
|
autospec=True,
|
||||||
|
)
|
||||||
|
self.device_checker_patch.start()
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
|
self.device_checker_patch.stop()
|
||||||
self.temp_directory.cleanup()
|
self.temp_directory.cleanup()
|
||||||
|
|
||||||
def _add_task(
|
def _add_task(
|
||||||
@@ -481,6 +488,97 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
|||||||
events.shutdown()
|
events.shutdown()
|
||||||
page.deleteLater()
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_disconnected_device_stops_auto_fetch_before_claim(self):
|
||||||
|
page = PDDTaskPage()
|
||||||
|
gateway = RecordingClaimGateway(collect_admin_task())
|
||||||
|
checker_threads = []
|
||||||
|
|
||||||
|
def disconnected(_serial):
|
||||||
|
checker_threads.append(threading.get_ident())
|
||||||
|
raise AndroidDeviceSearchError("USB Android 设备 USB-001 未连接")
|
||||||
|
|
||||||
|
events = PDDTaskPageEvent(
|
||||||
|
page,
|
||||||
|
self.repository,
|
||||||
|
claim_gateway=gateway,
|
||||||
|
settings_repository=self._saved_settings(),
|
||||||
|
collect_service_factory=fake_collect_factory,
|
||||||
|
device_connection_checker=disconnected,
|
||||||
|
)
|
||||||
|
page.autoFetchRequested.emit()
|
||||||
|
|
||||||
|
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
|
||||||
|
self.assertEqual(gateway.calls, [])
|
||||||
|
self.assertEqual(self.repository.count_tasks(), 0)
|
||||||
|
self.assertEqual(len(checker_threads), 1)
|
||||||
|
self.assertNotEqual(checker_threads[0], threading.get_ident())
|
||||||
|
self.assertIn("Android 设备不可用", page.statusLabel.text())
|
||||||
|
self.assertIsNotNone(events._device_feedback)
|
||||||
|
events.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_disconnected_rerun_keeps_existing_task_unchanged(self):
|
||||||
|
self._add_task(1)
|
||||||
|
first = self.repository.start_collect_run("PDD-001", "USB-001")
|
||||||
|
event = self.repository.save_collect_result(
|
||||||
|
"PDD-001", first.attempt_id, FakeCollectResult().to_pdd_data()
|
||||||
|
)
|
||||||
|
self.repository.mark_outbox_sent(event.id)
|
||||||
|
before = self.repository.get_task("PDD-001")
|
||||||
|
before_run = self.repository.latest_task_run("PDD-001")
|
||||||
|
page = PDDTaskPage()
|
||||||
|
def disconnected(_serial):
|
||||||
|
raise AndroidDeviceSearchError(
|
||||||
|
"USB Android 设备 USB-001 未连接"
|
||||||
|
)
|
||||||
|
|
||||||
|
events = PDDTaskPageEvent(
|
||||||
|
page,
|
||||||
|
self.repository,
|
||||||
|
claim_gateway=RecordingClaimGateway(),
|
||||||
|
settings_repository=self._saved_settings(),
|
||||||
|
collect_service_factory=fake_collect_factory,
|
||||||
|
device_connection_checker=disconnected,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("src.pdd_ui_event.MessageBox") as message_box:
|
||||||
|
message_box.return_value.exec.return_value = True
|
||||||
|
page.rerunRequested.emit("PDD-001")
|
||||||
|
self.assertTrue(wait_until(self.app, lambda: not events._claim_busy))
|
||||||
|
|
||||||
|
after = self.repository.get_task("PDD-001")
|
||||||
|
after_run = self.repository.latest_task_run("PDD-001")
|
||||||
|
self.assertEqual(after.status, TaskStatus.SUCCEEDED)
|
||||||
|
self.assertEqual(after.pdd_data, before.pdd_data)
|
||||||
|
self.assertEqual(after_run.attempt_id, before_run.attempt_id)
|
||||||
|
self.assertIn("重新采集未开始", page.statusLabel.text())
|
||||||
|
events.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
|
def test_device_feedback_opens_settings_closes_and_does_not_stack(self):
|
||||||
|
page = PDDTaskPage()
|
||||||
|
events = PDDTaskPageEvent(page, self.repository)
|
||||||
|
opened = []
|
||||||
|
page.openSettingsRequested.connect(lambda: opened.append(True))
|
||||||
|
|
||||||
|
events._show_device_unavailable("第一条")
|
||||||
|
first = events._device_feedback
|
||||||
|
events._show_device_unavailable("第二条")
|
||||||
|
second = events._device_feedback
|
||||||
|
self.assertIsNot(first, second)
|
||||||
|
buttons = {
|
||||||
|
button.text(): button
|
||||||
|
for button in second.findChildren(type(page.rerunButton))
|
||||||
|
}
|
||||||
|
self.assertIn("打开设置", buttons)
|
||||||
|
self.assertIn("关闭提示", buttons)
|
||||||
|
buttons["打开设置"].click()
|
||||||
|
self.app.processEvents()
|
||||||
|
self.assertEqual(opened, [True])
|
||||||
|
self.assertIsNone(events._device_feedback)
|
||||||
|
events.shutdown()
|
||||||
|
page.deleteLater()
|
||||||
|
|
||||||
def test_query_failure_shows_readable_error(self):
|
def test_query_failure_shows_readable_error(self):
|
||||||
page = PDDTaskPage()
|
page = PDDTaskPage()
|
||||||
events = PDDTaskPageEvent(page, BrokenRepository())
|
events = PDDTaskPageEvent(page, BrokenRepository())
|
||||||
@@ -587,6 +685,9 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
|||||||
create_u2_purchase_adapter,
|
create_u2_purchase_adapter,
|
||||||
)
|
)
|
||||||
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
|
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
|
||||||
|
window.pddTaskPage.openSettingsRequested.emit()
|
||||||
|
self.app.processEvents()
|
||||||
|
self.assertIs(window.stackedWidget.currentWidget(), window.settingsPage)
|
||||||
window.close()
|
window.close()
|
||||||
window.deleteLater()
|
window.deleteLater()
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
purchase_reconcile_factory=(
|
purchase_reconcile_factory=(
|
||||||
lambda _address, _cancelled: ReadOnlyReconcileAdapter(calls)
|
lambda _address, _cancelled: ReadOnlyReconcileAdapter(calls)
|
||||||
),
|
),
|
||||||
|
device_connection_checker=lambda _serial: None,
|
||||||
)
|
)
|
||||||
|
|
||||||
first = dispatcher.execute_one()
|
first = dispatcher.execute_one()
|
||||||
@@ -267,6 +268,7 @@ class PurchaseRecoveryTest(unittest.TestCase):
|
|||||||
lambda _address, _cancelled: purchase_calls.append("purchase")
|
lambda _address, _cancelled: purchase_calls.append("purchase")
|
||||||
),
|
),
|
||||||
purchase_reconcile_factory=unavailable_reconcile,
|
purchase_reconcile_factory=unavailable_reconcile,
|
||||||
|
device_connection_checker=lambda _serial: None,
|
||||||
).execute_one()
|
).execute_one()
|
||||||
|
|
||||||
self.assertEqual(outcome.kind, "manual_review")
|
self.assertEqual(outcome.kind, "manual_review")
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import tempfile
|
|||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.admin_gateway import AdminTask, ClientInfo
|
from src.android_device_service import AndroidDeviceSearchError
|
||||||
|
from src.admin_gateway import AdminTask, ClientInfo, SubmissionReceipt
|
||||||
from src.mock_admin_gateway import MockAdminGateway
|
from src.mock_admin_gateway import MockAdminGateway
|
||||||
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
|
from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState
|
||||||
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
from src.task_dispatcher import TaskDispatcher, admin_task_to_new_claimed_task
|
||||||
@@ -102,7 +103,7 @@ class TaskDispatcherTest(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.temporary.cleanup()
|
self.temporary.cleanup()
|
||||||
|
|
||||||
def _dispatcher(self, *, purchase_ready):
|
def _dispatcher(self, *, purchase_ready, device_checker=lambda _serial: None):
|
||||||
purchase_factory = None
|
purchase_factory = None
|
||||||
if purchase_ready:
|
if purchase_ready:
|
||||||
purchase_factory = (
|
purchase_factory = (
|
||||||
@@ -117,6 +118,7 @@ class TaskDispatcherTest(unittest.TestCase):
|
|||||||
lambda *_args: RecordingCollector(self.calls)
|
lambda *_args: RecordingCollector(self.calls)
|
||||||
),
|
),
|
||||||
purchase_adapter_factory=purchase_factory,
|
purchase_adapter_factory=purchase_factory,
|
||||||
|
device_connection_checker=device_checker,
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_capability_only_includes_purchase_when_adapter_is_ready(self):
|
def test_capability_only_includes_purchase_when_adapter_is_ready(self):
|
||||||
@@ -173,6 +175,56 @@ class TaskDispatcherTest(unittest.TestCase):
|
|||||||
assert detail is not None
|
assert detail is not None
|
||||||
self.assertEqual(detail.status, TaskStatus.CLAIMED)
|
self.assertEqual(detail.status, TaskStatus.CLAIMED)
|
||||||
|
|
||||||
|
def test_disconnected_device_does_not_claim_new_task(self):
|
||||||
|
self.gateway.enqueue_task(purchase_task(), self.client.client_id)
|
||||||
|
|
||||||
|
def disconnected(_serial):
|
||||||
|
raise AndroidDeviceSearchError("USB Android 设备 USB-001 未连接")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(AndroidDeviceSearchError, "未连接"):
|
||||||
|
self._dispatcher(
|
||||||
|
purchase_ready=True, device_checker=disconnected
|
||||||
|
).execute_one()
|
||||||
|
|
||||||
|
self.assertEqual(self.repository.count_tasks(), 0)
|
||||||
|
|
||||||
|
def test_pending_outbox_submits_without_connected_device(self):
|
||||||
|
task = admin_task_to_new_claimed_task(
|
||||||
|
AdminTask(
|
||||||
|
"COL-OUTBOX",
|
||||||
|
TaskType.COLLECT,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
{"goods_url": "https://example.test", "goods_id": "G"},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.repository.add_claimed_task(task)
|
||||||
|
started = self.repository.start_collect_run("COL-OUTBOX", "USB-001")
|
||||||
|
self.repository.save_collect_result(
|
||||||
|
"COL-OUTBOX", started.attempt_id, FakeCollectResult().to_pdd_data()
|
||||||
|
)
|
||||||
|
checks = []
|
||||||
|
|
||||||
|
class AcceptGateway:
|
||||||
|
def submit_result(self, *_args):
|
||||||
|
return SubmissionReceipt(
|
||||||
|
True, "RESULT-001", "2026-08-10T08:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
outcome = TaskDispatcher(
|
||||||
|
AcceptGateway(),
|
||||||
|
self.repository,
|
||||||
|
self.client,
|
||||||
|
"USB-001",
|
||||||
|
purchase_adapter_factory=(
|
||||||
|
lambda _address, _cancelled: ReadyPurchaseAdapter(self.calls)
|
||||||
|
),
|
||||||
|
device_connection_checker=lambda serial: checks.append(serial),
|
||||||
|
).execute_one()
|
||||||
|
|
||||||
|
self.assertEqual(outcome.kind, "succeeded")
|
||||||
|
self.assertEqual(checks, [])
|
||||||
|
|
||||||
def test_purchase_mapper_rejects_missing_or_invalid_safety_fields(self):
|
def test_purchase_mapper_rejects_missing_or_invalid_safety_fields(self):
|
||||||
invalid_payloads = (
|
invalid_payloads = (
|
||||||
{"goods_url": "https://example.test"},
|
{"goods_url": "https://example.test"},
|
||||||
|
|||||||
@@ -322,13 +322,15 @@ Client 与 Admin 的任务交互只有三种调用:领一个任务、提交结
|
|||||||
|
|
||||||
### 任务领取与执行
|
### 任务领取与执行
|
||||||
|
|
||||||
1. 调用 Admin `claim` 领一个任务;返回 204 表示暂时没活,按轮询周期退避后再试。
|
1. 优先补交一条本地 Outbox。提交结果不依赖 Android 设备。
|
||||||
2. Client 持久化任务,状态置 `claimed`。
|
2. 没有待提交结果时,在工作线程通过 ADB 检查已保存设备号是否仍为 `device` 状态。检查失败立即停止,不执行本地任务,也不领取新任务。
|
||||||
3. 根据 `task_type` 分派给采集或采购执行器。
|
3. 执行最早的本地待处理任务;没有本地任务时才调用 Admin `claim`。返回 204 表示暂时没活,按轮询周期退避后再试。
|
||||||
4. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。
|
4. Client 持久化新领取的任务,状态置 `claimed`。
|
||||||
5. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。
|
5. 根据 `task_type` 分派给采集或采购执行器。
|
||||||
6. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。
|
6. 工作线程执行,持续更新 `current_step`(只写本地,不上报 Admin)。
|
||||||
7. 回到第 1 步领下一个任务。**同一时间只做一个任务。**
|
7. 完成后在同一事务里写入结果与 Outbox,状态置 `result_pending`。
|
||||||
|
8. Outbox 提交成功、Admin 返回 `accepted: true` 后标记 `succeeded`。
|
||||||
|
9. 回到第 1 步处理下一轮。**同一时间只做一个任务。**
|
||||||
|
|
||||||
`TaskDispatcher` 是能力声明的唯一入口。没有可用采购演练 Adapter、没有已保存
|
`TaskDispatcher` 是能力声明的唯一入口。没有可用采购演练 Adapter、没有已保存
|
||||||
Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明
|
Android 设备或本地持久化未准备好时,只声明 `collect`;条件满足时才声明
|
||||||
|
|||||||
@@ -67,6 +67,11 @@
|
|||||||
或配置错误会停止自动获取。采购演练执行器和 Android 设备都准备好时,可以领取
|
或配置错误会停止自动获取。采购演练执行器和 Android 设备都准备好时,可以领取
|
||||||
采购任务;界面必须明确显示“演练”,任何路径都不得显示成真实下单。
|
采购任务;界面必须明确显示“演练”,任何路径都不得显示成真实下单。
|
||||||
|
|
||||||
|
- 补交本地 Outbox 不依赖手机,可以在 Android 设备断开时继续执行。
|
||||||
|
- 执行本地任务或向 Admin 领取新任务前,工作线程必须用 `adb devices -l` 检查已保存的精确设备号。USB 和 Wi-Fi 设备都只接受 `device` 状态。
|
||||||
|
- 设备未连接、`offline`、`unauthorized` 或 ADB 检查失败时,立即停止本轮和自动获取;不得领取新任务,也不得启动本地任务。
|
||||||
|
- 设备错误使用一条持久 `InfoBar` 提示,提供“打开设置”和“关闭提示”。重复错误替换旧提示,不得堆叠。
|
||||||
|
|
||||||
### 4.2 搜索与筛选
|
### 4.2 搜索与筛选
|
||||||
|
|
||||||
建议控件:
|
建议控件:
|
||||||
@@ -83,6 +88,7 @@
|
|||||||
- “刷新”只重新读取本地任务列表,不请求 Admin,也不操作手机。
|
- “刷新”只重新读取本地任务列表,不请求 Admin,也不操作手机。
|
||||||
- “重新执行”位于刷新右侧;没有选中任务时禁用。
|
- “重新执行”位于刷新右侧;没有选中任务时禁用。
|
||||||
- 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。
|
- 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。
|
||||||
|
- 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。
|
||||||
- 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。
|
- 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。
|
||||||
- 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。
|
- 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。
|
||||||
- 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。
|
- 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。
|
||||||
|
|||||||
Reference in New Issue
Block a user