feat: 完成T-401更新确认页

- 新增 ApplyTab 和 ApplyTaskTableModel,③ Tab 展示已生成更新候选任务

- 支持按批次、店铺、状态筛选,默认列出待更新任务并展示失败/已更新状态

- 开始更新弹窗确认当前筛选条件、任务数和线上提交风险,取消不执行不改库

- 补充 GUI 测试覆盖筛选、确认弹窗和不调用 apply_task 的 T-401 边界

- 同步任务看板、API、路由、当前状态与 progress 文档
This commit is contained in:
chengma
2026-06-27 16:59:46 +08:00
parent e6d7de3d7c
commit 5a63af4620
7 changed files with 510 additions and 10 deletions
+311
View File
@@ -289,6 +289,98 @@ if QT_IMPORT_ERROR is None:
return values[column] if 0 <= column < len(values) else None
class ApplyTaskTableModel(QAbstractTableModel):
"""Table model for Tab 3 update candidates."""
HEADERS = ["店铺", "商品ID", "新标题", "新封面", "阶段", "结果"]
STATUS_TEXT = {
"running": "处理中",
"failed": "失败",
"skipped": "略过",
"cancelled": "已取消",
"pending": "待更新",
"success": "成功",
}
def __init__(self, parent=None):
super().__init__(parent)
self.tasks = []
self.account_by_alias = {}
def set_tasks(self, tasks, accounts):
self.beginResetModel()
self.tasks = list(tasks)
self.account_by_alias = {
str(account.alias).strip(): account
for account in accounts
if str(account.alias).strip()
}
self.endResetModel()
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.tasks)
def columnCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.HEADERS)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role != Qt.DisplayRole:
return None
if orientation == Qt.Horizontal and 0 <= section < len(self.HEADERS):
return self.HEADERS[section]
return section + 1 if orientation == Qt.Vertical else None
def data(self, index, role=Qt.DisplayRole):
if not index.isValid():
return None
task = self.tasks[index.row()]
if role == Qt.DisplayRole:
return self._display_value(task, index.column())
if role == Qt.ToolTipRole and task.last_error:
return task.last_error
return None
def flags(self, index):
if not index.isValid():
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def task_at(self, row):
if row < 0 or row >= len(self.tasks):
return None
return self.tasks[row]
def account_name_for(self, task):
account = self.account_by_alias.get(str(task.alias).strip())
if account is not None:
return account.account_name
return task.account_name or task.alias or ""
def _display_value(self, task, column):
values = [
self.account_name_for(task),
task.item_id,
task.new_title or "",
os.path.basename(task.new_cover_path or ""),
self._stage_text(task),
self._result_text(task),
]
return values[column] if 0 <= column < len(values) else None
def _stage_text(self, task):
if task.stage == "generated":
return "待更新"
if task.stage == "applied":
return "已更新"
return task.stage
def _result_text(self, task):
if task.status == "success" and task.stage == "generated":
return "待更新"
return self.STATUS_TEXT.get(task.status, task.status)
class GenerateTab(QWidget):
"""Tab 2: prompt area plus generation task filters/list."""
@@ -848,6 +940,219 @@ if QT_IMPORT_ERROR is None:
return True
class ApplyTab(QWidget):
"""Tab 3: list generated tasks and confirm the update scope."""
STATUS_FILTERS = [
("已生成", "generated"),
("失败", "failed"),
("已更新", "applied"),
("略过", "skipped"),
("全部状态", "all"),
]
def __init__(self, parent=None, db_path=None, config=None, status_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.batch_filter = QComboBox()
self.batch_filter.setObjectName("applyBatchFilter")
self.shop_filter = QComboBox()
self.shop_filter.setObjectName("applyShopFilter")
self.status_filter = QComboBox()
self.status_filter.setObjectName("applyStatusFilter")
for label, value in self.STATUS_FILTERS:
self.status_filter.addItem(label, value)
self.refresh_button = QPushButton("刷新")
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("批次"))
filter_layout.addWidget(self.batch_filter, 2)
filter_layout.addWidget(QLabel("店铺"))
filter_layout.addWidget(self.shop_filter, 1)
filter_layout.addWidget(QLabel("状态"))
filter_layout.addWidget(self.status_filter, 1)
filter_layout.addWidget(self.refresh_button)
self.summary_label = QLabel("任务 0 条")
self.risk_label = QLabel("点击「开始更新」后会先确认当前筛选范围;确认后才允许后续任务提交线上。")
self.task_table = QTableView()
self.model = ApplyTaskTableModel(self.task_table)
self.task_table.setModel(self.model)
self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.task_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.task_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.task_table.verticalHeader().setVisible(False)
self.start_update_button = QPushButton("开始更新")
self.stop_update_button = QPushButton("停止")
self.write_back_button = QPushButton("回写结果到 Excel")
self.stop_update_button.setEnabled(False)
self.write_back_button.setEnabled(False)
action_layout = QHBoxLayout()
action_layout.addWidget(self.start_update_button)
action_layout.addWidget(self.stop_update_button)
action_layout.addStretch(1)
action_layout.addWidget(self.write_back_button)
layout = QVBoxLayout(self)
layout.setContentsMargins(18, 18, 18, 18)
layout.addLayout(filter_layout)
layout.addWidget(self.risk_label)
layout.addWidget(self.summary_label)
layout.addWidget(self.task_table, 1)
layout.addLayout(action_layout)
self.batch_filter.currentIndexChanged.connect(self.refresh_tasks)
self.shop_filter.currentIndexChanged.connect(self.refresh_tasks)
self.status_filter.currentIndexChanged.connect(self.refresh_tasks)
self.refresh_button.clicked.connect(self.refresh_tasks)
self.start_update_button.clicked.connect(self.start_update)
self.refresh_tasks()
def _set_status(self, message):
if self.status_callback is not None:
self.status_callback(message)
def refresh_tasks(self, checked=False):
try:
db.init_db(self.db_path)
batches = db.list_batches(path=self.db_path)
account_rows = accounts.list_accounts(path=self.db_path, config=self.config)
selected_batch = self.batch_filter.currentData()
selected_shop = self.shop_filter.currentData()
selected_status = self.status_filter.currentData() or "generated"
self._populate_batch_filter(batches, selected_batch)
selected_batch = self.batch_filter.currentData()
batch_tasks = [
task for task in db.list_tasks(batch_id=selected_batch, path=self.db_path)
if self._is_update_task(task)
]
self._populate_shop_filter(batch_tasks, account_rows, selected_shop)
selected_shop = self.shop_filter.currentData()
filtered_tasks = [
task for task in batch_tasks
if self._matches_shop(task, selected_shop)
and self._matches_status(task, selected_status)
]
except Exception as exc:
self.model.set_tasks([], [])
self.summary_label.setText("任务读取失败")
self._set_status(f"更新任务读取失败:{exc}")
return
self.model.set_tasks(filtered_tasks, account_rows)
self.summary_label.setText(
f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条"
)
def start_update(self, checked=False):
tasks = list(self.model.tasks)
if not tasks:
self._set_status("当前筛选结果没有可更新任务")
return
answer = QMessageBox.question(
self,
"确认开始更新",
self._confirmation_message(tasks),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if answer != QMessageBox.Yes:
self._set_status("已取消开始更新")
return
self._set_status(
f"已确认更新范围:{len(tasks)} 条;实际更新执行将在 T-402 接入"
)
def _populate_batch_filter(self, batches, selected_batch):
previous = selected_batch if selected_batch in {batch.id for batch in batches} 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 _populate_shop_filter(self, tasks, account_rows, selected_shop):
account_by_alias = {
str(account.alias).strip(): account
for account in account_rows
if str(account.alias).strip()
}
aliases = []
for task in tasks:
alias = str(task.alias).strip()
if alias and alias not in aliases:
aliases.append(alias)
previous = selected_shop if selected_shop in aliases else None
self.shop_filter.blockSignals(True)
self.shop_filter.clear()
self.shop_filter.addItem("全部店铺", None)
for alias in sorted(aliases, key=lambda value: self._shop_label(value, account_by_alias)):
self.shop_filter.addItem(self._shop_label(alias, account_by_alias), alias)
index = self.shop_filter.findData(previous)
self.shop_filter.setCurrentIndex(index if index >= 0 else 0)
self.shop_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 _shop_label(self, alias, account_by_alias):
account = account_by_alias.get(alias)
if account is not None:
return f"{account.account_name} ({alias})"
return alias
def _status_label(self):
return self.status_filter.currentText() or "已生成"
def _batch_filter_label(self):
return self.batch_filter.currentText() or "全部批次"
def _shop_filter_label(self):
return self.shop_filter.currentText() or "全部店铺"
def _is_update_task(self, task):
if task.stage in {"generated", "applied"}:
return True
return bool((task.new_title or task.new_cover_path) and task.status in {"failed", "skipped"})
def _matches_shop(self, task, selected_shop):
return selected_shop is None or str(task.alias).strip() == selected_shop
def _matches_status(self, task, selected_status):
if selected_status in (None, "all"):
return True
if selected_status == "generated":
return task.stage == "generated" and task.status in {"success", "pending"}
if selected_status == "failed":
return task.status == "failed"
if selected_status == "applied":
return task.stage == "applied"
if selected_status == "skipped":
return task.status == "skipped"
return True
def _confirmation_message(self, tasks):
return (
"即将按当前筛选结果开始更新 Shopee 线上商品。\n\n"
f"批次:{self._batch_filter_label()}\n"
f"店铺:{self._shop_filter_label()}\n"
f"状态:{self._status_label()}\n"
f"任务数:{len(tasks)}\n\n"
"确认后后续执行会打开商品编辑页、替换标题和封面,并点击「更新」提交线上。"
)
class CollectTab(QWidget):
"""Tab 1: import Excel files and list imported tasks."""
@@ -1897,6 +2202,12 @@ if QT_IMPORT_ERROR is None:
config=self.config,
status_callback=self.statusBar().showMessage,
)
if title == "③ 更新shopee":
return ApplyTab(
db_path=self.db_path,
config=self.config,
status_callback=self.statusBar().showMessage,
)
if title == "④ 账号管理":
return AccountsTab(
db_path=self.db_path,