feat: add soft delete for import batches

This commit is contained in:
chengma
2026-07-01 09:41:10 +08:00
parent 5b044f2349
commit cc277366ed
10 changed files with 362 additions and 42 deletions
+91 -26
View File
@@ -16,7 +16,13 @@ from .config import make_slug
DEFAULT_BUSY_TIMEOUT_MS = 5000
VALID_BATCH_FIELDS = {"source_files_json", "status", "note"}
VALID_BATCH_FIELDS = {
"source_files_json",
"status",
"note",
"deleted_at",
"deleted_reason",
}
VALID_ACCOUNT_FIELDS = {
"account_name",
"alias",
@@ -61,6 +67,8 @@ class Batch:
note: Optional[str]
created_at: str
updated_at: str
deleted_at: Optional[str]
deleted_reason: Optional[str]
@property
def source_files(self) -> list[str]:
@@ -157,7 +165,9 @@ CREATE TABLE IF NOT EXISTS batches (
status TEXT NOT NULL DEFAULT 'active',
note TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
updated_at TEXT NOT NULL,
deleted_at TEXT,
deleted_reason TEXT
);
CREATE TABLE IF NOT EXISTS accounts (
@@ -313,8 +323,16 @@ def init_db(path=None, conn=None) -> None:
with _connection(conn, path) as database:
with database:
database.executescript(SCHEMA_SQL)
_ensure_batch_delete_columns(database)
def _ensure_batch_delete_columns(database):
columns = {row["name"] for row in database.execute("PRAGMA table_info(batches)").fetchall()}
if "deleted_at" not in columns:
database.execute("ALTER TABLE batches ADD COLUMN deleted_at TEXT")
if "deleted_reason" not in columns:
database.execute("ALTER TABLE batches ADD COLUMN deleted_reason TEXT")
def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) -> str:
batch_id = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:8]
files = [os.path.abspath(file_path) for file_path in file_paths]
@@ -332,22 +350,26 @@ def create_batch(file_paths: Iterable[str], note=None, path=None, conn=None) ->
return batch_id
def get_batch(batch_id, path=None, conn=None):
def get_batch(batch_id, path=None, conn=None, include_deleted=False):
sql = "SELECT * FROM batches WHERE id = ?"
params = [batch_id]
if not include_deleted:
sql += " AND deleted_at IS NULL"
with _connection(conn, path) as database:
return _fetch_one(
database,
"SELECT * FROM batches WHERE id = ?",
(batch_id,),
Batch,
)
return _fetch_one(database, sql, params, Batch)
def list_batches(status=None, path=None, conn=None):
sql = "SELECT * FROM batches"
def list_batches(status=None, path=None, conn=None, include_deleted=False):
clauses = []
params = []
if status is not None:
sql += " WHERE status = ?"
clauses.append("status = ?")
params.append(status)
if not include_deleted:
clauses.append("deleted_at IS NULL")
sql = "SELECT * FROM batches"
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY created_at DESC, id DESC"
with _connection(conn, path) as database:
return _fetch_all(database, sql, params, Batch)
@@ -497,35 +519,78 @@ def insert_tasks(batch_id, rows, path=None, conn=None) -> int:
return len(values)
def list_tasks(batch_id=None, stage=None, status=None, alias=None, path=None, conn=None):
def list_tasks(
batch_id=None,
stage=None,
status=None,
alias=None,
path=None,
conn=None,
include_deleted=False,
):
clauses = []
params = []
filters = {
"batch_id": batch_id,
"stage": stage,
"status": status,
"alias": alias,
"t.batch_id": batch_id,
"t.stage": stage,
"t.status": status,
"t.alias": alias,
}
for field, value in filters.items():
if value is not None:
clauses.append(f"{field} = ?")
params.append(value)
sql = "SELECT * FROM tasks"
if not include_deleted:
clauses.append("b.deleted_at IS NULL")
sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id"
if clauses:
sql += " WHERE " + " AND ".join(clauses)
sql += " ORDER BY id"
sql += " ORDER BY t.id"
with _connection(conn, path) as database:
return _fetch_all(database, sql, params, Task)
def get_task(task_id, path=None, conn=None):
def delete_batch(batch_id, reason=None, path=None, conn=None) -> dict:
"""Soft delete a batch so it disappears from normal UI and workflows."""
with _connection(conn, path) as database:
return _fetch_one(
database,
"SELECT * FROM tasks WHERE id = ?",
(int(task_id),),
Task,
)
batch = get_batch(batch_id, conn=database)
if batch is None:
raise DbError(f"批次不存在或已删除: {batch_id}")
tasks = list_tasks(batch_id=batch_id, conn=database)
image_paths = []
for task in tasks:
for image_path in (task.old_cover_path, task.new_cover_path):
if image_path and image_path not in image_paths:
image_paths.append(image_path)
committed_count = sum(1 for task in tasks if int(task.committed or 0) == 1)
now = _now()
with database:
database.execute(
"""
UPDATE batches
SET deleted_at = ?, deleted_reason = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
""",
(now, str(reason or ""), now, batch_id),
)
return {
"batch_id": batch_id,
"deleted_at": now,
"task_count": len(tasks),
"committed_count": committed_count,
"image_paths": image_paths,
}
def get_task(task_id, path=None, conn=None, include_deleted=False):
clauses = ["t.id = ?"]
params = [int(task_id)]
if not include_deleted:
clauses.append("b.deleted_at IS NULL")
sql = "SELECT t.* FROM tasks t JOIN batches b ON b.id = t.batch_id"
sql += " WHERE " + " AND ".join(clauses)
with _connection(conn, path) as database:
return _fetch_one(database, sql, params, Task)
def mark_running(task_id, phase, path=None, conn=None) -> None:
+105 -4
View File
@@ -1820,12 +1820,14 @@ if QT_IMPORT_ERROR is None:
config=None,
status_callback=None,
open_accounts_callback=None,
refresh_workflow_callback=None,
):
super().__init__(parent)
self.config = appconfig.load_config() if config is None else config
self.db_path = _database_path(db_path, self.config)
self.status_callback = status_callback
self.open_accounts_callback = open_accounts_callback
self.refresh_workflow_callback = refresh_workflow_callback
self.current_batch_id = None
self.has_import_result = False
self.last_import_stats = None
@@ -1841,10 +1843,18 @@ if QT_IMPORT_ERROR is None:
self.stop_collect_button = QPushButton("停止")
self.write_back_button = QPushButton("回写旧数据到 Excel")
self.stop_collect_button.setEnabled(False)
self.batch_filter = QComboBox()
self.batch_filter.setObjectName("collectBatchFilter")
self.delete_batch_button = QPushButton("删除批次")
self.delete_batch_button.setObjectName("deleteBatchButton")
self.delete_batch_button.setEnabled(False)
toolbar = QHBoxLayout()
toolbar.addWidget(self.import_button)
toolbar.addWidget(self.refresh_button)
toolbar.addWidget(QLabel("批次"))
toolbar.addWidget(self.batch_filter, 2)
toolbar.addWidget(self.delete_batch_button)
toolbar.addWidget(self.collect_button)
toolbar.addWidget(self.stop_collect_button)
toolbar.addWidget(self.write_back_button)
@@ -1890,6 +1900,8 @@ if QT_IMPORT_ERROR is None:
self.import_button.clicked.connect(self.import_excel)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.delete_batch_button.clicked.connect(self.delete_current_batch)
self.collect_button.clicked.connect(self.collect_old_data)
self.stop_collect_button.clicked.connect(self.stop_collect)
self.write_back_button.clicked.connect(self.write_back_old_data)
@@ -1981,10 +1993,14 @@ if QT_IMPORT_ERROR is None:
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
if self.current_batch_id is None and self.has_import_result:
task_rows = []
else:
task_rows = db.list_tasks(batch_id=self.current_batch_id, path=self.db_path)
batches = db.list_batches(path=self.db_path)
selected_batch = self.batch_filter.currentData()
if self.current_batch_id and self.batch_filter.findData(self.current_batch_id) < 0:
selected_batch = self.current_batch_id
self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData()
self.current_batch_id = selected_batch
task_rows = db.list_tasks(batch_id=selected_batch, path=self.db_path)
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
except Exception as exc:
self.model.set_tasks([], [])
@@ -1994,6 +2010,80 @@ if QT_IMPORT_ERROR is None:
self.model.set_tasks(task_rows, account_rows)
self._update_summary(task_rows, account_rows)
self._update_empty_label(len(task_rows))
self._update_delete_batch_button()
def _populate_batch_filter(self, batches, selected_batch):
batch_ids = {batch.id for batch in batches}
previous = selected_batch if selected_batch in batch_ids else None
self.batch_filter.blockSignals(True)
self.batch_filter.clear()
self.batch_filter.addItem("全部批次", None)
for batch in batches:
self.batch_filter.addItem(self._batch_label(batch), batch.id)
index = self.batch_filter.findData(previous)
self.batch_filter.setCurrentIndex(index if index >= 0 else 0)
self.batch_filter.blockSignals(False)
def _batch_label(self, batch):
source_files = batch.source_files
first_file = os.path.basename(source_files[0]) if source_files else batch.id
return f"{batch.created_at} · {first_file}"
def _selected_batch_id(self):
return self.batch_filter.currentData()
def _update_delete_batch_button(self):
running = bool(self.collect_thread or self.write_back_thread)
self.delete_batch_button.setEnabled((not running) and bool(self._selected_batch_id()))
def delete_current_batch(self, checked=False):
batch_id = self._selected_batch_id()
if not batch_id:
self._set_status("请先选择一个具体批次")
return
batch = db.get_batch(batch_id, path=self.db_path)
if batch is None:
self._set_status("批次不存在或已删除")
self.current_batch_id = None
self.refresh_tasks()
return
tasks = db.list_tasks(batch_id=batch_id, path=self.db_path)
committed_count = sum(1 for task in tasks if int(getattr(task, "committed", 0) or 0) == 1)
lines = [
f"确定要软删除批次 {self._batch_label(batch)} 吗?",
f"任务数:{len(tasks)}",
f"已提交线上:{committed_count}",
"",
"软删除后,该批次不会再出现在①/②/③页面、筛选、采集、生成、更新或回写入口中。",
"软删除只隐藏本地批次,不会回滚 Shopee 线上修改,不删除原始 Excel,也不删除本地图片。",
]
answer = QMessageBox.question(
self,
"删除批次",
"\n".join(lines),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消删除批次")
return
try:
result = db.delete_batch(batch_id, reason="用户在导入采集页软删除", path=self.db_path)
except Exception as exc:
QMessageBox.warning(self, "删除批次", str(exc))
self._set_status(f"删除批次失败:{exc}")
return
self.current_batch_id = None
self.has_import_result = False
self.refresh_tasks()
if self.refresh_workflow_callback is not None:
self.refresh_workflow_callback()
message = "已软删除批次:任务{task_count},已提交线上{committed_count}".format(
task_count=result.get("task_count", 0),
committed_count=result.get("committed_count", 0),
)
self._set_status(message)
QMessageBox.information(self, "删除批次", message)
def collect_old_data(self, checked=False):
tasks = list(self.model.all_tasks)
@@ -2077,12 +2167,16 @@ if QT_IMPORT_ERROR is None:
self.collect_button.setEnabled(not running)
self.write_back_button.setEnabled(not running)
self.stop_collect_button.setEnabled(running)
self.batch_filter.setEnabled(not running)
self._update_delete_batch_button()
def _set_write_back_running(self, running):
self.import_button.setEnabled(not running)
self.refresh_button.setEnabled(not running)
self.collect_button.setEnabled(not running)
self.write_back_button.setEnabled(not running)
self.batch_filter.setEnabled(not running)
self._update_delete_batch_button()
def _forget_collect_thread(self, thread):
if self.collect_thread is thread:
@@ -4629,6 +4723,7 @@ if QT_IMPORT_ERROR is None:
config=self.config,
status_callback=self.statusBar().showMessage,
open_accounts_callback=lambda: self.open_accounts_tab(),
refresh_workflow_callback=lambda: self.refresh_task_tabs(),
)
if title == "② AI生成":
return GenerateTab(
@@ -4656,6 +4751,12 @@ if QT_IMPORT_ERROR is None:
status_callback=self.statusBar().showMessage,
)
def refresh_task_tabs(self):
for index in range(self.tabs.count()):
widget = self.tabs.widget(index)
if hasattr(widget, "refresh_tasks"):
widget.refresh_tasks()
def _on_tab_changed(self, index):
self.statusBar().showMessage(f"当前:{self.tabs.tabText(index)}")