diff --git a/client/src/collect_task_service.py b/client/src/collect_task_service.py index 3aecd35..b63bcf6 100644 --- a/client/src/collect_task_service.py +++ b/client/src/collect_task_service.py @@ -126,12 +126,20 @@ class CollectTaskService: def _execute_task(self, remote_task_id: str) -> CollectTaskOutcome: """执行一条已经保存在本地且处于待执行状态的采集任务。""" - if self._cancelled(): - return CollectTaskOutcome("cancelled", "本次采集已取消", remote_task_id) - started = self._repository.start_collect_run( remote_task_id, self._device_address ) + if self._cancelled(): + event = self._repository.save_collect_failure( + remote_task_id, + started.attempt_id, + TaskStatus.CANCELLED, + "PDD_CANCELLED", + "采集任务已安全取消", + False, + {}, + ) + return self._submit_cancelled(event, remote_task_id) collector = self._factory( self._device_address, self._client.client_id, self._cancelled ) @@ -152,8 +160,22 @@ class CollectTaskService: retryable, exc.diagnostics, ) + if exc.code == "PDD_CANCELLED": + return self._submit_cancelled(event, remote_task_id) return self._submit(event) + def _submit_cancelled( + self, event: OutboxEventRecord, remote_task_id: str + ) -> CollectTaskOutcome: + """提交取消结果;Admin 接收后对界面返回明确的已取消。""" + + outcome = self._submit(event) + if outcome.kind == "failed": + return CollectTaskOutcome( + "cancelled", f"任务 {remote_task_id} 已安全取消", remote_task_id + ) + return outcome + def _submit(self, event: OutboxEventRecord) -> CollectTaskOutcome: task_id = self._repository.outbox_task_id(event.id) self._repository.mark_outbox_sending(event.id) diff --git a/client/src/pdd_ui.py b/client/src/pdd_ui.py index 2b17c4e..97c8d38 100644 --- a/client/src/pdd_ui.py +++ b/client/src/pdd_ui.py @@ -277,12 +277,14 @@ class PDDTaskPage(QWidget): searchRequested = pyqtSignal(dict) refreshRequested = pyqtSignal() rerunRequested = pyqtSignal(str) + rerunCancelRequested = pyqtSignal() detailRequested = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.setObjectName("pddTaskPage") + self._rerun_state = "idle" self.taskModel = TaskTableModel(self) self._build_command_area() self._build_content_area() @@ -508,22 +510,47 @@ class PDDTaskPage(QWidget): return task.remote_task_id if task else "" def set_rerun_running(self, running: bool) -> None: - """重新采集期间锁定两个会操作手机的入口。""" + """设置重新采集状态;运行时保留可见的停止入口。""" - self.rerunButton.setText("正在重新采集" if running else "重新执行") - self.autoFetchButton.setEnabled(not running) + self._rerun_state = "running" if running else "idle" + self._update_rerun_button() + + def set_rerun_cancelling(self) -> None: + """显示取消请求已发出,防止用户重复点击。""" + + self._rerun_state = "cancelling" self._update_rerun_button() def _request_rerun(self) -> None: + if self._rerun_state == "running": + self.rerunCancelRequested.emit() + return + if self._rerun_state != "idle": + return task_id = self.current_task_id() if task_id: self.rerunRequested.emit(task_id) def _update_rerun_button(self) -> None: - running = self.rerunButton.text() == "正在重新采集" + if self._rerun_state == "running": + self.rerunButton.setText("停止重新采集") + self.rerunButton.setAccessibleName("停止当前重新采集") + self.rerunButton.setEnabled(True) + self.autoFetchButton.setEnabled(False) + return + if self._rerun_state == "cancelling": + self.rerunButton.setText("正在停止…") + self.rerunButton.setAccessibleName("正在停止重新采集") + self.rerunButton.setEnabled(False) + self.autoFetchButton.setEnabled(False) + return + + self.rerunButton.setText("重新执行") + self.rerunButton.setAccessibleName("重新执行当前选中的采集任务") + self.autoFetchButton.setEnabled(True) has_selection = self.taskTable.selectionModel().hasSelection() self.rerunButton.setEnabled( - has_selection and bool(self.current_task_id()) and not running + has_selection and bool(self.current_task_id()) ) def _apply_filters(self) -> None: diff --git a/client/src/pdd_ui_event.py b/client/src/pdd_ui_event.py index fbe9023..c8e53ce 100644 --- a/client/src/pdd_ui_event.py +++ b/client/src/pdd_ui_event.py @@ -30,7 +30,7 @@ from PyQt5.QtCore import ( pyqtSignal, pyqtSlot, ) -from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox +from qfluentwidgets import InfoBar, InfoBarPosition, MessageBox, PushButton from .admin_gateway import ( AdminGatewayError, @@ -134,7 +134,7 @@ class ClaimTaskWorker(QObject): @pyqtSlot() def run(self) -> None: try: - if self._cancelled: + if self._cancelled and not self._selected_task_id: return client_settings = self._client_service.load() if not client_settings.client_id: @@ -212,6 +212,8 @@ class PDDTaskPageEvent(QObject): self._claim_busy = False self._claim_thread: Optional[QThread] = None self._claim_worker: Optional[ClaimTaskWorker] = None + self._rerun_cancel_requested = False + self._rerun_feedback: Optional[InfoBar] = None self._auto_fetch_running = False self._stop_requested = False self._cycle_next_delay_ms: Optional[int] = None @@ -261,6 +263,7 @@ class PDDTaskPageEvent(QObject): page.searchRequested.connect(self.search_tasks) page.refreshRequested.connect(self.refresh_tasks) page.rerunRequested.connect(self.request_rerun) + page.rerunCancelRequested.connect(self.request_cancel_rerun) page.autoFetchRequested.connect(self._request_claim_task) page.detailRequested.connect(self.show_task_detail) page.taskModel.loadMoreRequested.connect(self._load_page) @@ -345,7 +348,7 @@ class PDDTaskPageEvent(QObject): self._page.window(), ) dialog.yesButton.setText("重新采集") - dialog.cancelButton.setText("取消") + dialog.cancelButton.setText("暂不重新采集") dialog.cancelButton.setFocus() if not dialog.exec(): return @@ -367,6 +370,8 @@ class PDDTaskPageEvent(QObject): """启动只处理指定任务的工作线程。""" assert self._claim_gateway is not None + self._close_rerun_feedback() + self._rerun_cancel_requested = False self._claim_busy = True self._page.set_rerun_running(True) self._page.set_engine_status(f"正在重新采集任务 {task_id}…") @@ -394,32 +399,67 @@ class PDDTaskPageEvent(QObject): self._claim_worker = worker thread.start() + @pyqtSlot() + def request_cancel_rerun(self) -> None: + """请求当前重新采集在下一个安全点停止。""" + + worker = self._claim_worker + if ( + self._closing + or self._rerun_cancel_requested + or not self._claim_busy + or worker is None + ): + return + self._rerun_cancel_requested = True + self._page.set_rerun_cancelling() + self._page.set_engine_status( + "正在停止重新采集…正在等待手机当前操作结束" + ) + # uiautomator2/ADB 的单次调用无法安全强制中断。 + # Worker 会在调用返回后通过 cancelled 回调在安全点停止。 + worker.cancel() + @pyqtSlot(str, str, str) def _on_rerun_outcome(self, kind: str, message: str, _task_id: str) -> None: if self._closing: return self._reload() + if self._rerun_cancel_requested: + self._page.set_engine_status( + "停止请求已处理,请查看任务最新状态" + ) + return self._page.set_engine_status(message) if kind != "succeeded": - self._show_claim_error("重新采集需要处理", message) + self._show_rerun_error("重新采集需要处理", message) @pyqtSlot(str) def _on_rerun_failed(self, message: str) -> None: if self._closing: return + if self._rerun_cancel_requested: + return self._page.set_engine_status(message) - self._show_claim_error("重新采集失败", message) + self._show_rerun_error("重新采集失败", message) @pyqtSlot() def _on_rerun_thread_finished(self) -> None: + cancel_requested = self._rerun_cancel_requested self._claim_worker = None self._claim_thread = None self._claim_busy = False + self._rerun_cancel_requested = False if not self._closing: self._page.set_rerun_running(False) + if cancel_requested: + self._reload() + self._page.set_engine_status( + "停止请求已处理,请查看任务最新状态" + ) def _show_rerun_warning(self, title: str, content: str) -> None: - InfoBar.warning( + bar = InfoBar.warning( title=title, content=content, isClosable=True, @@ -427,6 +467,51 @@ class PDDTaskPageEvent(QObject): position=InfoBarPosition.TOP_RIGHT, parent=self._page, ) + self._replace_rerun_feedback(bar) + + def _show_rerun_error(self, title: str, content: str) -> None: + """显示一条可明确关闭的重新采集错误。""" + + bar = InfoBar.error( + title=title, + content=content, + isClosable=True, + duration=-1, + position=InfoBarPosition.TOP_RIGHT, + parent=self._page, + ) + self._replace_rerun_feedback(bar) + + def _replace_rerun_feedback(self, bar: InfoBar) -> None: + """用新提示替换旧提示,避免连续失败后堆叠。""" + + self._close_rerun_feedback() + self._rerun_feedback = bar + close_button = PushButton("关闭提示", bar) + close_button.setAccessibleName("关闭重新采集提示") + close_button.clicked.connect( + lambda _checked=False, current=bar: self._close_rerun_feedback(current) + ) + bar.addWidget(close_button) + bar.destroyed.connect( + lambda _object=None, current=bar: self._forget_rerun_feedback(current) + ) + + def _close_rerun_feedback(self, expected: Optional[InfoBar] = None) -> None: + """关闭当前重新采集提示;旧提示不得关闭新提示。""" + + bar = self._rerun_feedback + if bar is None or (expected is not None and bar is not expected): + return + self._rerun_feedback = None + try: + bar.close() + except RuntimeError: + pass + + def _forget_rerun_feedback(self, bar: InfoBar) -> None: + if self._rerun_feedback is bar: + self._rerun_feedback = None @pyqtSlot() def _request_claim_task(self) -> None: @@ -763,6 +848,7 @@ class PDDTaskPageEvent(QObject): self._auto_fetch_running = False self._stop_requested = True self._next_cycle_timer.stop() + self._close_rerun_feedback() for window in list(self._detail_windows.values()): window.close() diff --git a/client/test/test_collect_task_service.py b/client/test/test_collect_task_service.py index dd1755e..b74247a 100644 --- a/client/test/test_collect_task_service.py +++ b/client/test/test_collect_task_service.py @@ -194,6 +194,40 @@ class CollectTaskServiceTest(unittest.TestCase): self.repository.get_task("COL-OTHER").status, TaskStatus.CLAIMED ) + def test_selected_task_cancelled_before_collector_starts_is_persisted(self): + self.repository.add_claimed_task( + NewClaimedTask( + remote_task_id="COL-CANCEL", + task_type=TaskType.COLLECT, + goods_url="https://example.test/COL-CANCEL", + ) + ) + calls = [] + + class AcceptGateway: + def submit_failure(self, *_args): + return SubmissionReceipt( + True, "FAILURE-001", "2026-08-07T08:00:00Z" + ) + + service = CollectTaskService( + AcceptGateway(), + self.repository, + self.client, + "USB-001", + cancelled=lambda: True, + collect_service_factory=lambda *_args: FakeCollector(calls), + ) + + outcome = service.execute_selected("COL-CANCEL") + + self.assertEqual(outcome.kind, "cancelled") + self.assertEqual(calls, []) + self.assertEqual( + self.repository.get_task("COL-CANCEL").status, + TaskStatus.CANCELLED, + ) + if __name__ == "__main__": unittest.main() diff --git a/client/test/test_pdd_ui_event.py b/client/test/test_pdd_ui_event.py index 51ec3c5..573d925 100644 --- a/client/test/test_pdd_ui_event.py +++ b/client/test/test_pdd_ui_event.py @@ -21,6 +21,7 @@ from src.pdd_ui_event import ( ) from src.mock_admin_gateway import MockAdminGateway from src.pdd_purchase_adapter import PddPurchaseAdapter, PurchasePageState +from src.pdd_collect_service import PddCollectError from src.pdd_u2_purchase_adapter import create_u2_purchase_adapter from src.settings_repository import SettingsRepository from src.task_models import NewClaimedTask, TaskStatus, TaskSummary, TaskType @@ -361,6 +362,125 @@ class PDDTaskPageEventTest(unittest.TestCase): events.shutdown() page.deleteLater() + def test_rerun_confirmation_has_clear_safe_cancel_action(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) + page = PDDTaskPage() + events = PDDTaskPageEvent( + page, + self.repository, + claim_gateway=RecordingClaimGateway(), + settings_repository=self._saved_settings(), + collect_service_factory=fake_collect_factory, + ) + + with patch("src.pdd_ui_event.MessageBox") as message_box: + message_box.return_value.exec.return_value = False + page.rerunRequested.emit("PDD-001") + + message_box.return_value.cancelButton.setText.assert_called_once_with( + "暂不重新采集" + ) + message_box.return_value.cancelButton.setFocus.assert_called_once_with() + self.assertFalse(events._claim_busy) + self.assertEqual( + self.repository.get_task("PDD-001").status, TaskStatus.SUCCEEDED + ) + events.shutdown() + page.deleteLater() + + def test_running_rerun_can_request_cooperative_cancel_once(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) + started = threading.Event() + + def cancellable_factory(_address, _client_id, cancelled): + class CancellableCollector: + def collect(self, _task): + started.set() + while not cancelled(): + time.sleep(0.01) + raise PddCollectError("PDD_CANCELLED", "采集任务已安全取消") + + return CancellableCollector() + + page = PDDTaskPage() + events = PDDTaskPageEvent( + page, + self.repository, + claim_gateway=RecordingClaimGateway(), + settings_repository=self._saved_settings(), + collect_service_factory=cancellable_factory, + ) + 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(started.wait(1.0), "重新采集没有启动") + self.assertTrue( + wait_until( + self.app, + lambda: page.rerunButton.text() == "停止重新采集", + ) + ) + self.assertTrue(page.rerunButton.isEnabled()) + page.rerunButton.click() + self.app.processEvents() + self.assertEqual(page.rerunButton.text(), "正在停止…") + self.assertFalse(page.rerunButton.isEnabled()) + self.assertIn("等待手机当前操作结束", page.statusLabel.text()) + self.assertTrue( + wait_until(self.app, lambda: not events._claim_busy), + "取消后工作线程没有按时结束", + ) + + self.assertEqual(page.rerunButton.text(), "重新执行") + self.assertIn("停止请求已处理", page.statusLabel.text()) + events.shutdown() + page.deleteLater() + + def test_rerun_feedback_has_close_button_and_replaces_previous(self): + page = PDDTaskPage() + events = PDDTaskPageEvent(page, self.repository) + + events._show_rerun_warning("暂时不能重新执行", "测试警告") + first = events._rerun_feedback + self.assertIsNotNone(first) + events._show_rerun_error("重新采集失败", "测试错误") + second = events._rerun_feedback + self.assertIsNot(first, second) + + close_buttons = [ + button + for button in second.findChildren(type(page.rerunButton)) + if button.text() == "关闭提示" + ] + self.assertEqual(len(close_buttons), 1) + close_buttons[0].click() + self.app.processEvents() + self.assertIsNone(events._rerun_feedback) + events.shutdown() + page.deleteLater() + + def test_late_rerun_success_does_not_override_cancel_status(self): + page = PDDTaskPage() + events = PDDTaskPageEvent(page, self.repository) + events._rerun_cancel_requested = True + + events._on_rerun_outcome("succeeded", "采集成功", "PDD-001") + + self.assertNotIn("采集成功", page.statusLabel.text()) + self.assertIn("停止请求已处理", page.statusLabel.text()) + events.shutdown() + page.deleteLater() + def test_query_failure_shows_readable_error(self): page = PDDTaskPage() events = PDDTaskPageEvent(page, BrokenRepository()) diff --git a/docs/client/05-ui-specification.md b/docs/client/05-ui-specification.md index db205c3..2b4d5bc 100644 --- a/docs/client/05-ui-specification.md +++ b/docs/client/05-ui-specification.md @@ -82,10 +82,12 @@ - “刷新”只重新读取本地任务列表,不请求 Admin,也不操作手机。 - “重新执行”位于刷新右侧;没有选中任务时禁用。 -- 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。 +- 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。 - 允许重新采集已经结束或处于“等待重试”的采集任务。采购、执行中、结果待提交、仍有未发送 Outbox 或自动获取忙碌时必须阻止,并用中文说明原因。 - 确认后只执行选中的稳定任务编号,不领取新任务,不先处理其他任务或 Outbox。 -- 重新采集在工作线程运行。执行期间禁用“获取任务”和“重新执行”,完成后刷新列表。 +- 重新采集在工作线程运行。执行期间禁用“获取任务”,“重新执行”变为可点击的“停止重新采集”。 +- 点击停止后按钮显示“正在停止…”并禁用重复点击;底部明确说明正在等待手机当前操作结束。uiautomator2/ADB 的单次调用返回后,采集在下一个安全检查点停止;不得强制结束工作线程。 +- 重新采集的警告和错误 `InfoBar` 必须有可见的“关闭提示”按钮;同一时间只保留一条,新提示替换旧提示。 - 每次重新采集创建新的执行记录和幂等键;当前结果更新,旧结果保存在历史执行记录中。 - 采购任务始终不能通过“重新执行”入口启动;需要处理时由自动获取的安全恢复流程决定。 - “等待重试”当前没有倒计时。自动获取因可恢复采集错误停止时,底部状态显示