feat: 安全移除本地任务记录 (#108)
This commit is contained in:
+13
-1
@@ -4,7 +4,7 @@
|
||||
``MIGRATIONS`` 末尾增加版本,不能修改已经发布的迁移。
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = 3
|
||||
SCHEMA_VERSION = 4
|
||||
|
||||
|
||||
MIGRATION_1 = (
|
||||
@@ -142,8 +142,20 @@ MIGRATION_3 = (
|
||||
)
|
||||
|
||||
|
||||
MIGRATION_4 = (
|
||||
"""
|
||||
ALTER TABLE pdd_tasks ADD COLUMN removed_at TEXT
|
||||
""",
|
||||
"""
|
||||
CREATE INDEX idx_pdd_tasks_visible_list
|
||||
ON pdd_tasks(removed_at, updated_at DESC, id DESC)
|
||||
""",
|
||||
)
|
||||
|
||||
|
||||
MIGRATIONS = {
|
||||
1: MIGRATION_1,
|
||||
2: MIGRATION_2,
|
||||
3: MIGRATION_3,
|
||||
4: MIGRATION_4,
|
||||
}
|
||||
|
||||
+39
-9
@@ -368,6 +368,7 @@ class PDDTaskPage(QWidget):
|
||||
rerunRequested = pyqtSignal(str)
|
||||
rerunCancelRequested = pyqtSignal()
|
||||
resubmitRequested = pyqtSignal(object)
|
||||
removeRequested = pyqtSignal(object)
|
||||
openSettingsRequested = pyqtSignal()
|
||||
detailRequested = pyqtSignal(str)
|
||||
|
||||
@@ -377,6 +378,7 @@ class PDDTaskPage(QWidget):
|
||||
|
||||
self._rerun_state = "idle"
|
||||
self._resubmit_running = False
|
||||
self._remove_running = False
|
||||
self.taskModel = TaskTableModel(self)
|
||||
self._build_command_area()
|
||||
self._build_content_area()
|
||||
@@ -429,6 +431,10 @@ class PDDTaskPage(QWidget):
|
||||
self.resubmitButton.setAccessibleName("重新上报勾选任务的已有结果")
|
||||
self.resubmitButton.setEnabled(False)
|
||||
|
||||
self.removeButton = PushButton(FIF.DELETE, "删除", self)
|
||||
self.removeButton.setAccessibleName("从任务列表删除勾选的本地任务")
|
||||
self.removeButton.setEnabled(False)
|
||||
|
||||
self.commandCard = CardWidget(self)
|
||||
self.checkedCountLabel = CaptionLabel("已选 0 条", self.commandCard)
|
||||
self.checkedCountLabel.setAccessibleName("已勾选任务数量")
|
||||
@@ -446,7 +452,7 @@ class PDDTaskPage(QWidget):
|
||||
if compact == self._compact_commands and self.commandLayout.count():
|
||||
return
|
||||
self._compact_commands = compact
|
||||
for column in range(13):
|
||||
for column in range(14):
|
||||
self.commandLayout.setColumnStretch(column, 0)
|
||||
|
||||
if compact:
|
||||
@@ -459,6 +465,7 @@ class PDDTaskPage(QWidget):
|
||||
(self.checkedCountLabel, 0, 6, 1, 1),
|
||||
(self.rerunButton, 0, 7, 1, 1),
|
||||
(self.resubmitButton, 0, 8, 1, 1),
|
||||
(self.removeButton, 0, 9, 1, 1),
|
||||
(self.keywordLabel, 1, 0, 1, 1),
|
||||
(self.keywordInput, 1, 1, 1, 5),
|
||||
(self.searchButton, 1, 6, 1, 1),
|
||||
@@ -479,6 +486,7 @@ class PDDTaskPage(QWidget):
|
||||
(self.checkedCountLabel, 0, 10, 1, 1),
|
||||
(self.rerunButton, 0, 11, 1, 1),
|
||||
(self.resubmitButton, 0, 12, 1, 1),
|
||||
(self.removeButton, 0, 13, 1, 1),
|
||||
)
|
||||
self.commandLayout.setColumnStretch(6, 4)
|
||||
self.commandLayout.setColumnStretch(9, 1)
|
||||
@@ -562,6 +570,7 @@ class PDDTaskPage(QWidget):
|
||||
self.refreshButton.clicked.connect(self.refreshRequested.emit)
|
||||
self.rerunButton.clicked.connect(self._request_rerun)
|
||||
self.resubmitButton.clicked.connect(self._request_resubmit)
|
||||
self.removeButton.clicked.connect(self._request_remove)
|
||||
self.clearFiltersButton.clicked.connect(self.clear_filters)
|
||||
self.taskTable.clicked.connect(self._on_table_clicked)
|
||||
self.taskTable.activated.connect(self._on_table_activated)
|
||||
@@ -634,7 +643,9 @@ class PDDTaskPage(QWidget):
|
||||
text, icon, enabled = states.get(state, states["stopped"])
|
||||
self.autoFetchButton.setText(text)
|
||||
self.autoFetchButton.setIcon(icon)
|
||||
self.autoFetchButton.setEnabled(enabled)
|
||||
self.autoFetchButton.setEnabled(
|
||||
enabled and not self._resubmit_running and not self._remove_running
|
||||
)
|
||||
self.autoFetchButton.setAccessibleName(f"{text} PDD 任务")
|
||||
|
||||
def set_engine_status(self, text: str) -> None:
|
||||
@@ -669,6 +680,12 @@ class PDDTaskPage(QWidget):
|
||||
self._resubmit_running = running
|
||||
self._update_checked_actions(self.taskModel.checked_task_ids())
|
||||
|
||||
def set_remove_running(self, running: bool) -> None:
|
||||
"""设置本地任务删除忙碌状态并防止重复操作。"""
|
||||
|
||||
self._remove_running = running
|
||||
self._update_checked_actions(self.taskModel.checked_task_ids())
|
||||
|
||||
def set_rerun_cancelling(self) -> None:
|
||||
"""显示取消请求已发出,防止用户重复点击。"""
|
||||
|
||||
@@ -686,18 +703,26 @@ class PDDTaskPage(QWidget):
|
||||
self.rerunRequested.emit(task_ids[0])
|
||||
|
||||
def _request_resubmit(self) -> None:
|
||||
if self._resubmit_running:
|
||||
if self._resubmit_running or self._remove_running:
|
||||
return
|
||||
task_ids = self.taskModel.checked_task_ids()
|
||||
if task_ids:
|
||||
self.resubmitRequested.emit(task_ids)
|
||||
|
||||
def _request_remove(self) -> None:
|
||||
if self._remove_running or self._resubmit_running:
|
||||
return
|
||||
task_ids = self.taskModel.checked_task_ids()
|
||||
if task_ids:
|
||||
self.removeRequested.emit(task_ids)
|
||||
|
||||
def _update_rerun_button(self) -> None:
|
||||
if self._rerun_state == "running":
|
||||
self.rerunButton.setText("停止重新采集")
|
||||
self.rerunButton.setAccessibleName("停止当前重新采集")
|
||||
self.rerunButton.setEnabled(True)
|
||||
self.resubmitButton.setEnabled(False)
|
||||
self.removeButton.setEnabled(False)
|
||||
self.autoFetchButton.setEnabled(False)
|
||||
return
|
||||
if self._rerun_state == "cancelling":
|
||||
@@ -705,19 +730,22 @@ class PDDTaskPage(QWidget):
|
||||
self.rerunButton.setAccessibleName("正在停止重新采集")
|
||||
self.rerunButton.setEnabled(False)
|
||||
self.resubmitButton.setEnabled(False)
|
||||
self.removeButton.setEnabled(False)
|
||||
self.autoFetchButton.setEnabled(False)
|
||||
return
|
||||
|
||||
self.rerunButton.setText("重新执行")
|
||||
self.rerunButton.setAccessibleName("重新执行当前选中的采集任务")
|
||||
self.autoFetchButton.setEnabled(not self._resubmit_running)
|
||||
idle = not self._resubmit_running and not self._remove_running
|
||||
self.autoFetchButton.setEnabled(idle)
|
||||
self.rerunButton.setEnabled(
|
||||
not self._resubmit_running
|
||||
and len(self.taskModel.checked_task_ids()) == 1
|
||||
idle and len(self.taskModel.checked_task_ids()) == 1
|
||||
)
|
||||
self.resubmitButton.setEnabled(
|
||||
not self._resubmit_running
|
||||
and bool(self.taskModel.checked_task_ids())
|
||||
idle and bool(self.taskModel.checked_task_ids())
|
||||
)
|
||||
self.removeButton.setEnabled(
|
||||
idle and bool(self.taskModel.checked_task_ids())
|
||||
)
|
||||
|
||||
def _update_checked_actions(self, task_ids=None) -> None:
|
||||
@@ -728,7 +756,9 @@ class PDDTaskPage(QWidget):
|
||||
self.resubmitButton.setText(
|
||||
"正在上报…" if self._resubmit_running else "重新上报"
|
||||
)
|
||||
self.resubmitButton.setEnabled(bool(checked) and not self._resubmit_running)
|
||||
self.removeButton.setText(
|
||||
"正在删除…" if self._remove_running else "删除"
|
||||
)
|
||||
self._update_rerun_button()
|
||||
|
||||
def _apply_filters(self) -> None:
|
||||
|
||||
+158
-5
@@ -59,7 +59,11 @@ from .task_models import (
|
||||
TaskSummary,
|
||||
TaskType,
|
||||
)
|
||||
from .task_repository import CollectRerunError, TaskRepository
|
||||
from .task_repository import (
|
||||
CollectRerunError,
|
||||
TaskRemovalError,
|
||||
TaskRepository,
|
||||
)
|
||||
from .task_dispatcher import (
|
||||
TaskDispatcher,
|
||||
admin_task_to_new_claimed_task,
|
||||
@@ -322,6 +326,36 @@ class ResultResubmitWorker(QObject):
|
||||
self.completed.emit()
|
||||
|
||||
|
||||
class TaskRemoveWorker(QObject):
|
||||
"""在后台原子校验并软移除勾选任务,不访问任何界面控件。"""
|
||||
|
||||
succeeded = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
completed = pyqtSignal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
repository: TaskRepository,
|
||||
task_ids: tuple[str, ...],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._repository = repository
|
||||
self._task_ids = task_ids
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self._repository.remove_tasks_from_list(self._task_ids)
|
||||
except TaskRemovalError as exc:
|
||||
self.failed.emit(str(exc))
|
||||
except Exception:
|
||||
self.failed.emit("删除本地任务失败,请检查数据库后重试。")
|
||||
else:
|
||||
self.succeeded.emit(self._task_ids)
|
||||
finally:
|
||||
self.completed.emit()
|
||||
|
||||
|
||||
class PDDTaskPageEvent(QObject):
|
||||
"""把 PDD 页面只读操作连接到本地任务 Repository。"""
|
||||
|
||||
@@ -359,6 +393,9 @@ class PDDTaskPageEvent(QObject):
|
||||
self._resubmit_busy = False
|
||||
self._resubmit_thread: Optional[QThread] = None
|
||||
self._resubmit_worker: Optional[ResultResubmitWorker] = None
|
||||
self._remove_busy = False
|
||||
self._remove_thread: Optional[QThread] = None
|
||||
self._remove_worker: Optional[TaskRemoveWorker] = None
|
||||
self._stop_requested = False
|
||||
self._cycle_next_delay_ms: Optional[int] = None
|
||||
self._stop_status = "自动获取:已停止 · 当前没有执行中的任务"
|
||||
@@ -417,6 +454,7 @@ class PDDTaskPageEvent(QObject):
|
||||
page.rerunRequested.connect(self.request_rerun)
|
||||
page.rerunCancelRequested.connect(self.request_cancel_rerun)
|
||||
page.resubmitRequested.connect(self.request_resubmit)
|
||||
page.removeRequested.connect(self.request_remove)
|
||||
page.autoFetchRequested.connect(self._request_claim_task)
|
||||
page.detailRequested.connect(self.show_task_detail)
|
||||
page.taskModel.loadMoreRequested.connect(self._load_page)
|
||||
@@ -452,7 +490,12 @@ class PDDTaskPageEvent(QObject):
|
||||
|
||||
if self._closing or not task_id:
|
||||
return
|
||||
if self._auto_fetch_running or self._claim_busy or self._resubmit_busy:
|
||||
if (
|
||||
self._auto_fetch_running
|
||||
or self._claim_busy
|
||||
or self._resubmit_busy
|
||||
or self._remove_busy
|
||||
):
|
||||
self._show_rerun_warning(
|
||||
"暂时不能重新执行",
|
||||
"自动获取或其他采集正在运行,请停止并等待当前任务结束。",
|
||||
@@ -517,7 +560,12 @@ class PDDTaskPageEvent(QObject):
|
||||
)
|
||||
if self._closing or not stable_ids:
|
||||
return
|
||||
if self._auto_fetch_running or self._claim_busy or self._resubmit_busy:
|
||||
if (
|
||||
self._auto_fetch_running
|
||||
or self._claim_busy
|
||||
or self._resubmit_busy
|
||||
or self._remove_busy
|
||||
):
|
||||
self._show_rerun_warning(
|
||||
"暂时不能重新上报",
|
||||
"自动获取、重新执行或另一批上报正在运行,请等待当前操作结束。",
|
||||
@@ -545,6 +593,98 @@ class PDDTaskPageEvent(QObject):
|
||||
|
||||
self._start_resubmit_worker(stable_ids)
|
||||
|
||||
@pyqtSlot(object)
|
||||
def request_remove(self, task_ids) -> None:
|
||||
"""确认后在后台从普通列表软移除勾选的本地任务。"""
|
||||
|
||||
stable_ids = tuple(
|
||||
dict.fromkeys(
|
||||
str(value).strip()
|
||||
for value in task_ids
|
||||
if value is not None and str(value).strip()
|
||||
)
|
||||
)
|
||||
if self._closing or not stable_ids:
|
||||
return
|
||||
if (
|
||||
self._auto_fetch_running
|
||||
or self._claim_busy
|
||||
or self._resubmit_busy
|
||||
or self._remove_busy
|
||||
):
|
||||
self._show_rerun_warning(
|
||||
"暂时不能删除",
|
||||
"自动获取、重新执行、重新上报或另一批删除正在运行,"
|
||||
"请等待当前操作结束。",
|
||||
)
|
||||
return
|
||||
|
||||
count = len(stable_ids)
|
||||
dialog = MessageBox(
|
||||
f"从列表删除 {count} 条任务?",
|
||||
"只会从普通任务列表中隐藏这些本地记录,不会删除 Admin 任务;"
|
||||
"执行记录和上报历史仍会永久保留。\n\n"
|
||||
"只有已结束、没有待上报数据且没有进入不可逆阶段的任务才允许删除。",
|
||||
self._page.window(),
|
||||
)
|
||||
dialog.yesButton.setText(f"删除 {count} 条")
|
||||
dialog.cancelButton.setText("暂不删除")
|
||||
dialog.cancelButton.setFocus()
|
||||
if not dialog.exec():
|
||||
return
|
||||
|
||||
self._start_remove_worker(stable_ids)
|
||||
|
||||
def _start_remove_worker(self, task_ids: tuple[str, ...]) -> None:
|
||||
"""启动本地任务软删除工作线程。"""
|
||||
|
||||
self._remove_busy = True
|
||||
self._page.set_remove_running(True)
|
||||
self._page.set_engine_status(
|
||||
f"正在检查并删除 {len(task_ids)} 条本地任务…"
|
||||
)
|
||||
|
||||
thread = QThread(self)
|
||||
worker = TaskRemoveWorker(self._repository, task_ids)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.succeeded.connect(self._on_remove_succeeded)
|
||||
worker.failed.connect(self._on_remove_failed)
|
||||
worker.completed.connect(thread.quit)
|
||||
worker.completed.connect(worker.deleteLater)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
thread.finished.connect(self._on_remove_thread_finished)
|
||||
self._remove_thread = thread
|
||||
self._remove_worker = worker
|
||||
thread.start()
|
||||
|
||||
@pyqtSlot(object)
|
||||
def _on_remove_succeeded(self, task_ids) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
stable_ids = tuple(task_ids)
|
||||
self._page.taskModel.uncheck_tasks(stable_ids)
|
||||
self._page.set_engine_status(
|
||||
f"已从列表删除 {len(stable_ids)} 条本地任务;"
|
||||
"执行记录和上报历史仍会永久保留。"
|
||||
)
|
||||
self._reload()
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _on_remove_failed(self, message: str) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
self._page.set_engine_status(f"删除未完成:{message}")
|
||||
self._show_claim_error("不能删除所选任务", message)
|
||||
|
||||
@pyqtSlot()
|
||||
def _on_remove_thread_finished(self) -> None:
|
||||
self._remove_worker = None
|
||||
self._remove_thread = None
|
||||
self._remove_busy = False
|
||||
if not self._closing:
|
||||
self._page.set_remove_running(False)
|
||||
|
||||
def _start_resubmit_worker(self, task_ids: tuple[str, ...]) -> None:
|
||||
"""启动只处理指定任务 Outbox 的工作线程。"""
|
||||
|
||||
@@ -774,10 +914,10 @@ class PDDTaskPageEvent(QObject):
|
||||
|
||||
if self._closing:
|
||||
return
|
||||
if self._resubmit_busy:
|
||||
if self._resubmit_busy or self._remove_busy:
|
||||
self._show_rerun_warning(
|
||||
"暂时不能获取任务",
|
||||
"任务结果正在重新上报,请等待当前操作结束。",
|
||||
"本地任务正在重新上报或删除,请等待当前操作结束。",
|
||||
)
|
||||
return
|
||||
if self._auto_fetch_running:
|
||||
@@ -1283,6 +1423,19 @@ class PDDTaskPageEvent(QObject):
|
||||
resubmit_thread.quit()
|
||||
resubmit_thread.wait(60_000)
|
||||
|
||||
remove_worker = self._remove_worker
|
||||
remove_thread = self._remove_thread
|
||||
if remove_worker is not None:
|
||||
try:
|
||||
remove_worker.succeeded.disconnect(self._on_remove_succeeded)
|
||||
remove_worker.failed.disconnect(self._on_remove_failed)
|
||||
except (TypeError, RuntimeError):
|
||||
pass
|
||||
if remove_thread is not None and remove_thread.isRunning():
|
||||
# 删除只包含一个很短的 SQLite 事务,不中途取消,避免部分更新。
|
||||
remove_thread.quit()
|
||||
remove_thread.wait(60_000)
|
||||
|
||||
|
||||
def summary_to_row(summary: TaskSummary) -> TaskRow:
|
||||
"""把领域摘要转换成只供表格显示的轻量行。"""
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
from typing import Dict, Iterable, List, Optional, Tuple, Union
|
||||
from uuid import uuid4
|
||||
|
||||
from .db import initialize_database, open_database
|
||||
@@ -39,6 +39,10 @@ class CollectRerunError(ValueError):
|
||||
"""当前任务不满足重新采集条件。"""
|
||||
|
||||
|
||||
class TaskRemovalError(ValueError):
|
||||
"""勾选任务不满足从普通列表移除的安全条件。"""
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
"""返回精确到秒的 UTC ISO 8601 时间。"""
|
||||
|
||||
@@ -145,6 +149,98 @@ class TaskRepository:
|
||||
connection.close()
|
||||
return int(row[0])
|
||||
|
||||
def remove_tasks_from_list(self, remote_task_ids: Iterable[str]) -> int:
|
||||
"""把符合安全条件的终态任务从普通列表中软移除。
|
||||
|
||||
全部任务会在同一个事务中完成校验和更新;任一任务不安全时,
|
||||
所有任务都保持原样。执行记录和 Outbox 不会被删除或修改。
|
||||
"""
|
||||
|
||||
task_ids = tuple(
|
||||
dict.fromkeys(
|
||||
str(value).strip()
|
||||
for value in remote_task_ids
|
||||
if value is not None and str(value).strip()
|
||||
)
|
||||
)
|
||||
if not task_ids:
|
||||
raise TaskRemovalError("没有可删除的任务,请重新勾选。")
|
||||
|
||||
placeholders = ", ".join("?" for _ in task_ids)
|
||||
terminal_statuses = {
|
||||
TaskStatus.SUCCEEDED.value,
|
||||
TaskStatus.FAILED.value,
|
||||
TaskStatus.CANCELLED.value,
|
||||
}
|
||||
now = utc_now_iso()
|
||||
connection = open_database(self._db_path)
|
||||
try:
|
||||
with connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, remote_task_id, status, removed_at FROM pdd_tasks"
|
||||
f" WHERE remote_task_id IN ({placeholders})",
|
||||
task_ids,
|
||||
).fetchall()
|
||||
found = {row["remote_task_id"]: row for row in rows}
|
||||
missing = [task_id for task_id in task_ids if task_id not in found]
|
||||
if missing:
|
||||
raise TaskRemovalError(
|
||||
f"本地找不到任务 {missing[0]},请刷新列表后重试。"
|
||||
)
|
||||
removed = next(
|
||||
(row for row in rows if row["removed_at"] is not None), None
|
||||
)
|
||||
if removed is not None:
|
||||
raise TaskRemovalError(
|
||||
f"任务 {removed['remote_task_id']} 已不在普通列表,请刷新后重试。"
|
||||
)
|
||||
non_terminal = next(
|
||||
(row for row in rows if row["status"] not in terminal_statuses),
|
||||
None,
|
||||
)
|
||||
if non_terminal is not None:
|
||||
raise TaskRemovalError(
|
||||
f"任务 {non_terminal['remote_task_id']} 尚未结束,不能删除。"
|
||||
)
|
||||
|
||||
irreversible = connection.execute(
|
||||
"SELECT t.remote_task_id FROM task_runs r"
|
||||
" JOIN pdd_tasks t ON t.id = r.task_id"
|
||||
f" WHERE t.remote_task_id IN ({placeholders})"
|
||||
" AND r.irreversible_action_at IS NOT NULL LIMIT 1",
|
||||
task_ids,
|
||||
).fetchone()
|
||||
if irreversible is not None:
|
||||
raise TaskRemovalError(
|
||||
f"任务 {irreversible['remote_task_id']} 已进入不可逆阶段,"
|
||||
"必须保留在列表中核对订单。"
|
||||
)
|
||||
|
||||
unsent = connection.execute(
|
||||
"SELECT t.remote_task_id, o.status FROM outbox_events o"
|
||||
" JOIN pdd_tasks t ON t.id = o.task_id"
|
||||
f" WHERE t.remote_task_id IN ({placeholders})"
|
||||
" AND o.status <> 'sent' LIMIT 1",
|
||||
task_ids,
|
||||
).fetchone()
|
||||
if unsent is not None:
|
||||
raise TaskRemovalError(
|
||||
f"任务 {unsent['remote_task_id']} 还有未发送完成的数据,"
|
||||
"请先重新上报。"
|
||||
)
|
||||
|
||||
cursor = connection.execute(
|
||||
"UPDATE pdd_tasks SET removed_at = ?, updated_at = ?"
|
||||
f" WHERE remote_task_id IN ({placeholders})"
|
||||
" AND removed_at IS NULL",
|
||||
(now, now, *task_ids),
|
||||
)
|
||||
if cursor.rowcount != len(task_ids):
|
||||
raise TaskRemovalError("任务列表已发生变化,请刷新后重试。")
|
||||
finally:
|
||||
connection.close()
|
||||
return len(task_ids)
|
||||
|
||||
def get_task(self, remote_task_id: str) -> Optional[TaskDetail]:
|
||||
"""按稳定远程编号读取完整任务;不存在时返回 None。"""
|
||||
|
||||
@@ -1594,7 +1690,7 @@ class TaskRepository:
|
||||
|
||||
@staticmethod
|
||||
def _build_where(filters: TaskFilters) -> Tuple[str, List[object]]:
|
||||
clauses = []
|
||||
clauses = ["removed_at IS NULL"]
|
||||
parameters: List[object] = []
|
||||
if filters.task_type is not None:
|
||||
clauses.append("task_type = ?")
|
||||
|
||||
+47
-4
@@ -6,7 +6,7 @@ import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.db import DatabaseVersionError, initialize_database, open_database
|
||||
from src.db_schema import MIGRATION_1, MIGRATION_2
|
||||
from src.db_schema import MIGRATION_1, MIGRATION_2, MIGRATION_3
|
||||
|
||||
|
||||
EXPECTED_TABLES = {
|
||||
@@ -22,6 +22,7 @@ EXPECTED_INDEXES = {
|
||||
"idx_pdd_tasks_goods_id",
|
||||
"idx_task_runs_task",
|
||||
"idx_outbox_pending",
|
||||
"idx_pdd_tasks_visible_list",
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +64,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
|
||||
self.assertTrue(EXPECTED_TABLES.issubset(tables))
|
||||
self.assertTrue(EXPECTED_INDEXES.issubset(indexes))
|
||||
self.assertEqual(version, 3)
|
||||
self.assertEqual(version, 4)
|
||||
|
||||
def test_v1_database_is_upgraded_without_losing_task_runs(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
@@ -106,7 +107,7 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
connection.close()
|
||||
self.assertIn("result_data", columns)
|
||||
self.assertEqual(attempt_id, "ATTEMPT-OLD")
|
||||
self.assertEqual(version, 3)
|
||||
self.assertEqual(version, 4)
|
||||
|
||||
def test_initialize_can_run_twice_without_losing_data(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
@@ -169,7 +170,49 @@ class DatabaseInitializationTests(unittest.TestCase):
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(row[0], "dry_run")
|
||||
self.assertEqual(version, 3)
|
||||
self.assertEqual(version, 4)
|
||||
|
||||
def test_v3_database_adds_soft_remove_column_without_losing_tasks(self) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
for statement in MIGRATION_1 + MIGRATION_2 + MIGRATION_3:
|
||||
connection.execute(statement)
|
||||
connection.execute("PRAGMA user_version = 3")
|
||||
connection.execute(
|
||||
"INSERT INTO pdd_tasks"
|
||||
" (remote_task_id, task_type, goods_url, status, received_at,"
|
||||
" created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
"COL-V3",
|
||||
"collect",
|
||||
"https://example.test/goods",
|
||||
"succeeded",
|
||||
"2026-08-10T00:00:00Z",
|
||||
"2026-08-10T00:00:00Z",
|
||||
"2026-08-10T00:00:00Z",
|
||||
),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
initialize_database(self.db_path)
|
||||
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
columns = {
|
||||
row[1] for row in connection.execute("PRAGMA table_info(pdd_tasks)")
|
||||
}
|
||||
row = connection.execute(
|
||||
"SELECT remote_task_id, removed_at FROM pdd_tasks"
|
||||
" WHERE remote_task_id = 'COL-V3'"
|
||||
).fetchone()
|
||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertIn("removed_at", columns)
|
||||
self.assertEqual(tuple(row), ("COL-V3", None))
|
||||
self.assertEqual(version, 4)
|
||||
|
||||
def test_new_connection_uses_required_pragmas(self) -> None:
|
||||
initialize_database(self.db_path)
|
||||
|
||||
@@ -63,9 +63,12 @@ class TaskTableModelCheckTests(unittest.TestCase):
|
||||
refresh_position = layout.getItemPosition(layout.indexOf(page.refreshButton))
|
||||
rerun_position = layout.getItemPosition(layout.indexOf(page.rerunButton))
|
||||
resubmit_position = layout.getItemPosition(layout.indexOf(page.resubmitButton))
|
||||
remove_position = layout.getItemPosition(layout.indexOf(page.removeButton))
|
||||
self.assertEqual(refresh_position[1], search_position[1] + 1)
|
||||
self.assertEqual(resubmit_position[1], rerun_position[1] + 1)
|
||||
self.assertEqual(remove_position[1], resubmit_position[1] + 1)
|
||||
self.assertEqual(page.checkedCountLabel.text(), "已选 1 条")
|
||||
self.assertTrue(page.removeButton.isEnabled())
|
||||
|
||||
page.resize(1000, 650)
|
||||
self.app.processEvents()
|
||||
@@ -78,6 +81,26 @@ class TaskTableModelCheckTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(page.taskModel.checked_task_ids(), ())
|
||||
self.assertEqual(page.checkedCountLabel.text(), "已选 0 条")
|
||||
self.assertFalse(page.removeButton.isEnabled())
|
||||
page.deleteLater()
|
||||
|
||||
def test_remove_emits_stable_checked_task_ids(self):
|
||||
page = PDDTaskPage()
|
||||
page.set_tasks(
|
||||
[TaskRow("TASK-1", "采集"), TaskRow("TASK-2", "采集")]
|
||||
)
|
||||
received = []
|
||||
page.removeRequested.connect(received.append)
|
||||
for row in (0, 1):
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(row, CHECK_COLUMN),
|
||||
Qt.Checked,
|
||||
Qt.CheckStateRole,
|
||||
)
|
||||
|
||||
page.removeButton.click()
|
||||
|
||||
self.assertEqual(received, [("TASK-1", "TASK-2")])
|
||||
page.deleteLater()
|
||||
|
||||
def test_auto_fetch_text_and_keyword_width(self):
|
||||
|
||||
@@ -627,6 +627,105 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_remove_confirmation_cancel_keeps_local_task(self):
|
||||
self._add_task(1)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'succeeded'"
|
||||
" WHERE remote_task_id = 'PDD-001'"
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
page = PDDTaskPage()
|
||||
gateway = RecordingClaimGateway()
|
||||
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
|
||||
events.load_initial_tasks()
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
|
||||
with patch("src.pdd_ui_event.MessageBox") as message_box:
|
||||
dialog = message_box.return_value
|
||||
dialog.exec.return_value = False
|
||||
page.removeButton.click()
|
||||
|
||||
message_box.assert_called_once()
|
||||
self.assertIs(message_box.call_args.args[2], page.window())
|
||||
dialog.cancelButton.setFocus.assert_called_once()
|
||||
self.assertEqual(self.repository.count_tasks(), 1)
|
||||
self.assertFalse(events._remove_busy)
|
||||
self.assertEqual(gateway.calls, [])
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_remove_terminal_task_refreshes_list_and_keeps_audit_data(self):
|
||||
self._add_task(1)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'failed'"
|
||||
" WHERE remote_task_id = 'PDD-001'"
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
page = PDDTaskPage()
|
||||
gateway = RecordingClaimGateway()
|
||||
events = PDDTaskPageEvent(page, self.repository, claim_gateway=gateway)
|
||||
events.load_initial_tasks()
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(0, 0), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
|
||||
with patch("src.pdd_ui_event.MessageBox") as message_box:
|
||||
message_box.return_value.exec.return_value = True
|
||||
page.removeButton.click()
|
||||
self.assertTrue(wait_until(self.app, lambda: not events._remove_busy))
|
||||
|
||||
self.assertEqual(page.taskModel.data_row_count(), 0)
|
||||
self.assertEqual(page.taskModel.checked_task_ids(), ())
|
||||
self.assertIsNotNone(self.repository.get_task("PDD-001"))
|
||||
self.assertIn("已从列表删除 1 条", page.statusLabel.text())
|
||||
self.assertEqual(gateway.calls, [])
|
||||
self.device_checker.assert_not_called()
|
||||
events.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_remove_mixed_batch_reports_reason_and_changes_nothing(self):
|
||||
self._add_task(1)
|
||||
self._add_task(2)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = 'cancelled'"
|
||||
" WHERE remote_task_id = 'PDD-001'"
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
page = PDDTaskPage()
|
||||
events = PDDTaskPageEvent(
|
||||
page, self.repository, claim_gateway=RecordingClaimGateway()
|
||||
)
|
||||
events.load_initial_tasks()
|
||||
for row in range(2):
|
||||
page.taskModel.setData(
|
||||
page.taskModel.index(row, 0), Qt.Checked, Qt.CheckStateRole
|
||||
)
|
||||
|
||||
with patch("src.pdd_ui_event.MessageBox") as message_box:
|
||||
message_box.return_value.exec.return_value = True
|
||||
page.removeButton.click()
|
||||
self.assertTrue(wait_until(self.app, lambda: not events._remove_busy))
|
||||
|
||||
self.assertEqual(self.repository.count_tasks(), 2)
|
||||
self.assertEqual(len(page.taskModel.checked_task_ids()), 2)
|
||||
self.assertIn("尚未结束", page.statusLabel.text())
|
||||
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")
|
||||
|
||||
@@ -14,7 +14,12 @@ from src.task_models import (
|
||||
TaskStatus,
|
||||
TaskType,
|
||||
)
|
||||
from src.task_repository import CollectRerunError, DuplicateTaskError, TaskRepository
|
||||
from src.task_repository import (
|
||||
CollectRerunError,
|
||||
DuplicateTaskError,
|
||||
TaskRemovalError,
|
||||
TaskRepository,
|
||||
)
|
||||
|
||||
|
||||
class TaskRepositoryTests(unittest.TestCase):
|
||||
@@ -26,6 +31,17 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
def tearDown(self) -> None:
|
||||
self._temporary_directory.cleanup()
|
||||
|
||||
def _set_task_status(self, task_id: str, status: TaskStatus) -> None:
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
with connection:
|
||||
connection.execute(
|
||||
"UPDATE pdd_tasks SET status = ? WHERE remote_task_id = ?",
|
||||
(status.value, task_id),
|
||||
)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _task(
|
||||
remote_task_id: str,
|
||||
@@ -516,6 +532,108 @@ class TaskRepositoryTests(unittest.TestCase):
|
||||
OutboxStatus.PENDING,
|
||||
)
|
||||
|
||||
def test_terminal_task_is_soft_removed_but_audit_record_remains(self):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-OK"))
|
||||
self._set_task_status("REMOVE-OK", TaskStatus.SUCCEEDED)
|
||||
|
||||
removed_count = self.repository.remove_tasks_from_list(("REMOVE-OK",))
|
||||
|
||||
self.assertEqual(removed_count, 1)
|
||||
self.assertEqual(self.repository.list_tasks(), [])
|
||||
self.assertEqual(self.repository.count_tasks(), 0)
|
||||
self.assertIsNotNone(self.repository.get_task("REMOVE-OK"))
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
row = connection.execute(
|
||||
"SELECT removed_at FROM pdd_tasks"
|
||||
" WHERE remote_task_id = 'REMOVE-OK'"
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertIsNotNone(row[0])
|
||||
with self.assertRaises(DuplicateTaskError):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-OK"))
|
||||
|
||||
def test_batch_remove_is_all_or_nothing_for_non_terminal_task(self):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-ENDED"))
|
||||
self.repository.add_claimed_task(self._task("REMOVE-RUNNING"))
|
||||
self._set_task_status("REMOVE-ENDED", TaskStatus.FAILED)
|
||||
self._set_task_status("REMOVE-RUNNING", TaskStatus.RUNNING)
|
||||
|
||||
with self.assertRaisesRegex(TaskRemovalError, "尚未结束"):
|
||||
self.repository.remove_tasks_from_list(
|
||||
("REMOVE-ENDED", "REMOVE-RUNNING")
|
||||
)
|
||||
|
||||
self.assertEqual(self.repository.count_tasks(), 2)
|
||||
|
||||
def test_task_with_unsent_outbox_cannot_be_removed(self):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-UNSENT"))
|
||||
started = self.repository.start_collect_run("REMOVE-UNSENT", "USB-001")
|
||||
self.repository.save_collect_failure(
|
||||
"REMOVE-UNSENT",
|
||||
started.attempt_id,
|
||||
TaskStatus.FAILED,
|
||||
"TEST_FAILURE",
|
||||
"测试失败",
|
||||
False,
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(TaskRemovalError, "未发送完成"):
|
||||
self.repository.remove_tasks_from_list(("REMOVE-UNSENT",))
|
||||
|
||||
self.assertEqual(self.repository.count_tasks(), 1)
|
||||
|
||||
def test_irreversible_task_cannot_be_removed_even_when_terminal(self):
|
||||
self.repository.add_claimed_task(
|
||||
self._task(
|
||||
"REMOVE-IRREVERSIBLE",
|
||||
TaskType.PURCHASE,
|
||||
execution_mode="live",
|
||||
)
|
||||
)
|
||||
started = self.repository.start_purchase_run(
|
||||
"REMOVE-IRREVERSIBLE", "USB-001"
|
||||
)
|
||||
self.repository.mark_purchase_irreversible(
|
||||
"REMOVE-IRREVERSIBLE", started.attempt_id
|
||||
)
|
||||
self._set_task_status("REMOVE-IRREVERSIBLE", TaskStatus.SUCCEEDED)
|
||||
|
||||
with self.assertRaisesRegex(TaskRemovalError, "不可逆阶段"):
|
||||
self.repository.remove_tasks_from_list(("REMOVE-IRREVERSIBLE",))
|
||||
|
||||
self.assertEqual(self.repository.count_tasks(), 1)
|
||||
|
||||
def test_sent_outbox_does_not_block_soft_remove(self):
|
||||
self.repository.add_claimed_task(self._task("REMOVE-SENT"))
|
||||
started = self.repository.start_collect_run("REMOVE-SENT", "USB-001")
|
||||
event = self.repository.save_collect_result(
|
||||
"REMOVE-SENT",
|
||||
started.attempt_id,
|
||||
{"goods_id": "10001", "title": "测试商品", "skus": []},
|
||||
)
|
||||
self.repository.mark_outbox_sent(event.id)
|
||||
|
||||
self.assertEqual(
|
||||
self.repository.remove_tasks_from_list(("REMOVE-SENT",)), 1
|
||||
)
|
||||
self.assertEqual(self.repository.count_tasks(), 0)
|
||||
connection = open_database(self.db_path)
|
||||
try:
|
||||
run_count = connection.execute(
|
||||
"SELECT COUNT(*) FROM task_runs r JOIN pdd_tasks t"
|
||||
" ON t.id = r.task_id WHERE t.remote_task_id = 'REMOVE-SENT'"
|
||||
).fetchone()[0]
|
||||
outbox_row = connection.execute(
|
||||
"SELECT o.status FROM outbox_events o JOIN pdd_tasks t"
|
||||
" ON t.id = o.task_id WHERE t.remote_task_id = 'REMOVE-SENT'"
|
||||
).fetchone()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertEqual(run_count, 1)
|
||||
self.assertEqual(outbox_row[0], OutboxStatus.SENT.value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -152,6 +152,7 @@ CREATE TABLE pdd_tasks (
|
||||
received_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
removed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
@@ -164,6 +165,9 @@ CREATE INDEX idx_pdd_tasks_status_type
|
||||
|
||||
CREATE INDEX idx_pdd_tasks_goods_id
|
||||
ON pdd_tasks(goods_id);
|
||||
|
||||
CREATE INDEX idx_pdd_tasks_visible_list
|
||||
ON pdd_tasks(removed_at, updated_at DESC, id DESC);
|
||||
```
|
||||
|
||||
### 字段语义
|
||||
@@ -176,6 +180,7 @@ CREATE INDEX idx_pdd_tasks_goods_id
|
||||
- `admin_payload`:`claim` 时收到的原始 Admin 任务 JSON,用于审计和向前兼容。
|
||||
- `pdd_data`:采集或采购结果 JSON,未产生结果时为空。
|
||||
- `received_at`:**领取时间**,即 `claim` 成功的时刻。
|
||||
- `removed_at`:用户把终态任务从普通列表移除的时间。为空表示正常显示;有值只表示隐藏,数据库记录和关联审计数据仍永久保留。
|
||||
- `updated_at`:本地更新时间,也是表格默认排序字段。
|
||||
|
||||
### 3.1 本地只存已领取任务
|
||||
@@ -196,7 +201,9 @@ CREATE INDEX idx_pdd_tasks_goods_id
|
||||
| `[必须]` 本地不保存 Admin 侧状态 | Client 拿到任务就做完,中途不查 Admin 怎么想。Admin 取消了、重派了,Client 一律不感知,照做完照提交,见 [04 接口契约](04-admin-api-contract.md) §1 |
|
||||
| `[必须]` 提交一定会被接受 | Admin 必须无条件接受已派发过的结果,见 [04](04-admin-api-contract.md) §6.1。所以本地不需要"提交被拒"的处理分支 |
|
||||
|
||||
**关于"永久保留":** 任务记录不设保留期,一直留着。但**诊断产物(截图、XML、长日志)仍然按
|
||||
**关于"永久保留":** 任务记录不设保留期,一直留着。界面上的“删除”只写入
|
||||
`removed_at`,不会执行 SQL `DELETE`,也不会删除 `task_runs` 或 `outbox_events`。
|
||||
但**诊断产物(截图、XML、长日志)仍然按
|
||||
`diagnostics.retention_days` 定期清理**——两者是不同的东西,别搞混。清理诊断文件不影响任务记录和审计字段。
|
||||
|
||||
数据量增长靠索引和增量加载扛(见 §3 的索引和 [05 界面规范](05-ui-specification.md) §5.3)。
|
||||
@@ -208,11 +215,16 @@ CREATE INDEX idx_pdd_tasks_goods_id
|
||||
SELECT id, remote_task_id, task_type, title, target_color, target_size,
|
||||
price_cent, quantity, status, updated_at
|
||||
FROM pdd_tasks
|
||||
WHERE /* 本地搜索与筛选条件 */
|
||||
WHERE removed_at IS NULL
|
||||
AND /* 本地搜索与筛选条件 */
|
||||
ORDER BY updated_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset;
|
||||
```
|
||||
|
||||
批量软删除必须在一个事务内先校验全部勾选任务,再统一写入 `removed_at`。
|
||||
只允许 `succeeded`、`failed`、`cancelled`,且所有 Outbox 都已发送、所有执行记录的
|
||||
`irreversible_action_at` 都为空。任一任务不满足时整体失败,不能只隐藏其中一部分。
|
||||
|
||||
## 4. `task_runs`
|
||||
|
||||
每次自动执行或人工确认的重新采集都生成一条记录。任务再次执行时创建新的
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
## 1. 设计目标
|
||||
|
||||
- 让操作人员在一个页面完成任务监控、搜索和异常定位。
|
||||
- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。
|
||||
- “获取任务”“重新执行”和“重新上报”会产生外部后果;重新执行只允许单条采集任务,重新上报只重发既有 Outbox,并优先处理未发送事件。“删除”只软隐藏本地记录。
|
||||
- 长任务状态始终可找到,不使用连续模态弹窗打断工作。
|
||||
- 任务表格在数据增长后仍保持响应速度、稳定选择和可访问性。
|
||||
- 界面只展示任务状态,不在 Qt 主线程执行 Admin 或手机自动化。
|
||||
@@ -35,7 +35,7 @@
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ PDD 任务 │
|
||||
│ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [已选0条] [重新执行] [重新上报] │
|
||||
│ [自动获取] [类型▼] [状态▼] [关键词...] [搜索] [刷新] [已选0条] [重新执行] [重新上报] [删除] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ 类型 │ 商品标题 │ 颜色 │ 尺码 │ 价格 │ 数量 │ 状态 │ 更新时间 │详情│
|
||||
│ │
|
||||
@@ -83,10 +83,10 @@
|
||||
|
||||
筛选条件之间采用 AND。点击搜索或在关键词输入框按 Enter 执行本地数据库查询,不请求领取任务。活动筛选必须可见,筛选无结果时保留条件并提供清除入口。
|
||||
|
||||
### 4.3 刷新、重新执行与重新上报
|
||||
### 4.3 刷新、重新执行、重新上报与删除
|
||||
|
||||
- “刷新”紧邻“搜索”右侧,只重新读取本地任务列表,不请求 Admin,也不操作手机。
|
||||
- 页面右侧显示“已选 N 条”,“重新执行”后面是“重新上报”。
|
||||
- 页面右侧显示“已选 N 条”,操作顺序是“重新执行”“重新上报”“删除”。
|
||||
- “重新执行”只在恰好勾选一条任务时启用;多选不允许批量操作手机。
|
||||
- 点击后先校验任务,再显示明确的“重新采集”确认弹窗。弹窗显示任务编号、商品标题,并说明新结果会覆盖 Client 和 Admin 的当前采集数据。“暂不重新采集”是默认聚焦的安全操作,点击该按钮、按 `Escape` 或关闭弹窗都不得启动任务。
|
||||
- 用户确认后,工作线程必须先检查已保存 Android 设备的实际连接状态,再重置任务和创建执行记录。检查失败时保留原任务状态、采集结果和执行历史。
|
||||
@@ -105,10 +105,13 @@
|
||||
- 重新上报不依赖 Android 设备,在独立工作线程中逐条提交。自动获取、重新执行或另一批上报运行时不得启动。
|
||||
- 批量完成后显示成功、失败和跳过数量。成功任务取消勾选;失败及没有结果 Outbox 的跳过任务保留勾选,方便继续处理。
|
||||
- 相同幂等键和相同内容只用于让 Admin 再次确认已接收,不代表创建新结果或覆盖 Admin 数据。
|
||||
- “删除”作用于当前勾选集。未勾选时禁用;执行前用主窗口居中的确认框显示准确数量,“暂不删除”默认聚焦,按 `Escape` 或关闭弹窗不修改数据。
|
||||
- 删除只允许已完成、失败或已取消的任务。存在待发送、发送中、发送失败 Outbox,或任一执行记录已进入不可逆阶段时,整批拒绝并显示中文原因。
|
||||
- 删除只把任务从普通列表软隐藏,不请求 Admin、不操作 Android,也不修改任务状态、执行记录或 Outbox。成功后清除对应勾选并刷新当前筛选结果。
|
||||
|
||||
## 5. 任务表格
|
||||
|
||||
表格显示的是**本机已领取的全部任务**,包括正在做的和早已做完的。已完成任务永久保留,不会被清理,所以数据只增不减——增量加载和索引是必须的,不是优化。
|
||||
表格显示的是**本机已领取且未被软隐藏的任务**,包括正在做的和早已做完的。已完成任务和审计数据永久保留;用户删除后只是不再出现在普通列表,所以数据库数据仍只增不减——增量加载和索引是必须的,不是优化。
|
||||
|
||||
使用 `qfluentwidgets.TableView`、自定义 `QAbstractTableModel` 和 Repository 查询。不得使用行号作为任务身份,也不得把完整 `pdd_data` 放入模型。
|
||||
|
||||
@@ -399,7 +402,7 @@ self.show_recoverable_error(
|
||||
|
||||
## 10. 键盘与无障碍
|
||||
|
||||
- Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 重新执行 → 重新上报 → 表格 → 状态区可操作项。
|
||||
- Tab 顺序:自动获取 → 类型 → 状态 → 关键词 → 搜索 → 刷新 → 重新执行 → 重新上报 → 删除 → 表格 → 状态区可操作项。
|
||||
- `Ctrl+F` 聚焦关键词,Enter 打开当前行详情。不绑定 `F5`——界面上没有需要刷新的远端数据。
|
||||
- 仅图标按钮必须设置准确的无障碍名称和工具提示。
|
||||
- 表单具有可见标签,占位符不能替代标签。
|
||||
|
||||
Reference in New Issue
Block a user