From 6e2dcf99543d3df0bca0ea3a9b0988f6aaff2a55 Mon Sep 17 00:00:00 2001 From: chengma Date: Thu, 2 Jul 2026 16:47:37 +0800 Subject: [PATCH] refactor: split gui into package --- app/gui.py | 6317 -------------------------------------- app/gui/__init__.py | 50 + app/gui/main_window.py | 91 + app/gui/models.py | 386 +++ app/gui/tabs/__init__.py | 1 + app/gui/tabs/accounts.py | 434 +++ app/gui/tabs/apply.py | 820 +++++ app/gui/tabs/collect.py | 840 +++++ app/gui/tabs/generate.py | 856 ++++++ app/gui/tabs/settings.py | 800 +++++ app/gui/widgets.py | 428 +++ app/gui/workers.py | 1772 +++++++++++ docs/04-architecture.md | 2 +- docs/06-tasks.md | 2 +- docs/api.md | 4 +- docs/current-state.md | 15 +- progress.md | 7 +- 17 files changed, 6497 insertions(+), 6328 deletions(-) delete mode 100644 app/gui.py create mode 100644 app/gui/__init__.py create mode 100644 app/gui/main_window.py create mode 100644 app/gui/models.py create mode 100644 app/gui/tabs/__init__.py create mode 100644 app/gui/tabs/accounts.py create mode 100644 app/gui/tabs/apply.py create mode 100644 app/gui/tabs/collect.py create mode 100644 app/gui/tabs/generate.py create mode 100644 app/gui/tabs/settings.py create mode 100644 app/gui/widgets.py create mode 100644 app/gui/workers.py diff --git a/app/gui.py b/app/gui.py deleted file mode 100644 index 68170e4..0000000 --- a/app/gui.py +++ /dev/null @@ -1,6317 +0,0 @@ -"""PySide6 GUI entry point.""" - -from __future__ import annotations - -import os -import sys -import threading -import time -from concurrent.futures import ThreadPoolExecutor, as_completed - -try: - from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt - from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap - from PySide6.QtWidgets import ( - QAbstractItemView, - QApplication, - QCheckBox, - QComboBox, - QDialog, - QDialogButtonBox, - QFileDialog, - QFormLayout, - QGridLayout, - QHBoxLayout, - QHeaderView, - QInputDialog, - QLabel, - QLineEdit, - QMainWindow, - QMenu, - QMessageBox, - QPlainTextEdit, - QProgressBar, - QPushButton, - QScrollArea, - QSplitter, - QTableView, - QSpinBox, - QTableWidget, - QTableWidgetItem, - QTabWidget, - QVBoxLayout, - QWidget, - ) - QT_IMPORT_ERROR = None -except ModuleNotFoundError as exc: - QApplication = None - QMainWindow = object - QTabWidget = None - QVBoxLayout = None - QWidget = object - QT_IMPORT_ERROR = exc - - -TAB_TITLES = [ - "① 导入采集", - "② AI生成", - "③ 更新shopee", - "④ 账号管理", - "⑤ 设置", -] - -TAB_STYLE = """ -QTabWidget::pane { - border-top: 1px solid #c9d1d9; -} -QTabBar::tab { - min-width: 128px; - min-height: 34px; - padding: 8px 18px; - margin-right: 8px; - border: 1px solid #c9d1d9; - border-bottom-color: #b8c0ca; - background: #f4f6f8; - color: #24292f; -} -QTabBar::tab:selected { - background: #ffffff; - border-color: #687785; - border-bottom-color: #ffffff; - font-weight: 600; -} -QTabBar::tab:hover:!selected { - background: #eaf2ff; -} -""" -COLOR_SUCCESS = "#1a7f37" -COLOR_DANGER = "#cf222e" -COLOR_INFO = "#0969da" -COLOR_PENDING = "#9a6700" -COLOR_MUTED = "#6e7781" -COLOR_WARNING = "#bc4c00" -COLOR_DANGER_BG = "#ffebe9" - - -if QT_IMPORT_ERROR is None: - from . import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, image_paths, prompts - from . import config as account_config - - - PLAINTEXT_SECRET_TITLE = "本地明文保存提示" - PLAINTEXT_API_KEY_WARNING = ( - "API Key 会以本地明文保存到 config/ai_models.json,仅供本机调用 AI 使用。" - "该文件已 gitignore,UI 打码显示,日志/导出不记录明文。" - ) - PLAINTEXT_PASSWORD_WARNING = ( - "密码会以本地明文保存到本地 SQLite,仅供人工参考,不会自动登录/自动填。" - "数据库文件已 gitignore,请勿提交或分享。" - ) - - - def _qcolor(color): - return QColor(color) - - - def _status_base_color(status): - if status == "running": - return COLOR_INFO - if status == "failed": - return COLOR_DANGER - if status in {"skipped", "cancelled"}: - return COLOR_MUTED - return None - - - def _danger_metric_text(text, active): - if not active: - return text - return f'{text}' - - - def _danger_outline_button_style(object_name): - return ( - f"QPushButton#{object_name} {{ " - f"color: {COLOR_DANGER}; border: 1px solid {COLOR_DANGER}; " - "font-weight: 600; padding: 3px 10px; border-radius: 4px; " - "}" - ) - - - def _login_status_display(status): - return f"● {status or '未知'}" - - - def _login_status_color(status): - status_text = str(status or "") - if status_text == "已登录": - return _qcolor(COLOR_SUCCESS) - if status_text == "检测中": - return _qcolor(COLOR_INFO) - if "未登录" in status_text or "失败" in status_text: - return _qcolor(COLOR_DANGER) - return _qcolor(COLOR_MUTED) - - - def _warning_dot_icon(size=12): - pixmap = QPixmap(size, size) - pixmap.fill(Qt.transparent) - painter = QPainter(pixmap) - painter.setRenderHint(QPainter.Antialiasing) - painter.setPen(Qt.NoPen) - painter.setBrush(QColor(COLOR_WARNING)) - margin = max(1, size // 6) - painter.drawEllipse(margin, margin, size - margin * 2, size - margin * 2) - painter.end() - return QIcon(pixmap) - - - def _build_empty_state_card(object_name, button_text="前往账号管理"): - card = QWidget() - card.setObjectName(object_name) - card.setStyleSheet( - f"QWidget#{object_name} {{ " - "background: #f6f8fa; border: 1px solid #d0d7de; " - "border-radius: 6px; padding: 10px; " - "}" - ) - layout = QHBoxLayout(card) - layout.setContentsMargins(12, 10, 12, 10) - label = QLabel("") - label.setWordWrap(True) - button = QPushButton(button_text) - button.setObjectName(f"{object_name}Button") - button.setVisible(False) - layout.addWidget(label, 1) - layout.addWidget(button) - card.setVisible(False) - return card, label, button - - - def _set_empty_state(card, label, button, message=None, show_button=False): - active = bool(message) - card.setVisible(active) - label.setText(message or "") - button.setVisible(active and show_button) - - - BATCH_PROGRESS_FIELDS = [ - ("total", "总数"), - ("imported", "导入"), - ("collected", "已采集"), - ("generated", "已生成"), - ("applied", "已更新"), - ("failed", "失败"), - ("skipped", "略过"), - ] - - - def _summarize_batch_progress(tasks): - summary = {key: 0 for key, _label in BATCH_PROGRESS_FIELDS} - task_rows = list(tasks or []) - summary["total"] = len(task_rows) - for task in task_rows: - status = getattr(task, "status", None) - stage = getattr(task, "stage", None) - if status == "failed": - summary["failed"] += 1 - elif status == "skipped": - summary["skipped"] += 1 - elif stage == "applied": - summary["applied"] += 1 - elif stage == "generated": - summary["generated"] += 1 - elif stage == "collected": - summary["collected"] += 1 - else: - summary["imported"] += 1 - return summary - - - def _format_batch_progress(summary): - parts = [f"{label}{summary.get(key, 0)}" for key, label in BATCH_PROGRESS_FIELDS] - return "批次进度:" + " · ".join(parts) - - - def _build_batch_progress_overview(object_name): - label = QLabel("") - label.setObjectName(object_name) - label.setWordWrap(True) - label.setStyleSheet( - f"QLabel#{object_name} {{ " - "background: #f6f8fa; border: 1px solid #d0d7de; " - "border-radius: 6px; padding: 8px 10px; color: #24292f; " - "}" - ) - label.setVisible(False) - return label - - - def _set_batch_progress_overview(label, tasks): - summary = _summarize_batch_progress(tasks) - active = summary["total"] > 0 - label.setVisible(active) - label.setText(_format_batch_progress(summary) if active else "") - - - def _database_path(db_path=None, config=None) -> str: - return db_path or appconfig.db_path(config) - - - def _write_reset_run_log(db_path, task, action, message): - run_id = db.create_run_log( - "reset", - total=1, - options={ - "action": action, - "task_id": getattr(task, "id", None), - "alias": getattr(task, "alias", None), - "item_id": getattr(task, "item_id", None), - }, - path=db_path, - ) - db.add_run_log_event( - run_id, - message, - task_id=getattr(task, "id", None), - alias=getattr(task, "alias", None), - item_id=getattr(task, "item_id", None), - path=db_path, - ) - db.finish_run_log( - run_id, - status="done", - done=1, - success_count=1, - failed_count=0, - summary_json={ - "action": action, - "task_id": getattr(task, "id", None), - "alias": getattr(task, "alias", None), - "item_id": getattr(task, "item_id", None), - }, - path=db_path, - ) - return run_id - - - def _safe_create_run_log(run_type, db_path=None, dry_run=False, total=0, options=None): - try: - return db.create_run_log( - run_type, - dry_run=dry_run, - total=total, - options=options or {}, - path=db_path, - ) - except Exception: - return None - - - def _safe_finish_run_log(run_id, db_path=None, **fields): - if run_id is None: - return - try: - db.finish_run_log(run_id, path=db_path, **fields) - except Exception: - return - - - def _safe_add_run_log_event( - run_id, - message, - db_path=None, - task=None, - account=None, - level="info", - task_id=None, - alias=None, - item_id=None, - ): - safe_message = diagnostics.redact_log_text(message) - if task is not None: - task_id = getattr(task, "id", task_id) - alias = getattr(task, "alias", alias) - item_id = getattr(task, "item_id", item_id) - if account is not None: - alias = getattr(account, "alias", alias) - if run_id is None: - return safe_message - try: - db.add_run_log_event( - run_id, - safe_message, - task_id=task_id, - alias=alias, - item_id=item_id, - level=level, - path=db_path, - ) - except Exception: - pass - return safe_message - - - def _safe_write_diagnostic_log( - message, - level="INFO", - step=None, - task=None, - account=None, - elapsed_ms=None, - payload=None, - exc=None, - log_dir=None, - ): - try: - diagnostics.write_diagnostic_log( - message, - level=level, - step=step, - task_id=getattr(task, "id", None), - alias=getattr(task, "alias", None) or getattr(account, "alias", None), - item_id=getattr(task, "item_id", None), - elapsed_ms=elapsed_ms, - payload=payload, - exc=exc, - log_dir=log_dir, - ) - except Exception: - return - - - def _elapsed_ms(started): - return int((time.monotonic() - started) * 1000) - - - class TaskTableModel(QAbstractTableModel): - """Table model for task rows shared by workflow tabs.""" - - HEADERS = ["账号", "别名", "商品ID", "阶段"] - - STAGE_TEXT = { - "imported": "待采集", - "collected": "已采集", - "generated": "已生成", - "applied": "已更新", - } - - STATUS_TEXT = { - "running": "处理中", - "failed": "失败", - "skipped": "略过", - "cancelled": "已取消", - } - - def __init__(self, parent=None): - super().__init__(parent) - self.tasks = [] - self.all_tasks = [] - self.account_by_alias = {} - self.filter_mode = "all" - - def set_tasks(self, tasks, accounts): - self.beginResetModel() - self.all_tasks = list(tasks) - self.account_by_alias = { - str(account.alias).strip(): account - for account in accounts - if str(account.alias).strip() - } - self.tasks = self._filtered_tasks() - self.endResetModel() - - def set_filter_mode(self, mode): - self.beginResetModel() - self.filter_mode = mode if mode in {"all", "unmatched"} else "all" - self.tasks = self._filtered_tasks() - self.endResetModel() - - def _filtered_tasks(self): - if self.filter_mode == "unmatched": - return [task for task in self.all_tasks if self.is_unmatched(task)] - return list(self.all_tasks) - - 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.ForegroundRole and index.column() == 3: - return self._stage_color(task) - if role == Qt.ToolTipRole and self.is_unmatched(task): - return "别名未匹配账号,采集时将略过" - 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 is_unmatched(self, task) -> bool: - return str(task.alias).strip() not in self.account_by_alias - - def unmatched_count(self) -> int: - return sum(1 for task in self.all_tasks if self.is_unmatched(task)) - - def _account_name(self, task) -> str: - account = self.account_by_alias.get(str(task.alias).strip()) - if account is not None: - return account.account_name - return task.account_name or "" - - def _stage_text(self, task) -> str: - if self.is_unmatched(task): - return "略过" - if task.status in self.STATUS_TEXT and task.status != "pending": - return self.STATUS_TEXT[task.status] - return self.STAGE_TEXT.get(task.stage, task.stage) - - def _stage_color(self, task): - if self.is_unmatched(task): - return _qcolor(COLOR_MUTED) - base_color = _status_base_color(getattr(task, "status", None)) - if base_color is not None: - return _qcolor(base_color) - if getattr(task, "stage", None) in {"collected", "generated", "applied"}: - return _qcolor(COLOR_SUCCESS) - return _qcolor(COLOR_PENDING) - - def _display_value(self, task, column): - values = [ - self._account_name(task), - task.alias, - task.item_id, - self._stage_text(task), - ] - return values[column] if 0 <= column < len(values) else None - - - class GenerateTaskTableModel(QAbstractTableModel): - """Table model for Tab 2 generation candidates.""" - - HEADERS = ["店铺", "商品ID", "旧标题", "新标题", "状态"] - - STATUS_TEXT = { - "running": "处理中", - "failed": "失败", - "skipped": "略过", - "cancelled": "已取消", - } - - STAGE_TEXT = { - "imported": "未采集", - "collected": "待生成", - "generated": "已生成", - "applied": "已更新", - } - - def __init__(self, parent=None, db_path=None, status_callback=None): - super().__init__(parent) - self.tasks = [] - self.account_by_alias = {} - self.db_path = db_path - self.status_callback = status_callback - self.last_edit_error = None - - 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 in (Qt.DisplayRole, Qt.EditRole): - return self._display_value(task, index.column()) - if role == Qt.ForegroundRole and index.column() == 4: - return self._status_color(task) - if role == Qt.ToolTipRole: - if index.column() == 3 and self._can_edit_title(task): - return "双击可微调新标题,只修改本地待更新内容" - if task.last_error: - return task.last_error - return None - - def setData(self, index, value, role=Qt.EditRole): - if role != Qt.EditRole or not index.isValid() or index.column() != 3: - return False - task = self.tasks[index.row()] - if not self._can_edit_title(task): - self._set_status("该任务不能修改新标题") - return False - title = str(value or "").strip() - if title == str(task.new_title or ""): - return True - try: - db.update_generated_title(task.id, title, path=self.db_path) - updated = db.get_task(task.id, path=self.db_path) - except Exception as exc: - self.last_edit_error = str(exc) - self._set_status(f"新标题修改失败:{exc}") - return False - self.tasks[index.row()] = updated - self.last_edit_error = None - self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole, Qt.ToolTipRole]) - self._set_status(f"已修改新标题:商品 {task.item_id}") - return True - - def flags(self, index): - if not index.isValid(): - return Qt.NoItemFlags - flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable - if index.column() == 3 and self._can_edit_title(self.tasks[index.row()]): - flags |= Qt.ItemIsEditable - return flags - - def _can_edit_title(self, task): - return ( - getattr(task, "stage", None) == "generated" - and getattr(task, "status", None) != "running" - and int(getattr(task, "committed", 0) or 0) == 0 - and bool(getattr(task, "new_title", None)) - ) - - def _set_status(self, message): - if self.status_callback is not None: - self.status_callback(message) - - 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): - return self._account_name(task) - - def _account_name(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 _status_text(self, task): - if task.status in self.STATUS_TEXT and task.status != "pending": - return self.STATUS_TEXT[task.status] - return self.STAGE_TEXT.get(task.stage, task.stage) - - def _status_color(self, task): - base_color = _status_base_color(getattr(task, "status", None)) - if base_color is not None: - return _qcolor(base_color) - if getattr(task, "stage", None) in {"generated", "applied"}: - return _qcolor(COLOR_SUCCESS) - return _qcolor(COLOR_PENDING) - - def _display_value(self, task, column): - values = [ - self._account_name(task), - task.item_id, - task.old_title or "", - task.new_title or "", - self._status_text(task), - ] - 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.ForegroundRole: - if index.column() == 4: - return self._stage_color(task) - if index.column() == 5: - return self._result_color(task) - 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) - - def _stage_color(self, task): - if getattr(task, "stage", None) == "applied": - return _qcolor(COLOR_SUCCESS) - return _qcolor(COLOR_PENDING) - - def _result_color(self, task): - base_color = _status_base_color(getattr(task, "status", None)) - if base_color is not None: - return _qcolor(base_color) - if getattr(task, "status", None) == "pending": - return _qcolor(COLOR_PENDING) - if getattr(task, "status", None) == "success" and getattr(task, "stage", None) == "generated": - return _qcolor(COLOR_PENDING) - if getattr(task, "stage", None) == "applied" or getattr(task, "status", None) == "success": - return _qcolor(COLOR_SUCCESS) - return _qcolor(COLOR_PENDING) - - class GenerateTab(QWidget): - """Tab 2: prompt area plus generation task filters/list.""" - - STATUS_FILTERS = [ - ("全部状态", "all"), - ("待生成", "to_generate"), - ("已生成", "generated"), - ("失败", "failed"), - ("略过", "skipped"), - ("已更新", "applied"), - ] - - def __init__( - self, - parent=None, - db_path=None, - config=None, - config_path=None, - status_callback=None, - title_prompt_path=None, - cover_prompts_dir=None, - open_accounts_callback=None, - ): - super().__init__(parent) - self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config - self.config_path = ( - config_path - or self.config.get("config_path") - or appconfig.CONFIG_PATH - ) - self.db_path = _database_path(db_path, self.config) - self.status_callback = status_callback - self.open_accounts_callback = open_accounts_callback - self.title_prompt_path = title_prompt_path or prompts.TITLE_PROMPT_PATH - self.cover_prompts_dir = cover_prompts_dir or prompts.COVER_PROMPTS_DIR - self.current_cover_template = None - self.generate_worker = None - self.generate_thread = None - - self.title_prompt_edit = QPlainTextEdit() - self.title_prompt_edit.setObjectName("titlePromptEdit") - self.title_prompt_edit.setPlaceholderText("标题提示词") - self.title_prompt_edit.setPlainText( - prompts.load_title_prompt(self.title_prompt_path) - ) - self.save_title_button = QPushButton("保存标题提示词") - self.cover_prompt_edit = QPlainTextEdit() - self.cover_prompt_edit.setObjectName("coverPromptEdit") - self.cover_prompt_edit.setPlaceholderText("封面提示词") - self.cover_template_combo = QComboBox() - self.cover_template_combo.setObjectName("coverTemplateCombo") - self.new_cover_template_button = QPushButton("新建") - self.save_cover_template_button = QPushButton("保存") - self.cover_template_actions_button = QPushButton("模板操作") - self.cover_template_actions_button.setObjectName("coverTemplateActionsButton") - self.cover_template_actions_menu = QMenu(self) - self.save_cover_template_as_action = self.cover_template_actions_menu.addAction("另存为") - self.save_cover_template_as_action.setObjectName("saveCoverTemplateAsAction") - self.rename_cover_template_action = self.cover_template_actions_menu.addAction("重命名") - self.rename_cover_template_action.setObjectName("renameCoverTemplateAction") - self.delete_cover_template_action = self.cover_template_actions_menu.addAction("删除") - self.delete_cover_template_action.setObjectName("deleteCoverTemplateAction") - self.cover_template_actions_button.setMenu(self.cover_template_actions_menu) - self.insert_title_button = QPushButton("插入标题") - self.preview_prompt_button = QPushButton("预览") - self.generate_button = QPushButton("开始生成") - self.stop_generate_button = QPushButton("停止") - self.reset_generate_button = QPushButton("重置生成结果") - self.reset_generate_button.setObjectName("resetGenerateButton") - self.stop_generate_button.setEnabled(False) - ai_settings = appconfig.ai_config(self.config) - self.generate_cover_checkbox = QCheckBox("生成封面图片(成本较高)") - self.generate_cover_checkbox.setObjectName("generateCoverCheckbox") - self.generate_cover_checkbox.setToolTip("关闭后只生成标题并保存为可更新,不调用图片模型") - self.generate_cover_checkbox.setChecked(bool(ai_settings.get("generate_cover", False))) - self.progress_label = QLabel("进度:标题0/0 · 图片0/0 · 失败0") - self.title_progress_label = QLabel("标题 0/0") - self.title_progress_label.setObjectName("generateTitleProgressLabel") - self.title_progress_bar = QProgressBar() - self.title_progress_bar.setObjectName("generateTitleProgressBar") - self.title_progress_bar.setTextVisible(False) - self.title_progress_bar.setRange(0, 1) - self.title_progress_bar.setValue(0) - self.cover_progress_label = QLabel("图片 0/0") - self.cover_progress_label.setObjectName("generateCoverProgressLabel") - self.cover_progress_bar = QProgressBar() - self.cover_progress_bar.setObjectName("generateCoverProgressBar") - self.cover_progress_bar.setTextVisible(False) - self.cover_progress_bar.setRange(0, 1) - self.cover_progress_bar.setValue(0) - self.failed_progress_label = QLabel("失败 0") - self.failed_progress_label.setObjectName("generateFailedProgressLabel") - - left_panel = QWidget() - left_layout = QVBoxLayout(left_panel) - left_layout.setContentsMargins(0, 0, 12, 0) - left_layout.addWidget(QLabel("标题提示词")) - left_layout.addWidget(self.title_prompt_edit, 1) - left_layout.addWidget(self.save_title_button) - left_layout.addWidget(QLabel("封面提示词")) - left_layout.addWidget(self.cover_template_combo) - cover_template_layout = QHBoxLayout() - cover_template_layout.addWidget(self.new_cover_template_button) - cover_template_layout.addWidget(self.save_cover_template_button) - cover_template_layout.addWidget(self.cover_template_actions_button) - cover_template_layout.addStretch(1) - left_layout.addLayout(cover_template_layout) - left_layout.addWidget(self.cover_prompt_edit, 2) - cover_action_layout = QHBoxLayout() - cover_action_layout.addWidget(self.insert_title_button) - cover_action_layout.addWidget(self.preview_prompt_button) - left_layout.addLayout(cover_action_layout) - - self.batch_filter = QComboBox() - self.batch_filter.setObjectName("batchFilter") - self.shop_filter = QComboBox() - self.shop_filter.setObjectName("shopFilter") - self.item_filter = QLineEdit() - self.item_filter.setObjectName("generateItemFilter") - self.item_filter.setPlaceholderText("商品ID") - self.status_filter = QComboBox() - self.status_filter.setObjectName("statusFilter") - 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("商品ID")) - filter_layout.addWidget(self.item_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.batch_progress_label = _build_batch_progress_overview("generateBatchProgressOverview") - ( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - ) = _build_empty_state_card("generateEmptyStateCard") - if self.open_accounts_callback is not None: - self.empty_state_button.clicked.connect(self.open_accounts_callback) - self.task_table = QTableView() - self.model = GenerateTaskTableModel(self.task_table, db_path=self.db_path, status_callback=self._set_status) - self.task_table.setModel(self.model) - self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.task_table.setSelectionMode(QAbstractItemView.SingleSelection) - self.task_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed) - self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - self.task_table.verticalHeader().setVisible(False) - - self.run_log_view = QPlainTextEdit() - self.run_log_view.setObjectName("generateRunLogView") - self.run_log_view.setReadOnly(True) - self.run_log_view.setMaximumHeight(128) - self.run_log_view.setPlaceholderText("AI生成运行日志") - - right_panel = QWidget() - right_layout = QVBoxLayout(right_panel) - right_layout.setContentsMargins(12, 0, 0, 0) - right_layout.addLayout(filter_layout) - right_layout.addWidget(self.summary_label) - right_layout.addWidget(self.batch_progress_label) - right_layout.addWidget(self.empty_state_card) - right_layout.addWidget(self.task_table, 1) - right_layout.addWidget(QLabel("AI生成运行日志")) - right_layout.addWidget(self.run_log_view) - - self.splitter = QSplitter(Qt.Horizontal) - self.splitter.addWidget(left_panel) - self.splitter.addWidget(right_panel) - self.splitter.setStretchFactor(0, 1) - self.splitter.setStretchFactor(1, 3) - self.splitter.setSizes([280, 860]) - - title_progress_layout = QHBoxLayout() - title_progress_layout.addWidget(self.title_progress_label) - title_progress_layout.addWidget(self.title_progress_bar, 1) - cover_progress_layout = QHBoxLayout() - cover_progress_layout.addWidget(self.cover_progress_label) - cover_progress_layout.addWidget(self.cover_progress_bar, 1) - cover_progress_layout.addWidget(self.failed_progress_label) - progress_layout = QVBoxLayout() - progress_layout.addLayout(title_progress_layout) - progress_layout.addLayout(cover_progress_layout) - - button_layout = QHBoxLayout() - button_layout.addStretch(1) - button_layout.addWidget(self.generate_button) - button_layout.addWidget(self.stop_generate_button) - button_layout.addWidget(self.reset_generate_button) - - bottom_layout = QHBoxLayout() - bottom_layout.addWidget(self.generate_cover_checkbox) - bottom_layout.addLayout(progress_layout, 1) - bottom_layout.addLayout(button_layout) - - layout = QVBoxLayout(self) - layout.setContentsMargins(18, 18, 18, 18) - layout.addWidget(self.splitter, 1) - layout.addLayout(bottom_layout) - - self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) - self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) - self.item_filter.textChanged.connect(self.refresh_tasks) - self.status_filter.currentIndexChanged.connect(self.refresh_tasks) - self.refresh_button.clicked.connect(self.refresh_tasks) - self.save_title_button.clicked.connect(self.save_title_prompt) - self.cover_template_combo.currentIndexChanged.connect(self.load_selected_cover_template) - self.new_cover_template_button.clicked.connect(self.new_cover_template) - self.save_cover_template_button.clicked.connect(self.save_cover_template) - self.save_cover_template_as_action.triggered.connect(self.save_cover_template_as) - self.rename_cover_template_action.triggered.connect(self.rename_cover_template) - self.delete_cover_template_action.triggered.connect(self.delete_cover_template) - self.insert_title_button.clicked.connect(self.insert_title_placeholder) - self.preview_prompt_button.clicked.connect(self.preview_cover_prompt) - self.generate_cover_checkbox.toggled.connect(self._on_generate_cover_toggled) - self.generate_button.clicked.connect(self.start_generate) - self.stop_generate_button.clicked.connect(self.stop_generate) - self.reset_generate_button.clicked.connect(self.reset_generated_result) - self.task_table.doubleClicked.connect(self.show_task_images) - - self.refresh_cover_templates() - self.refresh_tasks() - self._load_latest_generate_run_log() - - def _set_status(self, message): - if self.status_callback is not None: - self.status_callback(message) - - def _on_generate_log(self, message): - self._append_generate_log(message) - self._set_status(message) - - def _append_generate_log(self, message): - self.run_log_view.appendPlainText(str(message)) - scroll_bar = self.run_log_view.verticalScrollBar() - scroll_bar.setValue(scroll_bar.maximum()) - - def _on_generate_cover_toggled(self, checked): - previous = bool(appconfig.ai_config(self.config).get("generate_cover", False)) - if self._save_generate_cover_setting(show_status=True): - return - self.generate_cover_checkbox.blockSignals(True) - self.generate_cover_checkbox.setChecked(previous) - self.generate_cover_checkbox.blockSignals(False) - - def _save_generate_cover_setting(self, show_status=True): - generate_cover = bool(self.generate_cover_checkbox.isChecked()) - ai_settings = appconfig.ai_config(self.config) - ai_settings["generate_cover"] = generate_cover - payload = { - key: value - for key, value in self.config.items() - if key not in {"config_path", "ai_models_path"} - } - payload["ai"] = ai_settings - try: - saved = appconfig.save_config(payload, path=self.config_path) - except Exception as exc: - self._set_status(f"生成封面开关保存失败:{exc}") - return False - internal = { - key: value - for key, value in self.config.items() - if key in {"config_path", "ai_models_path"} - } - self.config.clear() - self.config.update(saved) - self.config.update(internal) - if self.config_path != appconfig.CONFIG_PATH: - self.config["config_path"] = self.config_path - if show_status: - mode = "会同时生成封面图片" if generate_cover else "只生成标题,不生成图片" - self._set_status(f"AI生成设置已保存:{mode}") - return True - - def _load_latest_generate_run_log(self): - try: - logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path) - if not logs: - return - events = db.list_run_log_events(logs[0].id, limit=40, path=self.db_path) - except Exception: - return - lines = [ - f"{event.created_at} [{event.level}] {event.message}" - for event in reversed(events) - ] - self.run_log_view.setPlainText("\n".join(lines)) - scroll_bar = self.run_log_view.verticalScrollBar() - scroll_bar.setValue(scroll_bar.maximum()) - - def save_title_prompt(self, checked=False): - try: - prompts.save_title_prompt( - self.title_prompt_edit.toPlainText(), - self.title_prompt_path, - ) - except Exception as exc: - self._show_prompt_error(exc) - return - self._set_status("标题提示词已保存") - - def refresh_cover_templates(self, selected=None): - try: - template_names = prompts.list_cover_templates(self.cover_prompts_dir) - except Exception as exc: - template_names = [] - self._show_prompt_error(exc) - current = selected if selected is not None else self.current_cover_template - self.cover_template_combo.blockSignals(True) - self.cover_template_combo.clear() - if template_names: - for name in template_names: - self.cover_template_combo.addItem(name, name) - index = self.cover_template_combo.findData(current) - self.cover_template_combo.setCurrentIndex(index if index >= 0 else 0) - else: - self.cover_template_combo.addItem("默认", None) - self.cover_template_combo.setCurrentIndex(0) - self.cover_template_combo.blockSignals(False) - self.load_selected_cover_template() - - def load_selected_cover_template(self, index=None): - name = self.cover_template_combo.currentData() - self.current_cover_template = name - if name is None: - self.cover_prompt_edit.setPlainText("") - return - try: - self.cover_prompt_edit.setPlainText( - prompts.load_cover_template(name, self.cover_prompts_dir) - ) - except Exception as exc: - self.cover_prompt_edit.setPlainText("") - self._show_prompt_error(exc) - - def new_cover_template(self, checked=False): - name = self._ask_template_name("新建封面提示词模板") - if not name: - return - try: - prompts.save_cover_template(name, "", self.cover_prompts_dir) - except Exception as exc: - self._show_prompt_error(exc) - return - self.refresh_cover_templates(selected=name) - self._set_status(f"封面提示词模板已新建:{name}") - - def save_cover_template(self, checked=False): - name = self.current_cover_template - if name is None: - self.save_cover_template_as() - return - try: - prompts.save_cover_template( - name, - self.cover_prompt_edit.toPlainText(), - self.cover_prompts_dir, - ) - except Exception as exc: - self._show_prompt_error(exc) - return - self._set_status(f"封面提示词模板已保存:{name}") - - def save_cover_template_as(self, checked=False): - name = self._ask_template_name("另存封面提示词模板") - if not name: - return - try: - prompts.save_cover_template( - name, - self.cover_prompt_edit.toPlainText(), - self.cover_prompts_dir, - ) - except Exception as exc: - self._show_prompt_error(exc) - return - self.refresh_cover_templates(selected=name) - self._set_status(f"封面提示词模板已另存为:{name}") - - def rename_cover_template(self, checked=False): - old_name = self.current_cover_template - if old_name is None: - self._set_status("没有可重命名的封面提示词模板") - return - new_name = self._ask_template_name("重命名封面提示词模板", text=old_name) - if not new_name or new_name == old_name: - return - try: - prompts.rename_cover_template(old_name, new_name, self.cover_prompts_dir) - except Exception as exc: - self._show_prompt_error(exc) - return - self.refresh_cover_templates(selected=new_name) - self._set_status(f"封面提示词模板已重命名:{new_name}") - - def delete_cover_template(self, checked=False): - name = self.current_cover_template - if name is None: - self._set_status("没有可删除的封面提示词模板") - return - choice = QMessageBox.question( - self, - "删除封面提示词模板", - f"确定删除「{name}」吗?", - ) - if choice != QMessageBox.Yes: - return - try: - prompts.delete_cover_template(name, self.cover_prompts_dir) - except Exception as exc: - self._show_prompt_error(exc) - return - self.refresh_cover_templates() - self._set_status(f"封面提示词模板已删除:{name}") - - def insert_title_placeholder(self, checked=False): - self.cover_prompt_edit.insertPlainText("{新标题}") - - def preview_cover_prompt(self, checked=False): - task = self._selected_task() - if task is None: - self._set_status("没有可预览的任务") - return - rendered = prompts.render_prompt( - self.cover_prompt_edit.toPlainText(), - self._prompt_context(task), - ) - QMessageBox.information(self, "封面提示词预览", rendered) - self._set_status("封面提示词预览已生成") - - def start_generate(self, checked=False): - if self.generate_thread is not None: - self._set_status("AI 生成正在进行...") - return - if not self._save_generate_cover_setting(show_status=False): - return - generate_cover = bool(self.generate_cover_checkbox.isChecked()) - tasks = [ - task for task in self.model.tasks - if getattr(task, "stage", None) == "collected" - ] - if not tasks: - self._set_status("当前筛选结果没有可生成任务") - return - prompt_values = { - "title": self.title_prompt_edit.toPlainText(), - "cover": self.cover_prompt_edit.toPlainText(), - } - worker = GenerateWorker( - tasks, - prompt_values, - db_path=self.db_path, - config=self.config, - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - worker.progress.connect(self._on_generate_progress) - worker.row_updated.connect(self._on_generate_row_updated) - worker.log.connect(self._on_generate_log) - worker.failed.connect(self._on_generate_failed) - worker.finished.connect(self._on_generate_finished) - worker.cancelled.connect(self._on_generate_cancelled) - self.run_log_view.clear() - thread = run_worker(worker, thread_name="GenerateWorker", start=False) - thread.finished.connect(lambda: self._forget_generate_thread(thread)) - self.generate_worker = worker - self.generate_thread = thread - self._set_generate_running(True) - self._update_generate_progress( - { - "total": len(tasks), - "title_done": 0, - "cover_done": 0, - "cover_total": len(tasks) if generate_cover else 0, - "generated_done": 0, - "failed": 0, - "generate_cover": generate_cover, - } - ) - self._set_status(f"开始 AI 生成:{len(tasks)} 条") - thread.start() - - def stop_generate(self, checked=False): - if self.generate_worker is not None: - self.generate_worker.cancel() - self._append_generate_log("[停止] 已收到停止请求,当前正在运行的任务结束后停止") - self._set_status("正在停止 AI 生成...") - - def reset_generated_result(self, checked=False): - if self.generate_thread is not None: - self._set_status("AI 生成正在进行,不能重置") - return - task = self._selected_task() - if task is None: - self._set_status("请选择要重置生成结果的任务") - return - has_ai_result = bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) - if not has_ai_result and getattr(task, "stage", None) not in {"generated", "applied"}: - self._set_status("选中任务没有可重置的生成结果") - return - lines = [ - "确定重置当前选中任务的本地生成结果吗?", - "", - f"商品ID:{task.item_id}", - f"店铺:{self.model.account_name_for(task)}", - "", - "将清空新标题、新封面路径和错误信息,并退回到已采集状态。", - "默认不删除本地新封面文件,不触碰 Shopee,也不会自动回写 Excel。", - ] - if getattr(task, "new_cover_path", None): - lines.append(f"本地新封面文件保留:{task.new_cover_path}") - if getattr(task, "committed", 0): - lines.extend([ - "", - "注意:该记录曾经提交过线上。本地重置不会回滚 Shopee,后续重新生成/更新可能再次提交线上。", - ]) - answer = QMessageBox.question( - self, - "重置生成结果", - "\n".join(lines), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if answer != QMessageBox.Yes: - self._set_status("已取消重置生成结果") - return - try: - db.reset_generated(task.id, path=self.db_path) - message = ( - "action=reset_generated step=db_write result=success " - f"detail=清空AI生成结果 task_id={task.id}" - ) - run_id = _write_reset_run_log(self.db_path, task, "reset_generated", message) - except Exception as exc: - QMessageBox.warning(self, "重置生成结果", str(exc)) - self._set_status(f"重置生成结果失败:{exc}") - return - self.refresh_tasks() - self._append_generate_log(message) - self._set_status( - f"已重置生成结果:商品 {task.item_id},run_id={run_id}" - ) - - def show_task_images(self, index): - if index.isValid() and index.column() == 3: - return - task = self.model.task_at(index.row()) if index.isValid() else self._selected_task() - if task is None: - self._set_status("没有可预览的任务") - return - dialog = QDialog(self) - dialog.setWindowTitle(f"封面对照:{task.item_id}") - layout = QVBoxLayout(dialog) - images_layout = QHBoxLayout() - images_layout.addWidget(self._image_panel("旧封面", task.old_cover_path)) - images_layout.addWidget(self._image_panel("新封面", task.new_cover_path)) - layout.addLayout(images_layout) - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(dialog.reject) - layout.addWidget(buttons) - dialog.resize(720, 420) - dialog.exec() - - def _image_panel(self, title, path): - panel = QWidget() - layout = QVBoxLayout(panel) - layout.addWidget(QLabel(title)) - image_label = QLabel() - image_label.setAlignment(Qt.AlignCenter) - image_label.setMinimumSize(260, 260) - image_label.setWordWrap(True) - if path and os.path.exists(str(path)): - pixmap = QPixmap(str(path)) - if not pixmap.isNull(): - image_label.setPixmap( - pixmap.scaled( - 260, - 260, - Qt.KeepAspectRatio, - Qt.SmoothTransformation, - ) - ) - else: - image_label.setText(f"图片无法读取\n{path}") - else: - image_label.setText(f"无图片\n{path or ''}".strip()) - layout.addWidget(image_label, 1) - return panel - - def _set_generate_running(self, running): - self.generate_button.setEnabled(not running) - self.stop_generate_button.setEnabled(running) - self.reset_generate_button.setEnabled(not running) - self.refresh_button.setEnabled(not running) - self.batch_filter.setEnabled(not running) - self.shop_filter.setEnabled(not running) - self.item_filter.setEnabled(not running) - self.status_filter.setEnabled(not running) - self.save_title_button.setEnabled(not running) - self.new_cover_template_button.setEnabled(not running) - self.save_cover_template_button.setEnabled(not running) - self.cover_template_actions_button.setEnabled(not running) - self.save_cover_template_as_action.setEnabled(not running) - self.rename_cover_template_action.setEnabled(not running) - self.delete_cover_template_action.setEnabled(not running) - self.generate_cover_checkbox.setEnabled(not running) - - def _forget_generate_thread(self, thread): - if self.generate_thread is thread: - self.generate_thread = None - self.generate_worker = None - - def _on_generate_progress(self, payload): - self._update_generate_progress(payload) - self._set_status("生成进度:" + self._generate_progress_text(payload)) - - def _on_generate_row_updated(self, task_id, fields): - self.refresh_tasks() - - def _on_generate_failed(self, task_id, error): - self._set_status(f"AI 生成失败:{error}") - - def _on_generate_finished(self, payload): - self._set_generate_running(False) - self.refresh_tasks() - self._load_latest_generate_run_log() - self._update_generate_progress(payload) - if payload.get("error"): - self._set_status(f"AI 生成失败:{payload.get('error')}") - return - self._set_status("AI 生成完成:" + self._generate_progress_text(payload)) - - def _on_generate_cancelled(self, payload): - self._set_generate_running(False) - self.refresh_tasks() - self._load_latest_generate_run_log() - self._update_generate_progress(payload) - self._set_status("AI 生成已停止:" + self._generate_progress_text(payload)) - - def _update_generate_progress(self, payload): - total = max(0, int(payload.get("total", 0) or 0)) - title_done = max(0, int(payload.get("title_done", 0) or 0)) - cover_done = max(0, int(payload.get("cover_done", 0) or 0)) - cover_total = self._cover_total_for_progress(payload, total) - failed = max(0, int(payload.get("failed", 0) or 0)) - self.progress_label.setText("进度:" + self._generate_progress_text(payload)) - self.title_progress_label.setText(f"标题 {title_done}/{total}") - self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}") - self.failed_progress_label.setText(f"失败 {failed}") - self._set_progress_bar(self.title_progress_bar, title_done, total) - self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total) - - def _set_progress_bar(self, bar, done, total): - maximum = max(1, int(total or 0)) - value = min(max(0, int(done or 0)), maximum) - bar.setRange(0, maximum) - bar.setValue(value) - - def _cover_total_for_progress(self, payload, total): - cover_total = payload.get("cover_total") - if cover_total is None: - cover_total = total if payload.get("generate_cover", True) else 0 - return max(0, int(cover_total or 0)) - - def _generate_progress_text(self, payload): - total = max(0, int(payload.get("total", 0) or 0)) - cover_total = self._cover_total_for_progress(payload, total) - return "标题{title}/{total} · 图片{cover}/{cover_total} · 失败{failed}".format( - title=payload.get("title_done", 0), - cover=payload.get("cover_done", 0), - cover_total=cover_total, - total=payload.get("total", 0), - failed=payload.get("failed", 0), - ) - - def _selected_task(self): - index = self.task_table.currentIndex() - if index.isValid(): - return self.model.task_at(index.row()) - if self.model.rowCount() > 0: - return self.model.task_at(0) - return None - - def _prompt_context(self, task): - return { - "old_title": task.old_title, - "new_title": task.new_title, - "item_id": task.item_id, - "account_name": self.model.account_name_for(task), - "alias": task.alias, - } - - def _ask_template_name(self, title, text=""): - value, ok = QInputDialog.getText( - self, - title, - "模板名", - QLineEdit.Normal, - text, - ) - if not ok: - return None - return str(value).strip() - - def _show_prompt_error(self, error): - message = str(error) - QMessageBox.warning(self, "提示词管理", message) - self._set_status(message) - - def refresh_tasks(self, checked=False): - try: - db.init_db(self.db_path) - batches = db.list_batches(path=self.db_path) - accounts_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 "all" - item_query = self.item_filter.text().strip() - self._populate_batch_filter(batches, selected_batch) - selected_batch = self.batch_filter.currentData() - batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path) - self._populate_shop_filter(batch_tasks, accounts_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_item(task, item_query) - and self._matches_status(task, selected_status) - ] - except Exception as exc: - self.model.set_tasks([], []) - self.summary_label.setText("任务读取失败") - _set_batch_progress_overview(self.batch_progress_label, []) - _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) - self._set_status(f"AI 生成任务读取失败:{exc}") - return - self.model.set_tasks(filtered_tasks, accounts_rows) - self.summary_label.setText( - f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条" - ) - _set_batch_progress_overview(self.batch_progress_label, batch_tasks) - self._update_empty_state(batch_tasks, filtered_tasks, accounts_rows) - - def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows): - if not account_rows: - _set_empty_state( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - "第一步:前往『④账号管理』配置并登录账号,再回到②生成标题和封面。", - self.open_accounts_callback is not None, - ) - return - if not batch_tasks: - _set_empty_state( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - "还没有可生成任务。请先在①导入采集完成旧标题和旧封面采集。", - ) - return - if not filtered_tasks: - _set_empty_state( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - "当前筛选没有匹配的生成任务,请调整批次、店铺、商品ID或状态筛选。", - ) - return - _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) - - 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 _matches_shop(self, task, selected_shop): - return selected_shop is None or str(task.alias).strip() == selected_shop - - def _matches_item(self, task, item_query): - if not item_query: - return True - return item_query in str(getattr(task, "item_id", "")) - - def _matches_status(self, task, selected_status): - if selected_status in (None, "all"): - return True - if selected_status == "to_generate": - return task.stage == "collected" and task.status in {"success", "pending"} - if selected_status == "generated": - return task.stage == "generated" - if selected_status == "applied": - return task.stage == "applied" - if selected_status == "failed": - return task.status == "failed" - if selected_status == "skipped": - return task.status == "skipped" - 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, - open_accounts_callback=None, - open_settings_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.open_settings_callback = open_settings_callback - self.apply_worker = None - self.apply_thread = None - self.result_write_back_worker = None - self.result_write_back_thread = None - self.last_apply_summary = None - - self.batch_filter = QComboBox() - self.batch_filter.setObjectName("applyBatchFilter") - self.shop_filter = QComboBox() - self.shop_filter.setObjectName("applyShopFilter") - self.item_filter = QLineEdit() - self.item_filter.setObjectName("applyItemFilter") - self.item_filter.setPlaceholderText("商品ID") - 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("商品ID")) - filter_layout.addWidget(self.item_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.batch_progress_label = _build_batch_progress_overview("applyBatchProgressOverview") - self.risk_label = QLabel("可先点击「检查本轮更新」确认当前筛选范围;点击「开始更新」后会再次确认并按批提交线上。") - ( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - ) = _build_empty_state_card("applyEmptyStateCard") - if self.open_accounts_callback is not None: - self.empty_state_button.clicked.connect(self.open_accounts_callback) - 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.setContextMenuPolicy(Qt.CustomContextMenu) - self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - self.task_table.verticalHeader().setVisible(False) - self.run_log_view = QPlainTextEdit() - self.run_log_view.setObjectName("applyRunLogView") - self.run_log_view.setReadOnly(True) - self.run_log_view.setMaximumHeight(128) - self.run_log_view.setPlaceholderText("运行日志") - - self.preview_update_button = QPushButton("检查本轮更新") - self.preview_update_button.setObjectName("previewUpdateButton") - self.start_update_button = QPushButton("开始更新") - self.start_update_button.setObjectName("startUpdateButton") - self.start_update_button.setMinimumWidth(118) - self.start_update_button.setStyleSheet( - "QPushButton#startUpdateButton { " - "font-weight: 600; padding: 6px 16px; " - f"color: {COLOR_WARNING}; border: 1px solid {COLOR_WARNING}; " - "border-radius: 4px; }" - ) - self.stop_update_button = QPushButton("停止") - self.reset_update_button = QPushButton("重置更新状态") - self.reset_update_button.setObjectName("resetUpdateButton") - self.reset_update_button.setVisible(False) - 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.preview_update_button) - 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.batch_progress_label) - layout.addWidget(self.empty_state_card) - layout.addWidget(self.task_table, 1) - layout.addWidget(QLabel("运行日志")) - layout.addWidget(self.run_log_view) - layout.addLayout(action_layout) - - self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) - self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) - self.item_filter.textChanged.connect(self.refresh_tasks) - self.status_filter.currentIndexChanged.connect(self.refresh_tasks) - self.refresh_button.clicked.connect(self.refresh_tasks) - self.preview_update_button.clicked.connect(self.preview_update) - self.start_update_button.clicked.connect(self.start_update) - self.stop_update_button.clicked.connect(self.stop_update) - self.reset_update_button.clicked.connect(self.reset_apply_status) - self.task_table.customContextMenuRequested.connect(self.show_task_context_menu) - self.write_back_button.clicked.connect(self.write_back_results) - - self.refresh_tasks() - self._load_latest_run_log() - - 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" - item_query = self.item_filter.text().strip() - self._populate_batch_filter(batches, selected_batch) - selected_batch = self.batch_filter.currentData() - all_batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path) - batch_tasks = [ - task for task in all_batch_tasks - 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_item(task, item_query) - and self._matches_status(task, selected_status) - ] - except Exception as exc: - self.model.set_tasks([], []) - self.summary_label.setText("任务读取失败") - _set_batch_progress_overview(self.batch_progress_label, []) - _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) - 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)} 条" - ) - _set_batch_progress_overview(self.batch_progress_label, all_batch_tasks) - self._update_empty_state(batch_tasks, filtered_tasks, account_rows) - self._update_write_back_button() - - def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows): - if not account_rows: - _set_empty_state( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - "第一步:前往『④账号管理』配置并登录账号,再回到③更新 Shopee。", - self.open_accounts_callback is not None, - ) - return - if not batch_tasks: - _set_empty_state( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - "还没有可更新任务。请先在②AI生成完成新标题或新封面。", - ) - return - if not filtered_tasks: - _set_empty_state( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - "当前筛选没有可更新任务,请调整批次、店铺、商品ID或状态筛选。", - ) - return - _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) - - def start_update(self, checked=False): - self._start_update(dry_run=False) - - def preview_update(self, checked=False): - self._start_update(dry_run=True) - - def _start_update(self, dry_run=False): - if self.apply_thread is not None: - self._set_status("更新正在进行...") - return - tasks = [ - task for task in self.model.tasks - if self._is_actionable_task(task) - ] - if not tasks: - self._set_status("当前筛选结果没有可更新任务") - return - update_cfg = self._shopee_update_config() - dry_run = bool(dry_run) - safety_error = self._update_safety_error(tasks, dry_run=dry_run) - if safety_error: - self._show_update_safety_error(safety_error) - self._set_status(safety_error.replace("\n", " ")) - return - answer = QMessageBox.question( - self, - "确认检查本轮更新" if dry_run else "确认开始更新", - self._confirmation_message(tasks, dry_run=dry_run), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if answer != QMessageBox.Yes: - self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新") - return - batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) - worker = ApplyWorker( - tasks, - db_path=self.db_path, - config=self.config, - close_success_tab=bool(update_cfg.get("close_success_tab", False)), - dry_run=dry_run, - parallel_accounts=bool(update_cfg.get("parallel_accounts", False)), - max_parallel_accounts=max( - 1, - int(update_cfg.get("max_parallel_accounts", 1) or 1), - ), - batch_size=batch_size, - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - worker.progress.connect(self._on_apply_progress) - worker.row_updated.connect(self._on_apply_row_updated) - worker.log.connect(self._on_apply_log) - worker.failed.connect(self._on_apply_failed) - worker.finished.connect(self._on_apply_finished) - worker.cancelled.connect(self._on_apply_cancelled) - thread = run_worker(worker, thread_name="ApplyWorker", start=False) - thread.finished.connect(lambda: self._forget_apply_thread(thread)) - self.apply_worker = worker - self.apply_thread = thread - self._set_apply_running(True) - self.run_log_view.clear() - if dry_run: - self._set_status(f"开始检查本轮更新:{len(tasks)} 条") - else: - self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行") - thread.start() - - def stop_update(self, checked=False): - if self.apply_worker is not None: - self.apply_worker.cancel() - self._set_status("正在停止更新...") - - def write_back_results(self, checked=False): - batch_ids = self._active_batch_ids() - if not batch_ids: - self._set_status("没有可回写结果的批次") - return - self._start_result_write_back(batch_ids, auto=False) - - def show_task_context_menu(self, position): - index = self.task_table.indexAt(position) - if index.isValid(): - self.task_table.setCurrentIndex(index) - menu = QMenu(self) - reset_action = menu.addAction("重置更新状态") - reset_action.setEnabled( - self.apply_thread is None - and self.result_write_back_thread is None - and self._selected_task() is not None - ) - reset_action.triggered.connect(self.reset_apply_status) - menu.exec(self.task_table.viewport().mapToGlobal(position)) - - def reset_apply_status(self, checked=False): - if self.apply_thread is not None or self.result_write_back_thread is not None: - self._set_status("更新或回写正在进行,不能重置") - return - task = self._selected_task() - if task is None: - self._set_status("请选择要重置更新状态的任务") - return - if not (getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)): - self._set_status("选中任务没有新标题或新封面,不能重置为可更新") - return - lines = [ - "确定重置当前选中任务的本地更新状态吗?", - "", - f"商品ID:{task.item_id}", - f"店铺:{self.model.account_name_for(task)}", - "", - "将保留新标题和新封面路径,只把本地状态退回可更新。", - "不会触碰 Shopee,也不会自动回写 Excel。", - ] - if getattr(task, "committed", 0): - lines.extend([ - "", - "注意:该记录已经提交过线上。本地重置不会回滚 Shopee,重复更新会再次提交线上。", - ]) - answer = QMessageBox.question( - self, - "重置更新状态", - "\n".join(lines), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if answer != QMessageBox.Yes: - self._set_status("已取消重置更新状态") - return - try: - db.reset_apply_status(task.id, path=self.db_path) - message = ( - "action=reset_apply_status step=db_write result=success " - f"detail=退回可更新 task_id={task.id}" - ) - run_id = _write_reset_run_log(self.db_path, task, "reset_apply_status", message) - except Exception as exc: - QMessageBox.warning(self, "重置更新状态", str(exc)) - self._set_status(f"重置更新状态失败:{exc}") - return - self.refresh_tasks() - self._append_run_log(message) - self._set_status( - f"已重置更新状态:商品 {task.item_id},run_id={run_id}" - ) - - def _selected_task(self): - index = self.task_table.currentIndex() - if index.isValid(): - return self.model.task_at(index.row()) - if self.model.rowCount() > 0: - return self.model.task_at(0) - return None - - def _is_actionable_task(self, task): - return ( - getattr(task, "stage", None) == "generated" - and getattr(task, "status", None) in {"success", "pending", "failed"} - and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) - ) - - 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 _item_filter_label(self): - return self.item_filter.text().strip() 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_item(self, task, item_query): - if not item_query: - return True - return item_query in str(getattr(task, "item_id", "")) - - 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, dry_run=False): - update_cfg = self._shopee_update_config() - cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许" - close_text = "是" if update_cfg.get("close_success_tab") else "否" - batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) - batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0 - parallel_text = ( - f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号" - if update_cfg.get("parallel_accounts") - else "关闭" - ) - intro = ( - "即将检查当前筛选结果。\n\n" - if dry_run - else "即将按当前筛选结果分批更新 Shopee 线上商品。\n\n" - ) - return ( - intro - + f"批次:{self._batch_filter_label()}\n" - + f"店铺:{self._shop_filter_label()}\n" - + f"商品ID:{self._item_filter_label()}\n" - + f"状态:{self._status_label()}\n" - + f"任务数:{len(tasks)}\n" - + f"预计批次:{batch_count}\n\n" - + "安全设置:" - + f"封面更新={cover_text}," - + f"每批最大更新条数={batch_size}," - + f"成功后关闭新页={close_text}," - + f"多账号并行={parallel_text}\n\n" - + ( - "检查只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态。" - if dry_run - else f"确认后会打开商品编辑页、替换标题/允许时替换封面,并按每批最多 {batch_size} 条点击「更新」提交线上;点击停止后不再开始下一条或下一批。" - ) - ) - - def _update_safety_error(self, tasks, dry_run=False): - update_cfg = self._shopee_update_config() - if dry_run: - return None - if not update_cfg.get("allow_real_submit", False): - return ( - "设置未开启「允许真实提交线上商品」,已阻止本次更新。\n" - "请到⑤设置 > Shopee 更新安全开启该开关后再开始更新。" - ) - if not update_cfg.get("allow_cover_update", False): - cover_tasks = [ - str(getattr(task, "item_id", "")) - for task in tasks - if getattr(task, "new_cover_path", None) - ] - if cover_tasks: - return ( - "设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。\n" - "请到⑤设置 > Shopee 更新安全开启该开关,或先筛掉含新封面的任务。" - ) - return None - - def _show_update_safety_error(self, message): - box = QMessageBox(self) - box.setIcon(QMessageBox.Warning) - box.setWindowTitle("更新安全开关") - box.setText(str(message)) - settings_button = None - if self.open_settings_callback is not None: - settings_button = box.addButton("前往设置", QMessageBox.ActionRole) - box.addButton(QMessageBox.Ok) - box.exec() - if settings_button is not None and box.clickedButton() is settings_button: - self.open_settings_callback() - - def _shopee_update_config(self): - defaults = appconfig.default_config().get("shopee_update", {}) - loaded = self.config.get("shopee_update", {}) - if not isinstance(loaded, dict): - loaded = {} - merged = dict(defaults) - merged.update(loaded) - return merged - - def _set_apply_running(self, running): - self.preview_update_button.setEnabled(not running) - self.start_update_button.setEnabled(not running) - self.stop_update_button.setEnabled(running) - self.reset_update_button.setEnabled(not running) - self.refresh_button.setEnabled(not running) - self.batch_filter.setEnabled(not running) - self.shop_filter.setEnabled(not running) - self.item_filter.setEnabled(not running) - self.status_filter.setEnabled(not running) - self._update_write_back_button() - - def _set_result_write_back_running(self, running): - self.preview_update_button.setEnabled(not running) - self.start_update_button.setEnabled(not running) - self.reset_update_button.setEnabled(not running) - self.refresh_button.setEnabled(not running) - self.batch_filter.setEnabled(not running) - self.shop_filter.setEnabled(not running) - self.item_filter.setEnabled(not running) - self.status_filter.setEnabled(not running) - self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids())) - - def _forget_apply_thread(self, thread): - if self.apply_thread is thread: - self.apply_thread = None - self.apply_worker = None - - def _forget_result_write_back_thread(self, thread): - if self.result_write_back_thread is thread: - self.result_write_back_thread = None - self.result_write_back_worker = None - self._update_write_back_button() - - def _on_apply_progress(self, payload): - self._set_status("更新进度:" + self._apply_progress_text(payload)) - - def _on_apply_log(self, message): - self._append_run_log(message) - self._set_status(message) - - def _on_apply_row_updated(self, task_id, fields): - self.refresh_tasks() - - def _on_apply_failed(self, task_id, error): - self._set_status(f"任务 {task_id} 更新失败:{error}") - - def _on_apply_finished(self, payload): - self._set_apply_running(False) - self.refresh_tasks() - if payload.get("blocked"): - self._show_apply_blocked(payload) - return - self.last_apply_summary = dict(payload) - prefix = "检查本轮更新完成:" if payload.get("dry_run") else "更新完成:" - message = prefix + self._apply_progress_text(payload) - batch_ids = payload.get("batch_ids") or self._active_batch_ids() - if (not payload.get("dry_run")) and payload.get("done", 0) > 0 and batch_ids: - if self._start_result_write_back( - batch_ids, - auto=True, - apply_summary=payload, - ): - self._set_status(f"{message},正在自动回写结果到 Excel...") - return - self._set_status(message) - self._show_apply_summary(payload) - - def _on_apply_cancelled(self, payload): - self._set_apply_running(False) - self.refresh_tasks() - self._set_status("更新已停止:" + self._apply_progress_text(payload)) - - def _apply_progress_text(self, payload): - success_label = "可更新" if payload.get("dry_run") else "成功" - return "完成{done}/{total},{success_label}{applied},略过{skipped},失败{failed}".format( - done=payload.get("done", 0), - total=payload.get("total", 0), - success_label=success_label, - applied=payload.get("applied", 0), - skipped=payload.get("skipped", 0), - failed=payload.get("failed", 0), - ) - - def _append_run_log(self, message): - self.run_log_view.appendPlainText(str(message)) - - def _load_latest_run_log(self): - try: - logs = db.list_run_logs(limit=1, run_type="apply", path=self.db_path) - if not logs: - return - events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path) - except Exception: - return - lines = [ - f"{event.created_at} [{event.level}] {event.message}" - for event in reversed(events) - ] - self.run_log_view.setPlainText("\n".join(lines)) - scroll_bar = self.run_log_view.verticalScrollBar() - scroll_bar.setValue(scroll_bar.maximum()) - - def _show_apply_blocked(self, payload): - lines = ["更新前检查未通过。"] - if payload.get("no_accounts"): - lines.append("当前没有配置账号。") - duplicate_ports = payload.get("duplicate_ports") or [] - if duplicate_ports: - for item in duplicate_ports: - lines.append( - "以下账号调试端口冲突:端口 {port} -> {aliases}".format( - port=item.get("debug_port"), - aliases="、".join(item.get("aliases") or []), - ) - ) - not_running = payload.get("not_running") or [] - if not_running: - lines.append( - "以下账号 Chrome 未启动或调试端口不可访问:" - + "、".join(self._account_label(item) for item in not_running) - ) - logged_out = payload.get("logged_out") or [] - if logged_out: - lines.append( - "以下账号未登录 Shopee:" - + "、".join(self._account_label(item) for item in logged_out) - ) - self._show_account_guide("\n".join(lines)) - - def _show_account_guide(self, message): - full_message = ( - f"{message}\n\n" - "本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n" - "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" - ) - QMessageBox.warning(self, "账号未就绪", full_message) - self._set_status(full_message.replace("\n", " ")) - if self.open_accounts_callback is not None: - self.open_accounts_callback() - - def _account_label(self, item): - if isinstance(item, dict): - name = item.get("account_name") or item.get("alias") or "" - alias = item.get("alias") or "" - reason = item.get("reason") - else: - name = getattr(item, "account_name", "") or getattr(item, "alias", "") - alias = getattr(item, "alias", "") - reason = getattr(item, "reason", None) - label = f"{name}({alias})" if alias and name != alias else (name or alias) - return f"{label}: {reason}" if reason else label - - def _active_batch_ids(self): - selected_batch = self.batch_filter.currentData() - if selected_batch: - return [selected_batch] - batch_ids = [] - for task in self.model.tasks: - batch_id = getattr(task, "batch_id", None) - if batch_id and batch_id not in batch_ids: - batch_ids.append(batch_id) - return batch_ids - - def _update_write_back_button(self): - if getattr(self, "write_back_button", None) is None: - return - enabled = ( - self.apply_thread is None - and self.result_write_back_thread is None - and bool(self._active_batch_ids()) - ) - self.write_back_button.setEnabled(enabled) - - def _start_result_write_back(self, batch_ids, auto=False, apply_summary=None): - if self.result_write_back_thread is not None: - self._set_status("Excel 结果回写正在进行...") - return False - worker = WriteBackWorker( - batch_ids, - db_path=self.db_path, - mode="results", - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - worker.failed.connect( - lambda task_id, error, auto=auto, apply_summary=apply_summary: - self._on_result_write_back_failed( - task_id, - error, - auto=auto, - apply_summary=apply_summary, - ) - ) - worker.finished.connect( - lambda payload, auto=auto, apply_summary=apply_summary: - self._on_result_write_back_finished( - payload, - auto=auto, - apply_summary=apply_summary, - ) - ) - thread = run_worker(worker, thread_name="ResultWriteBackWorker", start=False) - thread.finished.connect(lambda: self._forget_result_write_back_thread(thread)) - self.result_write_back_worker = worker - self.result_write_back_thread = thread - self._set_result_write_back_running(True) - self._set_status("正在自动回写更新结果到 Excel..." if auto else "正在回写更新结果到 Excel...") - thread.start() - return True - - def _on_result_write_back_failed(self, task_id, error, auto=False, apply_summary=None): - message = f"Excel {'自动' if auto else ''}回写更新结果失败:{error}" - if "被占用" in str(error): - message += "\n请关闭原 Excel 后点击「回写结果到 Excel」手动重试;SQLite 已保留更新结果。" - if auto and apply_summary: - message = self._apply_summary_message(apply_summary, error=message) - QMessageBox.warning(self, "回写结果到 Excel", message) - self._set_status(message.replace("\n", " ")) - - def _on_result_write_back_finished(self, payload, auto=False, apply_summary=None): - self._set_result_write_back_running(False) - self.refresh_tasks() - if payload.get("ok") is False: - error = payload.get("error") or "未知错误" - retry_hint = ",可点击「回写结果到 Excel」手动重试" if auto else "" - self._set_status(f"Excel {'自动' if auto else ''}回写更新结果失败:{error}{retry_hint}") - return - self._set_status( - "Excel {prefix}回写更新结果完成:文件{files},行{rows}".format( - prefix="自动" if auto else "", - files=payload.get("files", 0), - rows=payload.get("rows", 0), - ) - ) - if auto and apply_summary: - self._show_apply_summary(apply_summary, write_back_payload=payload) - elif not auto: - QMessageBox.information( - self, - "回写结果到 Excel", - "结果回写完成:文件{files},行{rows}".format( - files=payload.get("files", 0), - rows=payload.get("rows", 0), - ), - ) - - def _show_apply_summary(self, apply_summary, write_back_payload=None): - QMessageBox.information( - self, - "检查本轮更新完成" if apply_summary.get("dry_run") else "更新完成", - self._apply_summary_message(apply_summary, write_back_payload), - ) - - def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None): - dry_run = bool(apply_summary.get("dry_run")) - lines = [ - "检查本轮更新完成,未打开 Shopee、未提交线上、未改任务状态。" - if dry_run - else "更新完成。", - "{success_label}:{applied},失败:{failed},略过:{skipped}".format( - success_label="可更新" if dry_run else "成功", - applied=apply_summary.get("applied", 0), - failed=apply_summary.get("failed", 0), - skipped=apply_summary.get("skipped", 0), - ), - ] - if write_back_payload: - lines.append( - "Excel 回写:文件{files},行{rows}".format( - files=write_back_payload.get("files", 0), - rows=write_back_payload.get("rows", 0), - ) - ) - if error: - lines.append(str(error)) - return "\n".join(lines) - - - class CollectTab(QWidget): - """Tab 1: import Excel files and list imported tasks.""" - - STATUS_FILTERS = [ - ("全部状态", "all"), - ("待采集", "to_collect"), - ("已采集", "collected"), - ("已生成", "generated"), - ("已更新", "applied"), - ("失败", "failed"), - ("略过", "skipped"), - ] - - def __init__( - self, - parent=None, - db_path=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 - self.collect_worker = None - self.collect_thread = None - self.write_back_worker = None - self.write_back_thread = None - self.last_collect_run_id = None - - self.import_button = QPushButton("导入 Excel...") - self.refresh_button = QPushButton("刷新") - self.collect_button = QPushButton("采集旧标题/旧封面") - 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.shop_filter = QComboBox() - self.shop_filter.setObjectName("collectShopFilter") - self.item_filter = QLineEdit() - self.item_filter.setObjectName("collectItemFilter") - self.item_filter.setPlaceholderText("商品ID") - self.status_filter = QComboBox() - self.status_filter.setObjectName("collectStatusFilter") - for label, value in self.STATUS_FILTERS: - self.status_filter.addItem(label, value) - self.delete_batch_button = QPushButton("删除批次") - self.delete_batch_button.setObjectName("deleteBatchButton") - self.delete_batch_button.setStyleSheet(_danger_outline_button_style("deleteBatchButton")) - self.delete_batch_button.setEnabled(False) - - toolbar = QHBoxLayout() - toolbar.addWidget(self.import_button) - toolbar.addWidget(self.refresh_button) - toolbar.addWidget(self.collect_button) - toolbar.addWidget(self.stop_collect_button) - toolbar.addWidget(self.write_back_button) - toolbar.addStretch(1) - - 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("商品ID")) - filter_layout.addWidget(self.item_filter, 1) - filter_layout.addWidget(QLabel("状态")) - filter_layout.addWidget(self.status_filter, 1) - filter_layout.addWidget(self.delete_batch_button) - - self.summary_label = QLabel("未导入任务") - self.summary_label.setTextFormat(Qt.RichText) - self.batch_progress_label = _build_batch_progress_overview("collectBatchProgressOverview") - self.match_detail_label = QLabel("") - self.show_all_button = QPushButton("全部") - self.show_unmatched_button = QPushButton("未匹配(0)") - self.show_unmatched_button.setObjectName("showUnmatchedButton") - - summary_layout = QHBoxLayout() - summary_layout.addWidget(self.summary_label) - summary_layout.addStretch(1) - summary_layout.addWidget(self.show_all_button) - summary_layout.addWidget(self.show_unmatched_button) - - self.table = QTableView() - self.model = TaskTableModel(self.table) - self.table.setModel(self.model) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SingleSelection) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - self.table.verticalHeader().setVisible(False) - - self.run_log_view = QPlainTextEdit() - self.run_log_view.setObjectName("collectRunLogView") - self.run_log_view.setReadOnly(True) - self.run_log_view.setMaximumHeight(128) - self.run_log_view.setPlaceholderText("采集运行日志") - - self.empty_label = QLabel("") - ( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - ) = _build_empty_state_card("collectEmptyStateCard") - if self.open_accounts_callback is not None: - self.empty_state_button.clicked.connect(self.open_accounts_callback) - - layout = QVBoxLayout(self) - layout.setContentsMargins(18, 18, 18, 18) - layout.addLayout(toolbar) - layout.addLayout(filter_layout) - layout.addLayout(summary_layout) - layout.addWidget(self.match_detail_label) - layout.addWidget(self.batch_progress_label) - layout.addWidget(self.empty_state_card) - layout.addWidget(self.table, 1) - layout.addWidget(QLabel("采集运行日志")) - layout.addWidget(self.run_log_view) - layout.addWidget(self.empty_label) - - 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.shop_filter.currentIndexChanged.connect(self.refresh_tasks) - self.item_filter.textChanged.connect(self.refresh_tasks) - self.status_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) - self.show_all_button.clicked.connect(self.show_all_tasks) - self.show_unmatched_button.clicked.connect(self.show_unmatched_tasks) - - self.refresh_tasks() - self._load_latest_collect_run_log() - - def _set_status(self, message): - if self.status_callback is not None: - self.status_callback(message) - - def _on_collect_log(self, message): - self._append_collect_log(message) - self._set_status(message) - - def _append_collect_log(self, message): - self.run_log_view.appendPlainText(str(message)) - - def _load_latest_collect_run_log(self): - try: - logs = db.list_run_logs(limit=1, run_type="collect", path=self.db_path) - if not logs: - return - events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path) - except Exception: - return - lines = [ - f"{event.created_at} [{event.level}] {event.message}" - for event in reversed(events) - ] - self.run_log_view.setPlainText("\n".join(lines)) - scroll_bar = self.run_log_view.verticalScrollBar() - scroll_bar.setValue(scroll_bar.maximum()) - - def _log_collect_run_event(self, run_id, message, level="info"): - safe_message = diagnostics.redact_log_text(message) - try: - db.add_run_log_event(run_id, safe_message, level=level, path=self.db_path) - except Exception: - return - self._append_collect_log(safe_message) - - def _show_error(self, message): - QMessageBox.warning(self, "导入采集", str(message)) - self._set_status(str(message)) - - def _show_account_guide(self, message): - full_message = ( - f"{message}\n\n" - "本轮采集已中止,不会自动打开账号 Chrome。\n" - "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" - ) - QMessageBox.warning(self, "账号未就绪", full_message) - self._set_status(full_message.replace("\n", " ")) - if self.open_accounts_callback is not None: - self.open_accounts_callback() - - def _choose_excel_files(self): - files, _selected_filter = QFileDialog.getOpenFileNames( - self, - "选择 Excel 文件", - "", - "Excel 文件 (*.xlsx *.xlsm)", - ) - return files - - def import_excel(self, checked=False): - file_paths = self._choose_excel_files() - if not file_paths: - return - run_id = _safe_create_run_log( - "import", - db_path=self.db_path, - total=len(file_paths), - options={"files": file_paths}, - ) - started = time.monotonic() - _safe_add_run_log_event( - run_id, - f"step=select_files result=success detail=选择 Excel 文件 {len(file_paths)} 个", - db_path=self.db_path, - ) - try: - _safe_add_run_log_event( - run_id, - "step=parse_file result=start detail=开始解析 Excel 并写入 SQLite", - db_path=self.db_path, - ) - result = excel.import_tasks(file_paths, path=self.db_path) - except Exception as exc: - elapsed_ms = _elapsed_ms(started) - safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - _safe_add_run_log_event( - run_id, - f"step=import result=failed detail={safe_error} elapsed_ms={elapsed_ms}", - db_path=self.db_path, - level="error", - ) - _safe_write_diagnostic_log( - "Excel导入失败", - level="ERROR", - step="import", - elapsed_ms=elapsed_ms, - payload={"files": file_paths, "error": safe_error}, - exc=exc, - log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - _safe_finish_run_log( - run_id, - db_path=self.db_path, - status="failed", - done=0, - failed_count=1, - summary_json={"ok": False, "error": safe_error, "elapsed_ms": elapsed_ms}, - ) - self._show_error(safe_error) - return - elapsed_ms = _elapsed_ms(started) - self.has_import_result = True - self.last_import_stats = result.get("stats") or {} - self.current_batch_id = result.get("batch_id") - file_errors = self.last_import_stats.get("file_errors") or [] - row_errors = self.last_import_stats.get("row_errors") or [] - for item in file_errors: - missing = ",".join(item.get("missing_columns") or []) - detail = "file={file} sheet={sheet} error={error}{missing}".format( - file=os.path.basename(str(item.get("file") or "")), - sheet=item.get("sheet") or "", - error=item.get("error") or "", - missing=f" missing={missing}" if missing else "", - ) - _safe_add_run_log_event( - run_id, - f"step=parse_file result=failed detail={detail}", - db_path=self.db_path, - level="error", - ) - for item in row_errors: - detail = "file={file} sheet={sheet} row={row} error={error}".format( - file=os.path.basename(str(item.get("file") or "")), - sheet=item.get("sheet") or "", - row=item.get("row") or "", - error=item.get("error") or "", - ) - _safe_add_run_log_event( - run_id, - f"step=row_validate result=failed detail={detail}", - db_path=self.db_path, - level="warning", - ) - _safe_add_run_log_event( - run_id, - "step=db_insert result=success detail=batch_id={batch_id} files={files} total={total} valid={valid} invalid={invalid} inserted={inserted} elapsed_ms={elapsed_ms}".format( - batch_id=self.current_batch_id or "", - files=self.last_import_stats.get("files", 0), - total=self.last_import_stats.get("total", 0), - valid=self.last_import_stats.get("valid", 0), - invalid=self.last_import_stats.get("invalid", 0), - inserted=self.last_import_stats.get("inserted", 0), - elapsed_ms=elapsed_ms, - ), - db_path=self.db_path, - ) - _safe_finish_run_log( - run_id, - db_path=self.db_path, - status="done", - done=self.last_import_stats.get("files", 0), - success_count=self.last_import_stats.get("inserted", 0), - failed_count=len(file_errors) + len(row_errors), - summary_json={ - "ok": True, - "batch_id": self.current_batch_id, - "stats": self.last_import_stats, - "elapsed_ms": elapsed_ms, - }, - ) - self.refresh_tasks() - self._set_status( - "导入完成:有效{valid},无效{invalid},入库{inserted},未匹配{unmatched}".format( - valid=self.last_import_stats.get("valid", 0), - invalid=self.last_import_stats.get("invalid", 0), - inserted=self.last_import_stats.get("inserted", 0), - unmatched=self.model.unmatched_count(), - ) - ) - - def refresh_tasks(self, checked=False): - try: - db.init_db(self.db_path) - batches = db.list_batches(path=self.db_path) - selected_batch = self.batch_filter.currentData() - selected_shop = self.shop_filter.currentData() - selected_status = self.status_filter.currentData() or "all" - item_query = self.item_filter.text().strip() - 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) - self._populate_shop_filter(task_rows, account_rows, selected_shop) - selected_shop = self.shop_filter.currentData() - filtered_rows = [ - task for task in task_rows - if self._matches_shop(task, selected_shop) - and self._matches_item(task, item_query) - and self._matches_status(task, selected_status, account_rows) - ] - except Exception as exc: - self.model.set_tasks([], []) - self.empty_label.setText("任务读取失败") - _set_batch_progress_overview(self.batch_progress_label, []) - _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) - self._set_status(f"任务读取失败:{exc}") - return - self.model.set_tasks(filtered_rows, account_rows) - self._update_summary(task_rows, account_rows) - _set_batch_progress_overview(self.batch_progress_label, task_rows) - self._update_empty_state(task_rows, account_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 _populate_shop_filter(self, task_rows, account_rows, selected_shop): - aliases = {str(task.alias).strip() for task in task_rows if str(task.alias).strip()} - previous = selected_shop if selected_shop in aliases else None - account_by_alias = { - str(account.alias).strip(): account - for account in account_rows - if str(account.alias).strip() - } - self.shop_filter.blockSignals(True) - self.shop_filter.clear() - self.shop_filter.addItem("全部店铺", None) - for alias in sorted(aliases): - 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 _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 _matches_shop(self, task, selected_shop): - return selected_shop is None or str(task.alias).strip() == selected_shop - - def _matches_item(self, task, item_query): - if not item_query: - return True - return item_query in str(getattr(task, "item_id", "")) - - def _matches_status(self, task, selected_status, account_rows): - if selected_status in (None, "all"): - return True - if selected_status == "to_collect": - return task.stage == "imported" and task.status in {"pending", "success"} - if selected_status in {"collected", "generated", "applied"}: - return task.stage == selected_status - if selected_status == "failed": - return task.status == "failed" - if selected_status == "skipped": - return task.status == "skipped" or self._is_unmatched_task(task, account_rows) - return True - - def _is_unmatched_task(self, task, account_rows): - aliases = { - str(account.alias).strip() - for account in account_rows - if str(account.alias).strip() - } - return str(task.alias).strip() not in aliases - - 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.tasks) - if not tasks: - self._set_status("没有可采集任务") - return - worker = CollectWorker( - tasks, - db_path=self.db_path, - config=self.config, - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - worker.progress.connect(self._on_collect_progress) - worker.row_updated.connect(self._on_collect_row_updated) - worker.log.connect(self._on_collect_log) - worker.failed.connect(self._on_collect_failed) - worker.finished.connect(self._on_collect_finished) - worker.cancelled.connect(self._on_collect_cancelled) - self.run_log_view.clear() - thread = run_worker(worker, thread_name="CollectWorker", start=False) - thread.finished.connect(lambda: self._forget_collect_thread(thread)) - self.collect_worker = worker - self.collect_thread = thread - self._set_collect_running(True) - thread.start() - - def stop_collect(self, checked=False): - if self.collect_worker is not None: - self.collect_worker.cancel() - self._set_status("正在停止采集...") - - def write_back_old_data(self, checked=False): - batch_id = self._active_batch_id() - if not batch_id: - self._set_status("没有可回写批次") - return - self._start_write_back(batch_id) - - def _start_write_back(self, batch_id, auto=False): - if self.write_back_thread is not None: - self._set_status("Excel 回写正在进行...") - return False - worker = WriteBackWorker( - batch_id, - db_path=self.db_path, - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - worker.failed.connect( - lambda task_id, error, auto=auto: self._on_write_back_failed( - task_id, - error, - auto=auto, - ) - ) - worker.finished.connect( - lambda payload, auto=auto: self._on_write_back_finished( - payload, - auto=auto, - ) - ) - thread = run_worker(worker, thread_name="WriteBackWorker", start=False) - thread.finished.connect(lambda: self._forget_write_back_thread(thread)) - self.write_back_worker = worker - self.write_back_thread = thread - self._set_write_back_running(True) - self._set_status("正在自动回写旧数据到 Excel..." if auto else "正在回写旧数据到 Excel...") - thread.start() - return True - - def _active_batch_id(self): - if self.current_batch_id: - return self.current_batch_id - batch_ids = { - task.batch_id - for task in self.model.all_tasks - if getattr(task, "batch_id", None) - } - if len(batch_ids) == 1: - return next(iter(batch_ids)) - return None - - def _set_collect_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.stop_collect_button.setEnabled(running) - self.batch_filter.setEnabled(not running) - self.shop_filter.setEnabled(not running) - self.item_filter.setEnabled(not running) - self.status_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.shop_filter.setEnabled(not running) - self.item_filter.setEnabled(not running) - self.status_filter.setEnabled(not running) - self._update_delete_batch_button() - - def _forget_collect_thread(self, thread): - if self.collect_thread is thread: - self.collect_thread = None - self.collect_worker = None - - def _forget_write_back_thread(self, thread): - if self.write_back_thread is thread: - self.write_back_thread = None - self.write_back_worker = None - - def _on_collect_progress(self, payload): - self._set_status( - "采集进度:{done}/{total},成功{collected},略过{skipped},失败{failed}".format( - done=payload.get("done", 0), - total=payload.get("total", 0), - collected=payload.get("collected", 0), - skipped=payload.get("skipped", 0), - failed=payload.get("failed", 0), - ) - ) - - def _on_collect_row_updated(self, task_id, fields): - self.refresh_tasks() - - def _on_collect_failed(self, task_id, error): - self._set_status(f"任务 {task_id} 采集失败:{error}") - - def _on_collect_finished(self, payload): - self._set_collect_running(False) - self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id - self.refresh_tasks() - self._load_latest_collect_run_log() - if payload.get("blocked"): - self._show_collect_blocked(payload) - return - message = "采集完成:成功{collected},略过{skipped},失败{failed}".format( - collected=payload.get("collected", 0), - skipped=payload.get("skipped", 0), - failed=payload.get("failed", 0), - ) - if payload.get("collected", 0) > 0: - batch_id = self._active_batch_id() - if batch_id and self._start_write_back(batch_id, auto=True): - if self.last_collect_run_id: - self._log_collect_run_event( - self.last_collect_run_id, - "step=excel_write_back result=start detail=采集成功后自动回写旧数据到 Excel", - ) - self._set_status(f"{message},正在自动回写 Excel...") - return - if not batch_id: - self._set_status(f"{message},但没有可回写批次") - return - self._set_status(f"{message},Excel 回写已在进行") - return - self._set_status(message) - - def _show_collect_blocked(self, payload): - lines = ["采集前检查未通过。"] - if payload.get("no_accounts"): - lines.append("当前没有配置账号。") - not_running = payload.get("not_running") or [] - if not_running: - lines.append( - "以下账号 Chrome 未启动或调试端口不可访问:" - + "、".join(self._account_label(item) for item in not_running) - ) - logged_out = payload.get("logged_out") or [] - if logged_out: - lines.append( - "以下账号未登录 Shopee:" - + "、".join(self._account_label(item) for item in logged_out) - ) - self._show_account_guide("\n".join(lines)) - - def _account_label(self, item): - if isinstance(item, dict): - name = item.get("account_name") or item.get("alias") or "" - alias = item.get("alias") or "" - reason = item.get("reason") - else: - name = getattr(item, "account_name", "") or getattr(item, "alias", "") - alias = getattr(item, "alias", "") - reason = getattr(item, "reason", None) - label = f"{name}({alias})" if alias and name != alias else (name or alias) - return f"{label}: {reason}" if reason else label - - def _on_collect_cancelled(self, payload): - self._set_collect_running(False) - self.refresh_tasks() - self._set_status( - "采集已停止:完成{done}/{total}".format( - done=payload.get("done", 0), - total=payload.get("total", 0), - ) - ) - - def _on_write_back_failed(self, task_id, error, auto=False): - message = f"Excel {'自动' if auto else ''}回写失败:{error}" - if "被占用" in str(error): - if auto: - message += "\n请关闭原 Excel 后点击「回写旧数据到 Excel」手动重试;SQLite 已保留采集结果,也可另存副本。" - else: - message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。" - QMessageBox.warning(self, "回写旧数据", message) - self._set_status(message.replace("\n", " ")) - if auto and self.last_collect_run_id: - self._log_collect_run_event( - self.last_collect_run_id, - f"step=excel_write_back result=failed detail={error}", - level="error", - ) - - def _on_write_back_finished(self, payload, auto=False): - self._set_write_back_running(False) - if payload.get("ok") is False: - error = payload.get("error") or "未知错误" - retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else "" - self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}") - if auto and self.last_collect_run_id: - self._log_collect_run_event( - self.last_collect_run_id, - f"step=excel_write_back result=failed detail={error}", - level="error", - ) - return - self.refresh_tasks() - self._set_status( - "Excel {prefix}回写完成:文件{files},行{rows}".format( - prefix="自动" if auto else "", - files=payload.get("files", 0), - rows=payload.get("rows", 0), - ) - ) - if auto and self.last_collect_run_id: - self._log_collect_run_event( - self.last_collect_run_id, - "step=excel_write_back result=success detail=旧数据已回写 Excel", - ) - - def show_all_tasks(self, checked=False): - self.model.set_filter_mode("all") - self._update_empty_label(len(self.model.all_tasks)) - - def show_unmatched_tasks(self, checked=False): - self.model.set_filter_mode("unmatched") - self._update_empty_label(len(self.model.all_tasks)) - - def _update_summary(self, task_rows, account_rows): - stats = self.last_import_stats or {} - unmatched = self._unmatched_count(task_rows, account_rows) - matched = len(task_rows) - unmatched - files = stats.get("files", 0 if not task_rows else 1) - total = stats.get("total", len(task_rows)) - valid = stats.get("valid", len(task_rows)) - invalid = stats.get("invalid", 0) - invalid_text = _danger_metric_text(f"无效{invalid}", invalid > 0) - unmatched_text = _danger_metric_text(f"未匹配{unmatched}", unmatched > 0) - self.summary_label.setText( - f"{files} 文件 · {total} 行 · 有效{valid}/{invalid_text} · 匹配{matched} · {unmatched_text}" - ) - self.match_detail_label.setText(self._match_detail(task_rows, account_rows)) - self.show_unmatched_button.setText(f"未匹配({unmatched})") - self.show_unmatched_button.setEnabled(unmatched > 0) - self.show_unmatched_button.setStyleSheet( - _danger_outline_button_style("showUnmatchedButton") if unmatched > 0 else "" - ) - if unmatched == 0 and self.model.filter_mode == "unmatched": - self.model.set_filter_mode("all") - - def _match_detail(self, task_rows, account_rows): - account_by_alias = { - str(account.alias).strip(): account - for account in account_rows - if str(account.alias).strip() - } - counts = {} - for task in task_rows: - account = account_by_alias.get(str(task.alias).strip()) - if account is None: - continue - name = account.account_name or account.alias - counts[name] = counts.get(name, 0) + 1 - if not counts: - return "匹配明细:无" - parts = [f"{name}{count}" for name, count in sorted(counts.items())] - return "匹配明细:" + " · ".join(parts) - - def _unmatched_count(self, task_rows, account_rows): - aliases = { - str(account.alias).strip() - for account in account_rows - if str(account.alias).strip() - } - return sum(1 for task in task_rows if str(task.alias).strip() not in aliases) - - def _update_empty_state(self, task_rows, account_rows): - if not account_rows: - self.empty_label.setText("") - _set_empty_state( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - "第一步:前往『④账号管理』配置并登录账号,再回到①导入 Excel。", - self.open_accounts_callback is not None, - ) - return - if not task_rows: - self.empty_label.setText("") - _set_empty_state( - self.empty_state_card, - self.empty_state_label, - self.empty_state_button, - "还没有导入任务。请点击「导入 Excel...」导入待处理商品。", - ) - return - _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) - self._update_empty_label(len(task_rows)) - - def _update_empty_label(self, total_rows): - if total_rows == 0: - self.empty_label.setText("暂无任务") - return - if self.model.rowCount() == 0 and self.model.filter_mode == "unmatched": - self.empty_label.setText("当前筛选没有未匹配任务") - return - if self.model.rowCount() == 0: - self.empty_label.setText("当前筛选没有匹配任务") - return - unmatched = self.model.unmatched_count() - self.empty_label.setText( - "" if unmatched == 0 else f"{unmatched} 条任务别名未匹配账号,阶段显示为“略过”" - ) - - - class AccountDialog(QDialog): - """Dialog for adding or editing one account.""" - - def __init__(self, parent=None, account=None, default_port=9222, config=None): - super().__init__(parent) - self._account = account - self._config = config - self.setWindowTitle("编辑账号" if account else "新增账号") - - self.account_name_edit = QLineEdit() - self.alias_edit = QLineEdit() - self.region_host_edit = QLineEdit(accounts.DEFAULT_REGION_HOST) - self.debug_port_spin = QSpinBox() - self.debug_port_spin.setRange(1, 65535) - self.debug_port_spin.setValue(int(default_port)) - self.password_edit = QLineEdit() - self.password_edit.setEchoMode(QLineEdit.Password) - self.note_edit = QPlainTextEdit() - self.note_edit.setMaximumHeight(76) - self.slug_edit = QLineEdit() - self.slug_edit.setReadOnly(True) - self.user_data_dir_edit = QLineEdit() - self.user_data_dir_edit.setReadOnly(True) - - if account is not None: - self.account_name_edit.setText(account.account_name) - self.alias_edit.setText(account.alias) - self.region_host_edit.setText(account.region_host) - self.debug_port_spin.setValue(int(account.debug_port)) - self.password_edit.setText(account.password or "") - self.note_edit.setPlainText(account.note or "") - self.slug_edit.setText(account.slug) - self.user_data_dir_edit.setText(account.user_data_dir) - - form = QFormLayout() - form.addRow("账号名", self.account_name_edit) - form.addRow("别名", self.alias_edit) - form.addRow("地区", self.region_host_edit) - form.addRow("调试端口", self.debug_port_spin) - form.addRow("密码", self.password_edit) - form.addRow("备注", self.note_edit) - form.addRow("slug", self.slug_edit) - form.addRow("数据目录", self.user_data_dir_edit) - - buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - - layout = QVBoxLayout(self) - layout.addLayout(form) - layout.addWidget(buttons) - - self.alias_edit.textChanged.connect(self._update_path_preview) - self._update_path_preview() - - def _update_path_preview(self): - alias = self.alias_edit.text().strip() - if not alias: - self.slug_edit.clear() - self.user_data_dir_edit.clear() - return - try: - slug = account_config.make_slug(alias) - self.slug_edit.setText(slug) - if self._account is not None and alias == self._account.alias: - self.user_data_dir_edit.setText(self._account.user_data_dir) - else: - self.user_data_dir_edit.setText( - accounts.preview_user_data_dir(alias, config=self._config) - ) - except Exception: - self.slug_edit.clear() - self.user_data_dir_edit.clear() - - def values(self): - return { - "account_name": self.account_name_edit.text().strip(), - "alias": self.alias_edit.text().strip(), - "region_host": self.region_host_edit.text().strip(), - "debug_port": self.debug_port_spin.value(), - "password": self.password_edit.text(), - "note": self.note_edit.toPlainText().strip(), - } - - - from .workers import BaseWorker, run_worker - - - class GenerateWorker(BaseWorker): - """Generate titles and covers for collected tasks.""" - - def __init__( - self, - tasks, - prompt_values, - db_path=None, - config=None, - diagnostic_log_dir=None, - ): - super().__init__() - self.tasks = list(tasks) - self.prompt_values = dict(prompt_values or {}) - self.db_path = db_path - self.config = config - self.diagnostic_log_dir = diagnostic_log_dir - self._run_id = None - self._account_by_alias = {} - self._task_positions = {} - self._eligible_total = 0 - - def execute(self): - account_rows = accounts.list_accounts(path=self.db_path, config=self.config) - account_by_alias = { - str(account.alias).strip(): account - for account in account_rows - if str(account.alias).strip() - } - self._account_by_alias = account_by_alias - eligible = [ - task for task in self.tasks - if getattr(task, "stage", None) == "collected" - ] - self._eligible_total = len(eligible) - self._task_positions = { - getattr(task, "id", None): index - for index, task in enumerate(eligible, start=1) - } - batch_ids = self._batch_ids(eligible) - self._run_id = self._create_run_log(eligible, batch_ids) - ai_cfg = appconfig.ai_config(self.config) - generate_cover = bool(ai_cfg.get("generate_cover", False)) - if generate_cover: - start_message = "[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format( - total=len(eligible), - title_concurrency=ai_cfg.get("title_concurrency", 1), - image_concurrency=ai_cfg.get("image_concurrency", 1), - ) - else: - start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题并发{title_concurrency}".format( - total=len(eligible), - title_concurrency=ai_cfg.get("title_concurrency", 1), - ) - self._log_run_event(start_message) - try: - summary = ai.generate_batch( - self.tasks, - self.prompt_values, - ai_cfg={ - "config": self.config, - "db_path": self.db_path, - "image_dir": appconfig.image_dir(self.config), - "account_by_alias": account_by_alias, - "on_task_update": self._emit_row_update, - "on_event": self._on_generation_event, - "on_error": self._on_generation_error, - "generate_cover": generate_cover, - }, - on_progress=self.progress.emit, - should_stop=self.should_cancel, - ) - except Exception as exc: - error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - summary = { - "ok": False, - "error": error, - "total": len(eligible), - "title_done": 0, - "cover_done": 0, - "cover_total": len(eligible) if generate_cover else 0, - "generated_done": 0, - "failed": len(eligible), - "cancelled": self.should_cancel(), - "generate_cover": generate_cover, - } - self._log_run_event( - f"[失败] AI 生成运行失败:{error}", - level="error", - ) - self._write_diagnostic_log( - "AI生成运行失败", - level="ERROR", - step="execute", - payload={"error": error}, - exc=exc, - ) - summary["run_id"] = self._run_id - summary["batch_ids"] = batch_ids - status = "cancelled" if summary.get("cancelled") else "done" - level = "warning" if summary.get("cancelled") or summary.get("error") else "info" - self._log_run_event(self._format_generate_completion(summary), level=level) - self._finish_run_log(status, summary) - return summary - - def _emit_row_update(self, task_id, fields): - self.row_updated.emit(int(task_id), dict(fields or {})) - - def _on_generation_event(self, payload): - task = payload.get("task") - message = self._format_generation_event(payload) - if not message: - return - self._log_run_event(message, task=task, level=payload.get("level") or "info") - - def _format_generation_event(self, payload): - task = payload.get("task") - phase = payload.get("phase") or "generate" - step = payload.get("step") or "unknown" - result = payload.get("result") or "start" - detail = self._short_detail(payload.get("detail")) - if phase == "title": - if result == "start" and step == "title_submit": - return f"[标题] {self._task_progress_label(task)} 开始生成" - if result == "success" and step == "title_done": - return f"[标题] {self._task_progress_label(task)} 成功" - if result == "success" and step == "db_write": - suffix = f",{detail}" if detail else "" - return f"[标题] {self._task_progress_label(task)} 已保存{suffix}" - if result == "retry": - return self._retry_message("标题", task, payload, detail) - if result == "failed": - return f"[失败] {self._task_plain_label(task)} 标题生成失败:{detail or '未知错误'}" - if result == "cancelled": - return f"[停止] {self._task_plain_label(task)} 标题生成已取消" - return None - if phase == "cover": - if result == "start" and step == "cover_submit": - return f"[图片] {self._task_progress_label(task)} 开始生成" - if result == "success" and step == "db_write": - suffix = f",已保存 {detail}" if detail else "" - return f"[图片] {self._task_progress_label(task)} 成功{suffix}" - if result == "retry": - return self._retry_message("图片", task, payload, detail) - if result == "failed": - return f"[失败] {self._task_plain_label(task)} 图片生成失败:{detail or '未知错误'}" - if result == "cancelled": - return f"[停止] {self._task_plain_label(task)} 图片生成已取消" - return None - return None - - def _retry_message(self, label, task, payload, detail): - attempt = int(payload.get("attempt", 0) or 0) - attempts = int(payload.get("attempts", 0) or 0) - max_retries = max(0, attempts - 1) - retry_text = f"准备重试 {attempt}/{max_retries}" if max_retries else "准备重试" - reason = f":{detail}" if detail else "" - return f"[{label}] {self._task_progress_label(task)} 调用失败,{retry_text}{reason}" - - def _task_progress_label(self, task): - index = self._task_positions.get(getattr(task, "id", None), 0) - total = self._eligible_total or 0 - item_id = getattr(task, "item_id", "") or "未知商品" - shop = self._task_shop_label(task) - shop_text = f"({shop})" if shop else "" - return f"{index}/{total} 商品 {item_id}{shop_text}" - - def _task_plain_label(self, task): - item_id = getattr(task, "item_id", "") or "未知商品" - shop = self._task_shop_label(task) - return f"商品 {item_id}({shop})" if shop else f"商品 {item_id}" - - def _task_shop_label(self, task): - alias = str(getattr(task, "alias", "") or "").strip() - account = self._account_by_alias.get(alias) - if account is not None: - return getattr(account, "account_name", None) or getattr(account, "alias", None) or alias - return getattr(task, "account_name", None) or alias - - def _short_detail(self, detail): - if detail is None: - return "" - text = diagnostics.redact_log_text(str(detail)).replace("\r", " ").replace("\n", " ").strip() - if len(text) > 180: - return text[:177] + "..." - return text - - def _format_generate_completion(self, summary): - progress = self._summary_text(summary) - if summary.get("cancelled"): - return f"[停止] AI 生成已停止:{progress}" - if summary.get("error"): - return f"[失败] AI 生成失败:{summary.get('error')},{progress}" - return f"[完成] AI 生成完成:{progress}" - - def _summary_text(self, summary): - cover_total = summary.get("cover_total", summary.get("total", 0)) - return "标题{title}/{total},图片{cover}/{cover_total},失败{failed}".format( - title=summary.get("title_done", 0), - cover=summary.get("cover_done", 0), - cover_total=cover_total, - total=summary.get("total", 0), - failed=summary.get("failed", 0), - ) - - def _on_generation_error(self, payload): - task = payload.get("task") - phase = payload.get("phase") or "generate" - step = payload.get("step") or "unknown" - error = diagnostics.redact_log_text(payload.get("error") or "未知错误") - self._write_diagnostic_log( - "AI生成任务失败", - level="ERROR", - step=step, - task=task, - payload={"phase": phase, "error": error}, - exc=payload.get("exception"), - ) - - def _batch_ids(self, tasks): - batch_ids = [] - for task in tasks: - batch_id = getattr(task, "batch_id", None) - if batch_id and batch_id not in batch_ids: - batch_ids.append(batch_id) - return batch_ids - - def _create_run_log(self, eligible, batch_ids): - try: - ai_cfg = appconfig.ai_config(self.config) - return db.create_run_log( - "generate", - dry_run=False, - total=len(eligible), - options={ - "batch_ids": batch_ids, - "default_text_model": ai_cfg.get("default_text_model"), - "default_image_model": ai_cfg.get("default_image_model"), - "resolution": ai_cfg.get("resolution"), - "title_concurrency": ai_cfg.get("title_concurrency"), - "image_concurrency": ai_cfg.get("image_concurrency"), - "generate_cover": ai_cfg.get("generate_cover", False), - }, - path=self.db_path, - ) - except Exception: - return None - - def _finish_run_log(self, status, summary): - if self._run_id is None: - return - try: - generated_done = summary.get("generated_done") - if generated_done is None: - generated_done = summary.get("cover_done", 0) - if not summary.get("generate_cover", True) and not generated_done: - generated_done = summary.get("title_done", 0) - done = int(generated_done or 0) + int(summary.get("failed", 0) or 0) - db.finish_run_log( - self._run_id, - status=status, - done=done, - success_count=generated_done, - skipped_count=0, - failed_count=summary.get("failed", 0), - summary_json=summary, - path=self.db_path, - ) - except Exception: - return - - def _log_run_event(self, message, task=None, level="info"): - safe_message = diagnostics.redact_log_text(message) - self.log.emit(str(safe_message)) - if self._run_id is None: - return - try: - db.add_run_log_event( - self._run_id, - safe_message, - task_id=getattr(task, "id", None), - alias=getattr(task, "alias", None), - item_id=getattr(task, "item_id", None), - level=level, - path=self.db_path, - ) - except Exception: - return - - def _write_diagnostic_log( - self, - message, - level="INFO", - step=None, - task=None, - payload=None, - exc=None, - ): - try: - diagnostics.write_diagnostic_log( - message, - level=level, - step=step, - task_id=getattr(task, "id", None), - alias=getattr(task, "alias", None), - item_id=getattr(task, "item_id", None), - payload=payload, - exc=exc, - log_dir=self.diagnostic_log_dir, - ) - except Exception: - return - - class ApplyWorker(BaseWorker): - """Apply generated title/cover changes, optionally previewing or grouping by account.""" - - def __init__( - self, - tasks, - db_path=None, - config=None, - preflight=True, - close_success_tab=False, - dry_run=False, - parallel_accounts=False, - max_parallel_accounts=1, - batch_size=None, - diagnostic_log_dir=None, - ): - super().__init__() - self.tasks = list(tasks) - self.db_path = db_path - self.config = config - self.preflight = preflight - self.close_success_tab = close_success_tab - self.dry_run = bool(dry_run) - self.parallel_accounts = bool(parallel_accounts) - self.max_parallel_accounts = max(1, int(max_parallel_accounts or 1)) - self.batch_size = None if batch_size is None else max(1, int(batch_size or 1)) - self._current_batch_size = None - self._batch_count = 0 - self._progress_lock = threading.Lock() - self.diagnostic_log_dir = diagnostic_log_dir - self._run_id = None - - def execute(self): - account_rows = accounts.list_accounts(path=self.db_path, config=self.config) - account_by_alias = { - str(account.alias).strip(): account - for account in account_rows - if str(account.alias).strip() - } - eligible = [task for task in self.tasks if self._is_actionable_task(task)] - batch_ids = self._batch_ids(eligible) - total = len(eligible) - batch_size = self._effective_batch_size(total) - batches = self._task_batches(eligible, batch_size) - self._current_batch_size = batch_size - self._batch_count = len(batches) - counters = { - "done": 0, - "applied": 0, - "skipped": 0, - "failed": 0, - } - self._run_id = self._create_run_log(eligible, batch_ids) - self._log_run_event( - "step=start result=start detail=运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format( - mode="检查本轮更新" if self.dry_run else "真实更新", - total=total, - batch_size=batch_size, - batch_count=len(batches), - parallel=( - f"多账号并行最多{self.max_parallel_accounts}" - if self.parallel_accounts - else "串行" - ), - ) - ) - - if self.preflight and not self.dry_run: - self._log_run_event("step=preflight result=start detail=账号就绪检查") - blocked = self._preflight_block(eligible, account_rows, account_by_alias) - if blocked: - self._log_preflight_blocked(blocked) - summary = self._summary( - ok=False, - total=total, - counters=counters, - batch_ids=batch_ids, - blocked=True, - extra=blocked, - ) - self._finish_run_log("blocked", summary) - return summary - self._log_run_event("step=preflight result=success detail=账号检查通过") - elif not self.preflight: - self._log_run_event( - "step=preflight result=skipped detail=测试模式跳过更新前检查", - level="warning", - ) - - for batch_index, batch_tasks in enumerate(batches, start=1): - if self.should_cancel(): - break - self._log_batch_start(batch_index, len(batches), batch_tasks, counters, total) - if self.dry_run: - for task in batch_tasks: - if self.should_cancel(): - break - outcome = self._preview_task(task, account_by_alias) - self._record_outcome(counters, total, outcome) - elif self.parallel_accounts and self.max_parallel_accounts > 1: - self._run_parallel_by_account(batch_tasks, account_by_alias, counters, total) - else: - for task in batch_tasks: - if self.should_cancel(): - break - outcome = self._apply_one_task(task, account_by_alias) - self._record_outcome(counters, total, outcome) - - summary = self._summary( - ok=counters["failed"] == 0, - total=total, - counters=counters, - batch_ids=batch_ids, - ) - self._finish_run_log("cancelled" if self.should_cancel() else "done", summary) - return summary - - def _is_actionable_task(self, task): - return ( - getattr(task, "stage", None) == "generated" - and getattr(task, "status", None) in {"success", "pending", "failed"} - and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) - ) - - def _preflight_block(self, eligible, account_rows, account_by_alias): - if not account_rows: - return { - "reason": "NO_ACCOUNTS", - "no_accounts": True, - } - duplicate_ports = self._duplicate_debug_ports(account_rows, eligible, account_by_alias) - if duplicate_ports: - return { - "reason": "DUPLICATE_DEBUG_PORT", - "duplicate_ports": duplicate_ports, - } - required_accounts = [] - seen_aliases = set() - for task in eligible: - alias = str(task.alias).strip() - account = account_by_alias.get(alias) - if account is not None and alias not in seen_aliases: - required_accounts.append(account) - seen_aliases.add(alias) - not_running = [] - logged_out = [] - for account in required_accounts: - self._log_run_event( - f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", - level="info", - ) - if not chrome.is_running(account.debug_port): - self._log_run_event( - f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}", - level="warning", - ) - not_running.append(self._account_payload(account, "CDP 端口未响应")) - continue - self._log_run_event( - f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}", - level="info", - ) - self._log_run_event( - f"step=login_check result=start detail=账号 {account.alias}", - level="info", - ) - status = self._login_status(account) - if not status.get("logged_in"): - reason = self._login_skip_reason(status) - self._log_run_event( - f"step=login_check result=blocked detail=账号 {account.alias} {reason}", - level="warning", - ) - logged_out.append( - self._account_payload(account, reason) - ) - else: - self._log_run_event( - f"step=login_check result=success detail=账号 {account.alias}", - level="info", - ) - if not_running or logged_out: - return { - "reason": "ACCOUNT_NOT_READY", - "not_running": not_running, - "logged_out": logged_out, - } - return None - - def _duplicate_debug_ports(self, account_rows, eligible, account_by_alias): - required_aliases = { - str(task.alias).strip() - for task in eligible - if account_by_alias.get(str(task.alias).strip()) is not None - } - by_port = {} - for account in account_rows: - if account.alias not in required_aliases: - continue - by_port.setdefault(int(account.debug_port), []).append(account) - duplicates = [] - for port, rows in by_port.items(): - if len(rows) > 1: - duplicates.append( - { - "debug_port": port, - "aliases": [row.alias for row in rows], - } - ) - return duplicates - - def _effective_batch_size(self, total): - if self.batch_size is None: - return max(1, int(total or 1)) - return self.batch_size - - def _task_batches(self, tasks, batch_size): - if not tasks: - return [] - return [ - tasks[index:index + batch_size] - for index in range(0, len(tasks), batch_size) - ] - - def _log_batch_start(self, batch_index, batch_count, batch_tasks, counters, total): - first = counters["done"] + 1 - last = min(first + len(batch_tasks) - 1, total) - label = "检查批次" if self.dry_run else "更新批次" - self._log_run_event( - f"step=batch result=start detail={label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}" - ) - - def _run_parallel_by_account(self, eligible, account_by_alias, counters, total): - groups = self._group_tasks_by_alias(eligible) - max_workers = min(self.max_parallel_accounts, len(groups)) - if max_workers <= 1: - for group_tasks in groups: - self._run_task_group(group_tasks, account_by_alias, counters, total) - return - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = [ - executor.submit( - self._run_task_group, - group_tasks, - account_by_alias, - counters, - total, - ) - for group_tasks in groups - ] - for future in as_completed(futures): - future.result() - - def _group_tasks_by_alias(self, tasks): - groups = [] - index_by_alias = {} - for task in tasks: - alias = str(task.alias).strip() - if alias not in index_by_alias: - index_by_alias[alias] = len(groups) - groups.append([]) - groups[index_by_alias[alias]].append(task) - return groups - - def _run_task_group(self, tasks, account_by_alias, counters, total): - for task in tasks: - if self.should_cancel(): - break - outcome = self._apply_one_task(task, account_by_alias) - self._record_outcome(counters, total, outcome) - - def _preview_task(self, task, account_by_alias): - account = account_by_alias.get(str(task.alias).strip()) - if account is None: - reason = "别名未匹配账号" - self._log_run_event( - f"step=preview result=skipped detail=检查:任务 {task.id} 商品 {task.item_id} 将略过:{reason}", - task=task, - level="warning", - ) - return "skipped" - action_parts = [] - if getattr(task, "new_title", None): - action_parts.append("标题") - if getattr(task, "new_cover_path", None): - action_parts.append("封面") - action_text = "+".join(action_parts) or "无变更" - self._log_run_event( - "step=preview result=success detail=检查:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format( - task_id=task.id, - item_id=task.item_id, - alias=account.alias, - action=action_text, - ), - task=task, - ) - return "applied" - - def _apply_one_task(self, task, account_by_alias): - account = account_by_alias.get(str(task.alias).strip()) - if account is None: - reason = "别名未匹配账号" - db.mark_skipped(task.id, reason, path=self.db_path) - self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) - self._log_run_event( - f"step=preflight result=skipped detail=任务 {task.id} 商品 {task.item_id} 已略过:{reason}", - task=task, - level="warning", - ) - return "skipped" - - started = time.monotonic() - current_step = "db_write" - - def on_step(event): - nonlocal current_step - if isinstance(event, dict): - step = str(event.get("step") or "apply_task") - result = str(event.get("result") or "start") - detail = event.get("detail") - else: - step = str(event) - result = "start" - detail = None - current_step = step - level = "error" if result == "failed" else "info" - detail_text = "任务 {task_id} 商品 {item_id}".format( - task_id=task.id, - item_id=task.item_id, - ) - if detail: - detail_text = f"{detail_text} {detail}" - self._log_run_event( - f"step={step} result={result} detail={detail_text}", - task=task, - level=level, - ) - - try: - self._log_run_event( - f"step=apply_task result=start detail=任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}", - task=task, - ) - current_step = "db_write" - self._log_run_event( - f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 标记更新运行", - task=task, - ) - db.mark_running(task.id, "apply", path=self.db_path) - self.row_updated.emit(task.id, {"status": "running", "last_error": None}) - result = editor.apply_task( - account, - task, - close_success_tab=self.close_success_tab, - on_step=on_step, - ) - committed = bool(result.get("committed")) and not result.get("error") - error = result.get("error") - current_step = "db_write" - self._log_run_event( - f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 保存更新结果", - task=task, - ) - if committed: - db.set_applied(task.id, True, path=self.db_path) - elapsed_ms = self._elapsed_ms(started) - self.row_updated.emit( - task.id, - { - "stage": "applied", - "status": "success", - "committed": 1, - "last_error": None, - }, - ) - self._log_run_event( - f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 更新成功 elapsed_ms={elapsed_ms}", - task=task, - ) - return "applied" - - error = diagnostics.redact_log_text(error or "更新未提交") - failed_step = self._failed_apply_step(result, current_step) - db.set_applied(task.id, False, error, path=self.db_path) - elapsed_ms = self._elapsed_ms(started) - self.failed.emit(task.id, str(error)) - self.row_updated.emit( - task.id, - {"status": "failed", "last_error": str(error), "committed": 0}, - ) - self._log_run_event( - f"step={failed_step} result=failed detail={error} elapsed_ms={elapsed_ms}", - task=task, - level="error", - ) - self._log_run_event( - f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 保存失败状态 elapsed_ms={elapsed_ms}", - task=task, - ) - self._write_diagnostic_log( - "Shopee更新任务失败", - level="ERROR", - step=failed_step, - task=task, - elapsed_ms=elapsed_ms, - payload={"error": error, "result": result}, - ) - return "failed" - except Exception as exc: - error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - db.set_applied(task.id, False, error, path=self.db_path) - elapsed_ms = self._elapsed_ms(started) - self.failed.emit(task.id, error) - self.row_updated.emit( - task.id, - {"status": "failed", "last_error": error, "committed": 0}, - ) - self._log_run_event( - f"step={current_step} result=failed detail={error} elapsed_ms={elapsed_ms}", - task=task, - level="error", - ) - self._write_diagnostic_log( - "Shopee更新任务异常", - level="ERROR", - step=current_step, - task=task, - elapsed_ms=elapsed_ms, - payload={"error": error}, - exc=exc, - ) - return "failed" - def _record_outcome(self, counters, total, outcome): - with self._progress_lock: - counters["done"] += 1 - if outcome == "applied": - counters["applied"] += 1 - elif outcome == "skipped": - counters["skipped"] += 1 - else: - counters["failed"] += 1 - self._emit_progress( - counters["done"], - total, - counters["applied"], - counters["skipped"], - counters["failed"], - ) - - def _account_payload(self, account, reason=None): - payload = { - "account_name": account.account_name, - "alias": account.alias, - "debug_port": account.debug_port, - } - if reason: - payload["reason"] = reason - return payload - - def _emit_progress(self, done, total, applied, skipped, failed): - self.progress.emit( - { - "done": done, - "total": total, - "applied": applied, - "skipped": skipped, - "failed": failed, - "dry_run": self.dry_run, - "batch_size": self._current_batch_size, - "batch_count": self._batch_count, - } - ) - - def _login_status(self, account): - try: - return accounts.detect_login(account, path=self.db_path, config=self.config) - except Exception as exc: - return { - "logged_in": False, - "reason": f"LOGIN_CHECK_FAILED: {exc}", - } - - def _login_skip_reason(self, status): - reason = status.get("reason") - return f"账号未登录: {reason}" if reason else "账号未登录" - - def _batch_ids(self, tasks): - batch_ids = [] - for task in tasks: - batch_id = getattr(task, "batch_id", None) - if batch_id and batch_id not in batch_ids: - batch_ids.append(batch_id) - return batch_ids - - def _summary(self, ok, total, counters, batch_ids, blocked=False, extra=None): - summary = { - "ok": ok, - "total": total, - "done": counters["done"], - "applied": counters["applied"], - "skipped": counters["skipped"], - "failed": counters["failed"], - "batch_ids": batch_ids, - "dry_run": self.dry_run, - "parallel_accounts": self.parallel_accounts, - "batch_size": self._current_batch_size, - "batch_count": self._batch_count, - "run_id": self._run_id, - } - if blocked: - summary["blocked"] = True - if extra: - summary.update(extra) - return summary - - def _create_run_log(self, eligible, batch_ids): - try: - return db.create_run_log( - "apply", - dry_run=self.dry_run, - total=len(eligible), - options={ - "batch_ids": batch_ids, - "close_success_tab": self.close_success_tab, - "dry_run": self.dry_run, - "parallel_accounts": self.parallel_accounts, - "max_parallel_accounts": self.max_parallel_accounts, - "batch_size": self._current_batch_size, - "batch_count": self._batch_count, - }, - path=self.db_path, - ) - except Exception: - return None - - def _finish_run_log(self, status, summary): - if self._run_id is None: - return - try: - db.finish_run_log( - self._run_id, - status=status, - done=summary.get("done", 0), - success_count=summary.get("applied", 0), - skipped_count=summary.get("skipped", 0), - failed_count=summary.get("failed", 0), - summary_json=summary, - path=self.db_path, - ) - except Exception: - return - - def _log_run_event(self, message, task=None, level="info"): - safe_message = diagnostics.redact_log_text(message) - self.log.emit(str(safe_message)) - if self._run_id is None: - return - try: - db.add_run_log_event( - self._run_id, - safe_message, - task_id=getattr(task, "id", None), - alias=getattr(task, "alias", None), - item_id=getattr(task, "item_id", None), - level=level, - path=self.db_path, - ) - except Exception: - return - - - def _log_preflight_blocked(self, blocked): - if blocked.get("no_accounts"): - self._log_run_event( - "step=preflight result=blocked detail=当前没有配置账号", - level="warning", - ) - for item in blocked.get("duplicate_ports") or []: - self._log_run_event( - "step=preflight result=blocked detail=调试端口重复 debug_port={port} aliases={aliases}".format( - port=item.get("debug_port") or "", - aliases=",".join(item.get("aliases") or []), - ), - level="warning", - ) - for item in blocked.get("not_running") or []: - self._log_run_event( - "step=check_chrome result=blocked detail=账号 {alias} Chrome 未启动或调试端口不可访问: {reason}".format( - alias=item.get("alias") or "", - reason=item.get("reason") or "", - ), - level="warning", - ) - for item in blocked.get("logged_out") or []: - self._log_run_event( - "step=login_check result=blocked detail=账号 {alias} 未登录 Shopee: {reason}".format( - alias=item.get("alias") or "", - reason=item.get("reason") or "", - ), - level="warning", - ) - - def _failed_apply_step(self, result, fallback): - if not isinstance(result, dict): - return fallback or "apply_task" - title = result.get("title") - if isinstance(title, dict) and not title.get("ok", True): - return "change_title" - cover = result.get("cover") - if isinstance(cover, dict) and not cover.get("ok", True): - return "replace_cover" - update = result.get("update") - if isinstance(update, dict): - return "click_update" - return fallback or "apply_task" - - def _write_diagnostic_log( - self, - message, - level="INFO", - step=None, - task=None, - elapsed_ms=None, - payload=None, - exc=None, - ): - _safe_write_diagnostic_log( - message, - level=level, - step=step, - task=task, - elapsed_ms=elapsed_ms, - payload=payload, - exc=exc, - log_dir=self.diagnostic_log_dir, - ) - - def _elapsed_ms(self, started): - return _elapsed_ms(started) - - class CollectWorker(BaseWorker): - """Collect old title and cover for imported tasks.""" - - def __init__( - self, - tasks, - db_path=None, - config=None, - preflight=True, - diagnostic_log_dir=None, - ): - super().__init__() - self.tasks = list(tasks) - self.db_path = db_path - self.config = config - self.preflight = preflight - self.diagnostic_log_dir = diagnostic_log_dir - self._run_id = None - - def execute(self): - account_rows = accounts.list_accounts(path=self.db_path, config=self.config) - account_by_alias = { - str(account.alias).strip(): account - for account in account_rows - if str(account.alias).strip() - } - eligible = [ - task for task in self.tasks - if getattr(task, "stage", None) == "imported" - ] - batch_ids = self._batch_ids(eligible) - total = len(eligible) - collected = 0 - skipped = 0 - failed = 0 - done = 0 - - self._run_id = self._create_run_log(eligible, batch_ids) - self._log_run_event( - f"step=preflight result=start detail=采集运行开始 total={total}" - ) - - if self.preflight: - blocked = self._preflight_block(eligible, account_rows, account_by_alias) - if blocked: - self._log_preflight_blocked(blocked) - summary = self._summary( - ok=False, - total=total, - done=done, - collected=collected, - skipped=skipped, - failed=failed, - batch_ids=batch_ids, - blocked=True, - extra=blocked, - ) - self._finish_run_log("blocked", summary) - return summary - self._log_run_event("step=preflight result=success detail=账号检查通过") - else: - self._log_run_event( - "step=preflight result=skipped detail=测试模式跳过采集前检查", - level="warning", - ) - - for task in eligible: - if self.should_cancel(): - break - account = account_by_alias.get(str(task.alias).strip()) - if account is None: - skipped += 1 - done += 1 - reason = "别名未匹配账号" - db.mark_skipped(task.id, reason, path=self.db_path) - self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) - self._log_run_event( - "step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( - task_id=task.id, - item_id=task.item_id, - reason=reason, - ), - task=task, - level="warning", - ) - self._emit_progress(done, total, collected, skipped, failed) - continue - - status = self._login_status(account) - if not status.get("logged_in"): - skipped += 1 - done += 1 - reason = self._login_skip_reason(status) - db.mark_skipped(task.id, reason, path=self.db_path) - self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) - self._log_run_event( - "step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( - task_id=task.id, - item_id=task.item_id, - reason=reason, - ), - task=task, - level="warning", - ) - self._emit_progress(done, total, collected, skipped, failed) - continue - - started = time.monotonic() - current_step = "db_write" - - def on_step(step): - nonlocal current_step - current_step = str(step) - self._log_run_event( - "step={step} result=start detail=任务 {task_id} 商品 {item_id}".format( - step=current_step, - task_id=task.id, - item_id=task.item_id, - ), - task=task, - ) - - try: - self._log_run_event( - "step=db_write result=start detail=任务 {task_id} 商品 {item_id} 标记采集运行".format( - task_id=task.id, - item_id=task.item_id, - ), - task=task, - ) - db.mark_running(task.id, "collect", path=self.db_path) - self.row_updated.emit(task.id, {"status": "running"}) - result = editor.collect( - account, - { - "item_id": task.item_id, - "old_cover_path": self._old_cover_path(account, task), - }, - on_step=on_step, - ) - current_step = "db_write" - self._log_run_event( - "step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存采集结果".format( - task_id=task.id, - item_id=task.item_id, - ), - task=task, - ) - db.set_collected( - task.id, - result.get("old_title", ""), - result.get("old_cover_path", ""), - path=self.db_path, - ) - collected += 1 - elapsed_ms = self._elapsed_ms(started) - self.row_updated.emit( - task.id, - { - "stage": "collected", - "status": "success", - "old_title": result.get("old_title", ""), - "old_cover_path": result.get("old_cover_path", ""), - }, - ) - self._log_run_event( - "step=db_write result=success detail=任务 {task_id} 商品 {item_id} 采集成功 elapsed_ms={elapsed_ms}".format( - task_id=task.id, - item_id=task.item_id, - elapsed_ms=elapsed_ms, - ), - task=task, - ) - except Exception as exc: - failed += 1 - error = str(exc) or exc.__class__.__name__ - safe_error = diagnostics.redact_log_text(error) - elapsed_ms = self._elapsed_ms(started) - db.mark_failed(task.id, "collect", safe_error, path=self.db_path) - self.failed.emit(task.id, safe_error) - self.row_updated.emit(task.id, {"status": "failed", "last_error": safe_error}) - self._log_run_event( - "step={step} result=failed detail={error} elapsed_ms={elapsed_ms}".format( - step=current_step, - error=safe_error, - elapsed_ms=elapsed_ms, - ), - task=task, - level="error", - ) - self._write_diagnostic_log( - "采集任务失败", - level="ERROR", - step=current_step, - task=task, - elapsed_ms=elapsed_ms, - payload={"error": safe_error}, - exc=exc, - ) - finally: - done += 1 - self._emit_progress(done, total, collected, skipped, failed) - - summary = self._summary( - ok=failed == 0, - total=total, - done=done, - collected=collected, - skipped=skipped, - failed=failed, - batch_ids=batch_ids, - ) - self._finish_run_log("cancelled" if self.should_cancel() else "done", summary) - return summary - - def _preflight_block(self, eligible, account_rows, account_by_alias): - if not account_rows: - return { - "reason": "NO_ACCOUNTS", - "no_accounts": True, - } - required_accounts = [] - seen_aliases = set() - for task in eligible: - alias = str(task.alias).strip() - account = account_by_alias.get(alias) - if account is not None and alias not in seen_aliases: - required_accounts.append(account) - seen_aliases.add(alias) - not_running = [] - logged_out = [] - for account in required_accounts: - self._log_run_event( - f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", - level="info", - ) - if not chrome.is_running(account.debug_port): - self._log_run_event( - f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}", - level="warning", - ) - not_running.append(self._account_payload(account, "CDP 端口未响应")) - continue - self._log_run_event( - f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}", - level="info", - ) - self._log_run_event( - f"step=login_check result=start detail=账号 {account.alias}", - level="info", - ) - status = self._login_status(account) - if not status.get("logged_in"): - reason = self._login_skip_reason(status) - self._log_run_event( - f"step=login_check result=blocked detail=账号 {account.alias} {reason}", - level="warning", - ) - logged_out.append( - self._account_payload(account, reason) - ) - else: - self._log_run_event( - f"step=login_check result=success detail=账号 {account.alias}", - level="info", - ) - if not_running or logged_out: - return { - "reason": "ACCOUNT_NOT_READY", - "not_running": not_running, - "logged_out": logged_out, - } - return None - - def _account_payload(self, account, reason=None): - payload = { - "account_name": account.account_name, - "alias": account.alias, - "debug_port": account.debug_port, - } - if reason: - payload["reason"] = reason - return payload - - def _emit_progress(self, done, total, collected, skipped, failed): - self.progress.emit( - { - "done": done, - "total": total, - "collected": collected, - "skipped": skipped, - "failed": failed, - } - ) - - def _login_status(self, account): - try: - return accounts.detect_login(account, path=self.db_path, config=self.config) - except Exception as exc: - return { - "logged_in": False, - "reason": f"LOGIN_CHECK_FAILED: {exc}", - } - - def _login_skip_reason(self, status): - reason = status.get("reason") - return f"账号未登录: {reason}" if reason else "账号未登录" - - def _old_cover_path(self, account, task): - image_root = appconfig.image_dir(self.config) - return image_paths.task_image_path(image_root, task, account, "old") - - def _batch_ids(self, tasks): - batch_ids = [] - for task in tasks: - batch_id = getattr(task, "batch_id", None) - if batch_id and batch_id not in batch_ids: - batch_ids.append(batch_id) - return batch_ids - - def _summary( - self, - ok, - total, - done, - collected, - skipped, - failed, - batch_ids, - blocked=False, - extra=None, - ): - summary = { - "ok": ok, - "total": total, - "done": done, - "collected": collected, - "skipped": skipped, - "failed": failed, - "batch_ids": batch_ids, - "run_id": self._run_id, - } - if blocked: - summary["blocked"] = True - if extra: - summary.update(extra) - return summary - - def _create_run_log(self, eligible, batch_ids): - try: - return db.create_run_log( - "collect", - dry_run=False, - total=len(eligible), - options={ - "batch_ids": batch_ids, - "preflight": self.preflight, - }, - path=self.db_path, - ) - except Exception: - return None - - def _finish_run_log(self, status, summary): - if self._run_id is None: - return - try: - db.finish_run_log( - self._run_id, - status=status, - done=summary.get("done", 0), - success_count=summary.get("collected", 0), - skipped_count=summary.get("skipped", 0), - failed_count=summary.get("failed", 0), - summary_json=summary, - path=self.db_path, - ) - except Exception: - return - - def _log_run_event(self, message, task=None, level="info"): - safe_message = diagnostics.redact_log_text(message) - self.log.emit(str(safe_message)) - if self._run_id is None: - return - try: - db.add_run_log_event( - self._run_id, - safe_message, - task_id=getattr(task, "id", None), - alias=getattr(task, "alias", None), - item_id=getattr(task, "item_id", None), - level=level, - path=self.db_path, - ) - except Exception: - return - - def _log_preflight_blocked(self, blocked): - if blocked.get("no_accounts"): - self._log_run_event( - "step=preflight result=blocked detail=当前没有配置账号", - level="warning", - ) - for item in blocked.get("not_running") or []: - self._log_run_event( - "step=preflight result=blocked detail=账号 {alias} Chrome 未启动或调试端口不可访问: {reason}".format( - alias=item.get("alias") or "", - reason=item.get("reason") or "", - ), - level="warning", - ) - for item in blocked.get("logged_out") or []: - self._log_run_event( - "step=preflight result=blocked detail=账号 {alias} 未登录 Shopee: {reason}".format( - alias=item.get("alias") or "", - reason=item.get("reason") or "", - ), - level="warning", - ) - - def _write_diagnostic_log( - self, - message, - level="INFO", - step=None, - task=None, - elapsed_ms=None, - payload=None, - exc=None, - ): - try: - diagnostics.write_diagnostic_log( - message, - level=level, - step=step, - task_id=getattr(task, "id", None), - alias=getattr(task, "alias", None), - item_id=getattr(task, "item_id", None), - elapsed_ms=elapsed_ms, - payload=payload, - exc=exc, - log_dir=self.diagnostic_log_dir, - ) - except Exception: - return - - def _elapsed_ms(self, started): - return int((time.monotonic() - started) * 1000) - - class WriteBackWorker(BaseWorker): - """Write Excel fields back in a background thread.""" - - def __init__(self, batch_id, db_path=None, excel_path=None, mode="old", diagnostic_log_dir=None): - super().__init__() - self.batch_id = batch_id - self.db_path = db_path - self.excel_path = excel_path - self.mode = mode - self.diagnostic_log_dir = diagnostic_log_dir - self._run_id = None - - def execute(self): - batch_ids = self._batch_ids() - self._run_id = _safe_create_run_log( - "write_back", - db_path=self.db_path, - total=len(batch_ids), - options={ - "batch_ids": batch_ids, - "mode": self.mode, - "excel_path": self.excel_path, - }, - ) - self._log_run_event( - f"step=start result=start detail=Excel 回写开始 mode={self.mode} batch_count={len(batch_ids)}" - ) - results = [] - try: - for batch_id in batch_ids: - started = time.monotonic() - self._log_run_event( - f"step=write_excel result=start detail=batch_id={batch_id} mode={self.mode}" - ) - result = self._write_one(batch_id) - results.append(result) - self._log_run_event( - "step=write_excel result=success detail=batch_id={batch_id} files={files} rows={rows} elapsed_ms={elapsed_ms}".format( - batch_id=batch_id, - files=result.get("files", 0), - rows=result.get("rows", 0), - elapsed_ms=self._elapsed_ms(started), - ) - ) - except Exception as exc: - error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - self._log_run_event( - f"step=write_excel result=failed detail={error}", - level="error", - ) - self._write_diagnostic_log( - "Excel回写失败", - level="ERROR", - step="write_excel", - payload={"batch_ids": batch_ids, "mode": self.mode, "error": error}, - exc=exc, - ) - _safe_finish_run_log( - self._run_id, - db_path=self.db_path, - status="failed", - done=len(results), - success_count=sum(result.get("rows", 0) for result in results), - failed_count=1, - summary_json={"ok": False, "error": error, "mode": self.mode}, - ) - raise - result = results[0] if len(results) == 1 else self._combined_result(results) - self.progress.emit( - { - "done": result.get("rows", 0), - "total": result.get("rows", 0), - "files": result.get("files", 0), - } - ) - _safe_finish_run_log( - self._run_id, - db_path=self.db_path, - status="done", - done=len(batch_ids), - success_count=result.get("rows", 0), - failed_count=0, - summary_json={"ok": result.get("ok", False), "mode": self.mode, "result": result}, - ) - return result - - def _batch_ids(self): - if isinstance(self.batch_id, (list, tuple, set)): - return list(self.batch_id) - return [self.batch_id] - - def _write_one(self, batch_id): - if self.mode == "results": - return excel.write_back_results( - batch_id, - excel_path=self.excel_path, - path=self.db_path, - ) - return excel.write_back( - batch_id, - excel_path=self.excel_path, - path=self.db_path, - ) - - def _combined_result(self, results): - written_files = [] - for result in results: - for file_path in result.get("written_files", []): - if file_path not in written_files: - written_files.append(file_path) - return { - "ok": all(result.get("ok", False) for result in results), - "batch_id": [result.get("batch_id") for result in results], - "files": sum(result.get("files", 0) for result in results), - "rows": sum(result.get("rows", 0) for result in results), - "written_files": written_files, - } - - def _log_run_event(self, message, level="info"): - safe_message = _safe_add_run_log_event( - self._run_id, - message, - db_path=self.db_path, - level=level, - ) - self.log.emit(str(safe_message)) - - def _write_diagnostic_log(self, message, level="INFO", step=None, payload=None, exc=None): - _safe_write_diagnostic_log( - message, - level=level, - step=step, - payload=payload, - exc=exc, - log_dir=self.diagnostic_log_dir, - ) - - def _elapsed_ms(self, started): - return _elapsed_ms(started) - - class AccountLoginCheckWorker(BaseWorker): - def __init__(self, account, db_path=None, config=None, timeout=8, diagnostic_log_dir=None): - super().__init__() - self.account = account - self.db_path = db_path - self.config = config - self.timeout = timeout - self.diagnostic_log_dir = diagnostic_log_dir - self._run_id = None - - def execute(self): - self._run_id = _safe_create_run_log( - "login_check", - db_path=self.db_path, - total=1, - options={ - "alias": self.account.alias, - "debug_port": self.account.debug_port, - "timeout": self.timeout, - }, - ) - started = time.monotonic() - self._log_run_event( - f"step=detect_login result=start detail=账号 {self.account.alias} debug_port={self.account.debug_port}" - ) - try: - status = accounts.detect_login( - self.account, - timeout=self.timeout, - path=self.db_path, - config=self.config, - ) - except Exception as exc: - error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - elapsed_ms = self._elapsed_ms(started) - self._log_run_event( - f"step=detect_login result=failed detail={error} elapsed_ms={elapsed_ms}", - level="error", - ) - self._write_diagnostic_log( - "登录检测失败", - level="ERROR", - step="detect_login", - elapsed_ms=elapsed_ms, - payload={"alias": self.account.alias, "error": error}, - exc=exc, - ) - _safe_finish_run_log( - self._run_id, - db_path=self.db_path, - status="failed", - done=0, - failed_count=1, - summary_json={"ok": False, "alias": self.account.alias, "error": error}, - ) - raise - elapsed_ms = self._elapsed_ms(started) - logged_in = bool(status.get("logged_in")) - result_text = "success" if logged_in else "failed" - level = "info" if logged_in else "warning" - self._log_run_event( - "step=detect_login result={result} detail=账号 {alias} logged_in={logged_in} reason={reason} elapsed_ms={elapsed_ms}".format( - result=result_text, - alias=self.account.alias, - logged_in=logged_in, - reason=status.get("reason") or "", - elapsed_ms=elapsed_ms, - ), - level=level, - ) - _safe_finish_run_log( - self._run_id, - db_path=self.db_path, - status="done", - done=1, - success_count=1 if logged_in else 0, - failed_count=0 if logged_in else 1, - summary_json={"ok": logged_in, "alias": self.account.alias, "status": status}, - ) - self.row_updated.emit(self.account.id, status) - return {"alias": self.account.alias, "status": status} - - def _log_run_event(self, message, level="info"): - safe_message = _safe_add_run_log_event( - self._run_id, - message, - db_path=self.db_path, - account=self.account, - level=level, - ) - self.log.emit(str(safe_message)) - - def _write_diagnostic_log( - self, - message, - level="INFO", - step=None, - elapsed_ms=None, - payload=None, - exc=None, - ): - _safe_write_diagnostic_log( - message, - level=level, - step=step, - account=self.account, - elapsed_ms=elapsed_ms, - payload=payload, - exc=exc, - log_dir=self.diagnostic_log_dir, - ) - - def _elapsed_ms(self, started): - return _elapsed_ms(started) - - class AIModelTestWorker(BaseWorker): - """Test one AI model connection without blocking the GUI thread.""" - - def __init__(self, model_name, ai_models_path=None, db_path=None, diagnostic_log_dir=None): - super().__init__() - self.model_name = model_name - self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH - self.db_path = db_path - self.diagnostic_log_dir = diagnostic_log_dir - self._run_id = None - - def execute(self): - self._run_id = self._create_run_log() - started = time.monotonic() - self._log_run_event( - f"step=test_connection result=start detail=AI模型 {self.model_name}" - ) - try: - result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path) - except Exception as exc: - error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - elapsed_ms = self._elapsed_ms(started) - self._log_run_event( - f"step=test_connection result=failed detail={error} elapsed_ms={elapsed_ms}", - level="error", - ) - self._write_diagnostic_log( - "AI模型测试连接异常", - level="ERROR", - step="test_connection", - elapsed_ms=elapsed_ms, - payload={"model_name": self.model_name, "error": error}, - exc=exc, - ) - _safe_finish_run_log( - self._run_id, - db_path=self.db_path, - status="failed", - done=0, - failed_count=1, - summary_json={"ok": False, "name": self.model_name, "error": error}, - ) - raise - elapsed_ms = self._elapsed_ms(started) - payload = dict(appconfig.sanitize_for_log(result or {})) - payload["name"] = self.model_name - ok = bool(payload.get("ok")) - self._log_run_event( - "step=test_connection result={result} detail=AI模型 {name} status={status} error={error} elapsed_ms={elapsed_ms}".format( - result="success" if ok else "failed", - name=self.model_name, - status=payload.get("status") or "", - error=payload.get("error") or "", - elapsed_ms=elapsed_ms, - ), - level="info" if ok else "warning", - ) - _safe_finish_run_log( - self._run_id, - db_path=self.db_path, - status="done", - done=1, - success_count=1 if ok else 0, - failed_count=0 if ok else 1, - summary_json=payload, - ) - return payload - - def _create_run_log(self): - if not self.db_path: - return None - return _safe_create_run_log( - "ai_model_test", - db_path=self.db_path, - total=1, - options={"model_name": self.model_name}, - ) - - def _log_run_event(self, message, level="info"): - safe_message = _safe_add_run_log_event( - self._run_id, - message, - db_path=self.db_path, - level=level, - ) - self.log.emit(str(safe_message)) - - def _write_diagnostic_log( - self, - message, - level="INFO", - step=None, - elapsed_ms=None, - payload=None, - exc=None, - ): - _safe_write_diagnostic_log( - message, - level=level, - step=step, - elapsed_ms=elapsed_ms, - payload=payload, - exc=exc, - log_dir=self.diagnostic_log_dir, - ) - - def _elapsed_ms(self, started): - return _elapsed_ms(started) - - class SettingsTab(QWidget): - """Tab 5: AI model definitions stored in config/ai_models.json.""" - - CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")] - API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")] - RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"] - - def __init__( - self, - parent=None, - config=None, - config_path=None, - ai_models_path=None, - status_callback=None, - ): - super().__init__(parent) - self.config = appconfig.load_config() if config is None else config - self.config_path = ( - config_path - or self.config.get("config_path") - or appconfig.CONFIG_PATH - ) - self.ai_models_path = ( - ai_models_path - or self.config.get("ai_models_path") - or appconfig.AI_MODELS_PATH - ) - self.status_callback = status_callback - self.models = [] - self.current_model_name = None - self.test_worker = None - self.test_thread = None - self._compat_test_item_id = "" - - self.model_combo = QComboBox() - self.model_combo.setObjectName("aiModelCombo") - self.add_model_button = QPushButton("新增") - self.delete_model_button = QPushButton("删除") - - self.enabled_checkbox = QCheckBox("启用") - self.name_edit = QLineEdit() - self.name_edit.setObjectName("modelNameEdit") - self.category_combo = QComboBox() - self.category_combo.setObjectName("modelCategoryCombo") - for label, value in self.CATEGORY_ITEMS: - self.category_combo.addItem(label, value) - self.api_type_combo = QComboBox() - self.api_type_combo.setObjectName("modelApiTypeCombo") - for label, value in self.API_TYPE_ITEMS: - self.api_type_combo.addItem(label, value) - self.model_id_edit = QLineEdit() - self.model_id_edit.setObjectName("modelIdEdit") - self.url_edit = QLineEdit() - self.url_edit.setObjectName("modelUrlEdit") - self.api_key_edit = QLineEdit() - self.api_key_edit.setObjectName("modelApiKeyEdit") - self.api_key_edit.setEchoMode(QLineEdit.Password) - self.connect_timeout_spin = QSpinBox() - self.connect_timeout_spin.setObjectName("connectTimeoutSpin") - self.connect_timeout_spin.setRange(1, 3600) - self.connect_timeout_spin.setValue(30) - self.save_model_button = QPushButton("保存") - self.test_connection_button = QPushButton("测试连接") - self.test_result_label = QLabel("") - self.test_result_label.setWordWrap(True) - self.default_text_model_combo = QComboBox() - self.default_text_model_combo.setObjectName("defaultTextModelCombo") - self.default_image_model_combo = QComboBox() - self.default_image_model_combo.setObjectName("defaultImageModelCombo") - self.title_concurrency_spin = QSpinBox() - self.title_concurrency_spin.setObjectName("titleConcurrencySpin") - self.title_concurrency_spin.setRange(1, 64) - self.image_concurrency_spin = QSpinBox() - self.image_concurrency_spin.setObjectName("imageConcurrencySpin") - self.image_concurrency_spin.setRange(1, 64) - self.retry_spin = QSpinBox() - self.retry_spin.setObjectName("retrySpin") - self.retry_spin.setRange(0, 20) - self.resolution_combo = QComboBox() - self.resolution_combo.setObjectName("resolutionCombo") - for resolution in self.RESOLUTION_ITEMS: - self.resolution_combo.addItem(resolution, resolution) - self.response_timeout_label = QLabel("") - self.jpg_quality_spin = QSpinBox() - self.jpg_quality_spin.setObjectName("jpgQualitySpin") - self.jpg_quality_spin.setRange(1, 100) - self.chrome_path_edit = QLineEdit() - self.chrome_path_edit.setObjectName("chromePathEdit") - self.user_data_root_edit = QLineEdit() - self.user_data_root_edit.setObjectName("userDataRootEdit") - self.image_dir_edit = QLineEdit() - self.image_dir_edit.setObjectName("imageDirEdit") - self.db_path_edit = QLineEdit() - self.db_path_edit.setObjectName("dbPathEdit") - self.default_debug_port_spin = QSpinBox() - self.default_debug_port_spin.setObjectName("defaultDebugPortSpin") - self.default_debug_port_spin.setRange(1, 65535) - self.debug_port_start_spin = QSpinBox() - self.debug_port_start_spin.setObjectName("debugPortStartSpin") - self.debug_port_start_spin.setRange(1, 65535) - self.debug_port_end_spin = QSpinBox() - self.debug_port_end_spin.setObjectName("debugPortEndSpin") - self.debug_port_end_spin.setRange(1, 65535) - self.cdp_ready_timeout_spin = QSpinBox() - self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin") - self.cdp_ready_timeout_spin.setRange(1, 3600) - self.save_config_button = QPushButton("保存设置") - self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品") - self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox") - self.allow_cover_update_checkbox = QCheckBox("允许更新封面") - self.allow_cover_update_checkbox.setObjectName("allowCoverUpdateCheckbox") - self.max_items_per_run_spin = QSpinBox() - self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin") - self.max_items_per_run_spin.setRange(1, 9999) - self.max_items_per_run_spin.setToolTip("作为每批最大更新条数;正式更新会分批处理当前筛选全部可更新记录。") - self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页") - self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox") - self.parallel_accounts_checkbox = QCheckBox("多账号并行更新") - self.parallel_accounts_checkbox.setObjectName("parallelAccountsCheckbox") - self.max_parallel_accounts_spin = QSpinBox() - self.max_parallel_accounts_spin.setObjectName("maxParallelAccountsSpin") - self.max_parallel_accounts_spin.setRange(1, 16) - self.max_parallel_accounts_label = QLabel("最大并行账号数") - self.parallel_accounts_group = QWidget() - self.parallel_accounts_group.setObjectName("parallelAccountsGroup") - parallel_accounts_layout = QHBoxLayout(self.parallel_accounts_group) - parallel_accounts_layout.setContentsMargins(0, 0, 0, 0) - parallel_accounts_layout.setSpacing(12) - parallel_accounts_layout.addWidget(self.parallel_accounts_checkbox) - parallel_accounts_layout.addWidget(self.max_parallel_accounts_label) - parallel_accounts_layout.addWidget(self.max_parallel_accounts_spin) - parallel_accounts_layout.addStretch(1) - - model_picker_layout = QHBoxLayout() - model_picker_layout.addWidget(self.model_combo, 1) - model_picker_layout.addWidget(self.add_model_button) - model_picker_layout.addWidget(self.delete_model_button) - - action_layout = QHBoxLayout() - action_layout.addWidget(self.save_model_button) - action_layout.addWidget(self.test_connection_button) - action_layout.addStretch(1) - - form = self._three_column_form( - [ - ("状态", self.enabled_checkbox), - ("服务商名", self.name_edit), - ("类别", self.category_combo), - ("api_type", self.api_type_combo), - ("模型ID", self.model_id_edit), - ("连接超时(秒)", self.connect_timeout_spin), - ("网址", self.url_edit, True), - ("密钥", self.api_key_edit, True), - ] - ) - - ai_form = self._three_column_form( - [ - ("标题大模型", self.default_text_model_combo), - ("图片大模型", self.default_image_model_combo), - ("标题并发数", self.title_concurrency_spin), - ("图片并发数", self.image_concurrency_spin), - ("失败重试次数", self.retry_spin), - ("分辨率", self.resolution_combo), - ("返回超时", self.response_timeout_label), - ("jpg质量", self.jpg_quality_spin), - ] - ) - - port_range_layout = QHBoxLayout() - port_range_layout.setContentsMargins(0, 0, 0, 0) - port_range_layout.addWidget(self.debug_port_start_spin) - port_range_layout.addWidget(QLabel("到")) - port_range_layout.addWidget(self.debug_port_end_spin) - port_range_widget = QWidget() - port_range_widget.setLayout(port_range_layout) - - path_form = self._three_column_form( - [ - ("Chrome路径", self.chrome_path_edit, True), - ("账号数据根目录", self.user_data_root_edit), - ("图片目录", self.image_dir_edit), - ("DB路径", self.db_path_edit), - ("默认调试端口", self.default_debug_port_spin), - ("调试端口范围", port_range_widget), - ("CDP就绪超时(秒)", self.cdp_ready_timeout_spin), - ] - ) - - self.shopee_update_form_layout = self._three_column_form( - [ - ("每批最大更新条数", self.max_items_per_run_spin), - ("", self.allow_real_submit_checkbox), - ("", self.close_success_tab_checkbox), - ("", self.allow_cover_update_checkbox), - ("", self.parallel_accounts_group, 2), - ] - ) - - panel = QWidget() - panel.setMaximumWidth(1800) - panel_layout = QVBoxLayout(panel) - self.settings_panel_layout = panel_layout - panel_layout.setContentsMargins(13, 18, 13, 18) - self.ai_model_section_title = self._section_title( - "AI 模型", - "settingsAiModelSectionTitle", - ) - self.model_detail_section_title = self._section_title( - "模型详情", - "settingsModelDetailSectionTitle", - ) - self.generation_section_title = self._section_title( - "角色与生成参数", - "settingsGenerationSectionTitle", - ) - self.shopee_update_section_title = self._section_title( - "Shopee 更新安全 / 执行模式", - "settingsShopeeUpdateSectionTitle", - ) - self.infrastructure_section_title = self._section_title( - "基础设施(路径与端口)", - "settingsInfrastructureSectionTitle", - ) - panel_layout.addWidget(self.ai_model_section_title) - panel_layout.addLayout(model_picker_layout) - panel_layout.addSpacing(14) - panel_layout.addWidget(self.model_detail_section_title) - panel_layout.addLayout(form) - panel_layout.addLayout(action_layout) - panel_layout.addWidget(self.test_result_label) - panel_layout.addSpacing(18) - panel_layout.addWidget(self.generation_section_title) - panel_layout.addLayout(ai_form) - panel_layout.addSpacing(18) - panel_layout.addWidget(self.shopee_update_section_title) - panel_layout.addLayout(self.shopee_update_form_layout) - panel_layout.addSpacing(18) - panel_layout.addWidget(self.infrastructure_section_title) - panel_layout.addLayout(path_form) - panel_layout.addWidget(self.save_config_button) - panel_layout.addStretch(1) - - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll_content = QWidget() - scroll_layout = QHBoxLayout(scroll_content) - scroll_layout.setContentsMargins(0, 0, 0, 0) - scroll_layout.addStretch(1) - scroll_layout.addWidget(panel) - scroll_layout.addStretch(1) - scroll.setWidget(scroll_content) - - layout = QVBoxLayout(self) - layout.setContentsMargins(18, 18, 18, 18) - layout.addWidget(scroll, 1) - - self.model_combo.currentIndexChanged.connect(self.load_selected_model) - self.add_model_button.clicked.connect(self.add_model) - self.delete_model_button.clicked.connect(self.delete_model) - self.save_model_button.clicked.connect(self.save_model) - self.test_connection_button.clicked.connect(self.test_connection) - self.resolution_combo.currentIndexChanged.connect( - self._update_response_timeout_label - ) - self.save_config_button.clicked.connect(self.save_app_settings) - - self.refresh_models() - self._populate_app_settings() - - def _three_column_form(self, fields): - layout = QGridLayout() - layout.setHorizontalSpacing(18) - layout.setVerticalSpacing(8) - for column in (1, 3, 5): - layout.setColumnStretch(column, 1) - row = 0 - column_pair = 0 - for field in fields: - label = field[0] - widget = field[1] - span_pairs = self._form_field_span_pairs(field) - if span_pairs > 3 - column_pair: - row += 1 - column_pair = 0 - column = column_pair * 2 - self._add_form_field(layout, row, column, label, widget, span_pairs) - column_pair += span_pairs - if column_pair >= 3: - row += 1 - column_pair = 0 - return layout - - def _form_field_span_pairs(self, field): - if len(field) <= 2: - return 1 - span = field[2] - if isinstance(span, bool): - return 3 if span else 1 - return max(1, min(3, int(span or 1))) - - def _add_form_field(self, layout, row, column, label, widget, span_pairs): - if label: - layout.addWidget(QLabel(label), row, column) - layout.addWidget(widget, row, column + 1, 1, span_pairs * 2 - 1) - else: - layout.addWidget(widget, row, column, 1, span_pairs * 2) - - def _section_title(self, text, object_name): - label = QLabel(text) - label.setObjectName(object_name) - label.setStyleSheet("color: #24292f; font-weight: 600; padding-top: 4px;") - return label - - def _set_status(self, message): - if self.status_callback is not None: - self.status_callback(message) - - def refresh_models(self, selected=None): - try: - self.models = appconfig.list_ai_models( - path=self.ai_models_path, - reveal_api_key=True, - ) - except Exception as exc: - self.models = [] - self.current_model_name = None - self._show_error(exc) - - current = selected or self.current_model_name - self.model_combo.blockSignals(True) - self.model_combo.clear() - for model in self.models: - label = f"{model['name']} · {self._category_label(model['category'])}" - if not model.get("enabled", True): - label += " · 已停用" - self.model_combo.addItem(label, model["name"]) - index = self.model_combo.findData(current) - self.model_combo.setCurrentIndex(index if index >= 0 else (0 if self.models else -1)) - self.model_combo.blockSignals(False) - self.load_selected_model() - if hasattr(self, "default_text_model_combo"): - self._populate_role_model_combos() - - def load_selected_model(self, index=None): - name = self.model_combo.currentData() - model = self._model_by_name(name) - self.current_model_name = model["name"] if model else None - self._populate_form(model) - self._update_button_state() - - def add_model(self, checked=False): - name = self._unique_model_name("新文本模型") - model = { - "name": name, - "category": "text", - "enabled": True, - "url": "", - "model": "", - "api_key": "", - "api_type": "chat", - "connect_timeout_seconds": 30, - "timeout_seconds": 0, - "extra_body": {}, - } - try: - appconfig.add_ai_model(model, path=self.ai_models_path) - except Exception as exc: - self._show_error(exc) - return - self.refresh_models(selected=name) - self._set_status(f"AI 模型已新增:{name}") - - def save_model(self, checked=False): - model = self._form_values() - if model is None: - return - current = self._current_model() - if self._should_warn_plaintext_api_key(model, current): - self._show_plaintext_api_key_warning() - try: - if self.current_model_name is None: - appconfig.add_ai_model(model, path=self.ai_models_path) - else: - appconfig.update_ai_model( - self.current_model_name, - path=self.ai_models_path, - **model, - ) - except Exception as exc: - self._show_error(exc) - return - self.refresh_models(selected=model["name"]) - self._set_status(f"AI 模型已保存:{model['name']}") - - def delete_model(self, checked=False): - model = self._current_model() - if model is None: - return - if not self._can_delete_model(model): - self._set_status("每个类别至少保留一个模型,当前模型不能删除") - return - answer = QMessageBox.question( - self, - "删除 AI 模型", - f"确认删除模型「{model['name']}」?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if answer != QMessageBox.Yes: - return - try: - appconfig.delete_ai_model(model["name"], path=self.ai_models_path) - except Exception as exc: - self._show_error(exc) - return - self.refresh_models() - self._set_status(f"AI 模型已删除:{model['name']}") - - def test_connection(self, checked=False): - if self.test_thread is not None: - self._set_status("模型连接测试正在进行...") - return - model = self._current_model() - if model is None: - return - if self.name_edit.text().strip() != model["name"]: - self._set_status("请先保存模型名称变更后再测试连接") - return - worker = AIModelTestWorker( - model["name"], - ai_models_path=self.ai_models_path, - db_path=_database_path(config=self.config), - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - worker.finished.connect(self._on_test_finished) - worker.failed.connect(self._on_test_failed) - thread = run_worker(worker, thread_name="AIModelTestWorker", start=False) - thread.finished.connect(lambda: self._forget_test_thread(thread)) - self.test_worker = worker - self.test_thread = thread - self._set_test_running(True) - self.test_result_label.setText("正在测试连接...") - self._set_status(f"正在测试 AI 模型连接:{model['name']}") - thread.start() - - def save_app_settings(self, checked=False): - settings = self._app_settings_values() - if settings is None: - return - try: - saved = appconfig.save_config(settings, path=self.config_path) - except Exception as exc: - self._show_error(exc) - return - self._replace_config(saved) - self._populate_app_settings() - self._set_status("设置已保存") - QMessageBox.information(self, "保存设置", "设置已保存") - - def _app_settings_values(self): - start_port = self.debug_port_start_spin.value() - end_port = self.debug_port_end_spin.value() - default_port = self.default_debug_port_spin.value() - if start_port > end_port: - self._show_error("调试端口范围起始值不能大于结束值") - return None - if not (start_port <= default_port <= end_port): - self._show_error("默认调试端口必须在调试端口范围内") - return None - text_model = self.default_text_model_combo.currentData() - image_model = self.default_image_model_combo.currentData() - if not text_model or not image_model: - self._show_error("标题大模型和图片大模型不能为空") - return None - - ai_cfg = appconfig.ai_config(self.config) - ai_cfg.update( - { - "default_text_model": text_model, - "default_image_model": image_model, - "title_concurrency": self.title_concurrency_spin.value(), - "image_concurrency": self.image_concurrency_spin.value(), - "retry": self.retry_spin.value(), - "jpg_quality": self.jpg_quality_spin.value(), - "resolution": self.resolution_combo.currentData() or "1k", - "resolution_timeouts": dict(ai_cfg.get("resolution_timeouts", {})), - } - ) - - settings = { - key: value - for key, value in self.config.items() - if key not in {"config_path", "ai_models_path"} - } - settings.update( - { - "chrome_path": self.chrome_path_edit.text().strip(), - "user_data_root": self.user_data_root_edit.text().strip(), - "image_dir": self.image_dir_edit.text().strip(), - "db_path": self.db_path_edit.text().strip(), - "default_debug_port": default_port, - "debug_port_range": [start_port, end_port], - "cdp_ready_timeout": self.cdp_ready_timeout_spin.value(), - "ai": ai_cfg, - "shopee_update": { - "test_item_id": str(self._compat_test_item_id or ""), - "allow_real_submit": self.allow_real_submit_checkbox.isChecked(), - "allow_cover_update": self.allow_cover_update_checkbox.isChecked(), - "max_items_per_run": self.max_items_per_run_spin.value(), - "close_success_tab": self.close_success_tab_checkbox.isChecked(), - "dry_run": False, - "parallel_accounts": self.parallel_accounts_checkbox.isChecked(), - "max_parallel_accounts": self.max_parallel_accounts_spin.value(), - }, - } - ) - return settings - - def _replace_config(self, saved): - internal = {} - if self.config_path != appconfig.CONFIG_PATH: - internal["config_path"] = self.config_path - if self.ai_models_path != appconfig.AI_MODELS_PATH: - internal["ai_models_path"] = self.ai_models_path - self.config.clear() - self.config.update(saved) - self.config.update(internal) - - def _populate_app_settings(self): - self._populate_role_model_combos() - ai_cfg = appconfig.ai_config(self.config) - self._set_combo_by_data( - self.default_text_model_combo, - ai_cfg.get("default_text_model", ""), - ) - self._set_combo_by_data( - self.default_image_model_combo, - ai_cfg.get("default_image_model", ""), - ) - self.title_concurrency_spin.setValue( - int(ai_cfg.get("title_concurrency", 4) or 4) - ) - self.image_concurrency_spin.setValue( - int(ai_cfg.get("image_concurrency", 4) or 4) - ) - self.retry_spin.setValue(int(ai_cfg.get("retry", 2) or 0)) - self._set_combo_by_data( - self.resolution_combo, - str(ai_cfg.get("resolution", "1k")), - ) - self.jpg_quality_spin.setValue(int(ai_cfg.get("jpg_quality", 90) or 90)) - self.chrome_path_edit.setText(appconfig.chrome_path(self.config)) - self.user_data_root_edit.setText(appconfig.user_data_root(self.config)) - self.image_dir_edit.setText(appconfig.image_dir(self.config)) - self.db_path_edit.setText(appconfig.db_path(self.config)) - self.default_debug_port_spin.setValue( - int(appconfig.default_debug_port(self.config)) - ) - start_port, end_port = appconfig.debug_port_range(self.config) - self.debug_port_start_spin.setValue(int(start_port)) - self.debug_port_end_spin.setValue(int(end_port)) - self.cdp_ready_timeout_spin.setValue( - int(appconfig.cdp_ready_timeout(self.config)) - ) - update_cfg = self._shopee_update_config() - self._compat_test_item_id = str(update_cfg.get("test_item_id", "")) - self.allow_real_submit_checkbox.setChecked( - bool(update_cfg.get("allow_real_submit", False)) - ) - self.allow_cover_update_checkbox.setChecked( - bool(update_cfg.get("allow_cover_update", False)) - ) - self.max_items_per_run_spin.setValue( - max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) - ) - self.close_success_tab_checkbox.setChecked( - bool(update_cfg.get("close_success_tab", False)) - ) - self.parallel_accounts_checkbox.setChecked( - bool(update_cfg.get("parallel_accounts", False)) - ) - self.max_parallel_accounts_spin.setValue( - max(1, int(update_cfg.get("max_parallel_accounts", 2) or 2)) - ) - self._update_response_timeout_label() - - def _shopee_update_config(self): - defaults = appconfig.default_config().get("shopee_update", {}) - loaded = self.config.get("shopee_update", {}) - if not isinstance(loaded, dict): - loaded = {} - merged = dict(defaults) - merged.update(loaded) - return merged - - def _populate_role_model_combos(self): - ai_cfg = appconfig.ai_config(self.config) - self._populate_role_combo( - self.default_text_model_combo, - "text", - ai_cfg.get("default_text_model"), - ) - self._populate_role_combo( - self.default_image_model_combo, - "image", - ai_cfg.get("default_image_model"), - ) - - def _populate_role_combo(self, combo, category, selected): - combo.blockSignals(True) - combo.clear() - for model in self.models: - if model.get("category") == category and model.get("enabled", True): - combo.addItem(model.get("name", ""), model.get("name", "")) - if combo.count() == 0: - combo.addItem("无可用模型", None) - index = combo.findData(selected) - combo.setCurrentIndex(index if index >= 0 else 0) - combo.blockSignals(False) - - def _update_response_timeout_label(self, index=None): - ai_cfg = appconfig.ai_config(self.config) - resolution = self.resolution_combo.currentData() or ai_cfg.get("resolution", "1k") - timeouts = ai_cfg.get("resolution_timeouts", {}) - timeout = timeouts.get(str(resolution)) - if timeout is None: - self.response_timeout_label.setText("未配置") - return - self.response_timeout_label.setText(f"{int(timeout)} 秒") - - def _form_values(self): - current = self._current_model() or {} - name = self.name_edit.text().strip() - if not name: - self._show_error("AI 模型服务商名不能为空") - return None - extra_body = current.get("extra_body", {}) - if not isinstance(extra_body, dict): - extra_body = {} - return { - "name": name, - "category": self.category_combo.currentData() or "text", - "enabled": self.enabled_checkbox.isChecked(), - "url": self.url_edit.text().strip(), - "model": self.model_id_edit.text().strip(), - "api_key": self.api_key_edit.text(), - "api_type": self.api_type_combo.currentData() or "auto", - "connect_timeout_seconds": self.connect_timeout_spin.value(), - "timeout_seconds": int(current.get("timeout_seconds", 0) or 0), - "extra_body": dict(extra_body), - } - - def _populate_form(self, model): - widgets = [ - self.enabled_checkbox, - self.name_edit, - self.category_combo, - self.api_type_combo, - self.model_id_edit, - self.url_edit, - self.api_key_edit, - self.connect_timeout_spin, - ] - for widget in widgets: - widget.blockSignals(True) - if model is None: - self.enabled_checkbox.setChecked(False) - self.name_edit.clear() - self.category_combo.setCurrentIndex(0) - self.api_type_combo.setCurrentIndex(0) - self.model_id_edit.clear() - self.url_edit.clear() - self.api_key_edit.clear() - self.connect_timeout_spin.setValue(30) - else: - self.enabled_checkbox.setChecked(bool(model.get("enabled", True))) - self.name_edit.setText(model.get("name", "")) - self._set_combo_by_data(self.category_combo, model.get("category", "text")) - self._set_combo_by_data(self.api_type_combo, model.get("api_type", "auto")) - self.model_id_edit.setText(model.get("model", "")) - self.url_edit.setText(model.get("url", "")) - self.api_key_edit.setText(model.get("api_key", "")) - self.connect_timeout_spin.setValue( - int(model.get("connect_timeout_seconds", 30) or 30) - ) - for widget in widgets: - widget.blockSignals(False) - - def _set_combo_by_data(self, combo, value): - index = combo.findData(value) - combo.setCurrentIndex(index if index >= 0 else 0) - - def _update_button_state(self): - has_model = self._current_model() is not None - testing = self.test_thread is not None - for widget in ( - self.enabled_checkbox, - self.name_edit, - self.category_combo, - self.api_type_combo, - self.model_id_edit, - self.url_edit, - self.api_key_edit, - self.connect_timeout_spin, - self.save_model_button, - ): - widget.setEnabled(has_model and not testing) - self.add_model_button.setEnabled(not testing) - self.delete_model_button.setEnabled( - has_model and not testing and self._can_delete_model(self._current_model()) - ) - self.test_connection_button.setEnabled(has_model and not testing) - - def _set_test_running(self, running): - self._update_button_state() - self.test_connection_button.setEnabled( - not running and self._current_model() is not None - ) - - def _forget_test_thread(self, thread): - if self.test_thread is thread: - self.test_thread = None - self.test_worker = None - self._set_test_running(False) - - def _on_test_finished(self, payload): - if payload.get("ok"): - status = payload.get("status") - suffix = f"(HTTP {status})" if status else "" - message = f"测试连接成功:{payload.get('name')}{suffix}" - else: - error = payload.get("error") or "连接失败" - status = payload.get("status") - status_text = f"HTTP {status}," if status else "" - message = f"测试连接失败:{status_text}{error}" - self.test_result_label.setText(message) - self._set_status(message) - - def _on_test_failed(self, _task_id, error): - message = f"测试连接失败:{error}" - self.test_result_label.setText(message) - self._set_status(message) - - def _show_error(self, error): - message = str(error) - QMessageBox.warning(self, "设置", message) - self._set_status(message) - - def _should_warn_plaintext_api_key(self, model, current): - new_key = str((model or {}).get("api_key") or "") - current_key = str((current or {}).get("api_key") or "") - return bool(new_key) and new_key != current_key - - def _show_plaintext_api_key_warning(self): - QMessageBox.warning( - self, - PLAINTEXT_SECRET_TITLE, - PLAINTEXT_API_KEY_WARNING, - ) - - def _current_model(self): - return self._model_by_name(self.current_model_name) - - def _model_by_name(self, name): - for model in self.models: - if model.get("name") == name: - return model - return None - - def _unique_model_name(self, base): - names = {model.get("name") for model in self.models} - if base not in names: - return base - counter = 2 - while f"{base} {counter}" in names: - counter += 1 - return f"{base} {counter}" - - def _can_delete_model(self, model): - if model is None: - return False - category = model.get("category") - return sum(1 for item in self.models if item.get("category") == category) > 1 - - def _category_label(self, category): - return {"text": "文本", "image": "图像"}.get(category, category) - - - class AccountsTab(QWidget): - COLUMNS = ["账号名", "别名", "地区", "端口", "登录状态", "备注"] - - 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.account_rows = [] - self.login_statuses = {} - self.threads = [] - - self.table = QTableWidget(0, len(self.COLUMNS)) - self.table.setHorizontalHeaderLabels(self.COLUMNS) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.table.setSelectionMode(QAbstractItemView.SingleSelection) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) - self.table.verticalHeader().setVisible(False) - - self.add_button = QPushButton("新增") - self.edit_button = QPushButton("编辑") - self.delete_button = QPushButton("删除") - self.delete_button.setObjectName("deleteAccountButton") - self.delete_button.setStyleSheet(_danger_outline_button_style("deleteAccountButton")) - self.launch_button = QPushButton("启动登录") - self.check_button = QPushButton("检测登录") - self.shortcut_button = QPushButton("快捷方式") - - toolbar = QHBoxLayout() - for button in ( - self.add_button, - self.edit_button, - self.delete_button, - self.launch_button, - self.check_button, - self.shortcut_button, - ): - toolbar.addWidget(button) - toolbar.addStretch(1) - - self.empty_label = QLabel("") - - layout = QVBoxLayout(self) - layout.setContentsMargins(18, 18, 18, 18) - layout.addLayout(toolbar) - layout.addWidget(self.table, 1) - layout.addWidget(self.empty_label) - - self.add_button.clicked.connect(self.add_account) - self.edit_button.clicked.connect(self.edit_account) - self.delete_button.clicked.connect(self.delete_account) - self.launch_button.clicked.connect(self.launch_login) - self.check_button.clicked.connect(self.check_login) - self.shortcut_button.clicked.connect(self.create_shortcut) - self.table.itemSelectionChanged.connect(self._update_button_state) - self.table.doubleClicked.connect(self.edit_account) - - self.refresh_accounts() - - def _set_status(self, message): - if self.status_callback is not None: - self.status_callback(message) - - def _selected_account(self): - selected = self.table.selectionModel().selectedRows() - if not selected: - return None - row = selected[0].row() - if row < 0 or row >= len(self.account_rows): - return None - return self.account_rows[row] - - def _update_button_state(self): - has_selection = self._selected_account() is not None - for button in ( - self.edit_button, - self.delete_button, - self.launch_button, - self.check_button, - self.shortcut_button, - ): - button.setEnabled(has_selection) - - def refresh_accounts(self): - try: - self.account_rows = accounts.list_accounts( - path=self.db_path, - config=self.config, - ) - except Exception as exc: - self.account_rows = [] - self._set_status(f"账号读取失败:{exc}") - - self.table.setRowCount(len(self.account_rows)) - for row, account in enumerate(self.account_rows): - status = self.login_statuses.get(account.alias, "未知") - values = [ - account.account_name, - account.alias, - account.region_host, - str(account.debug_port), - _login_status_display(status), - account.note or "", - ] - for column, value in enumerate(values): - item = QTableWidgetItem(value) - if column == 4: - item.setForeground(_login_status_color(status)) - self.table.setItem(row, column, item) - self.empty_label.setText("" if self.account_rows else "暂无账号") - self._update_button_state() - - def _show_error(self, message): - QMessageBox.warning(self, "账号管理", str(message)) - self._set_status(str(message)) - - def add_account(self, checked=False): - try: - default_port = accounts.next_debug_port( - path=self.db_path, - config=self.config, - ) - except Exception: - default_port = appconfig.default_debug_port(self.config) - dialog = AccountDialog(self, default_port=default_port, config=self.config) - if dialog.exec() != QDialog.Accepted: - return - values = dialog.values() - if self._should_warn_plaintext_password(values): - self._show_plaintext_password_warning() - try: - accounts.create_account( - path=self.db_path, - config=self.config, - **values, - ) - except Exception as exc: - self._show_error(exc) - return - self.refresh_accounts() - self._set_status("账号已新增") - - def edit_account(self, checked=False): - account = self._selected_account() - if account is None: - return - dialog = AccountDialog( - self, - account=account, - default_port=account.debug_port, - config=self.config, - ) - if dialog.exec() != QDialog.Accepted: - return - values = dialog.values() - if self._should_warn_plaintext_password(values, account): - self._show_plaintext_password_warning() - try: - updated = accounts.update_account( - account.alias, - path=self.db_path, - config=self.config, - **values, - ) - except Exception as exc: - self._show_error(exc) - return - if updated.alias != account.alias: - self.login_statuses.pop(account.alias, None) - self.refresh_accounts() - self._set_status("账号已保存") - - def delete_account(self, checked=False): - account = self._selected_account() - if account is None: - return - answer = QMessageBox.question( - self, - "删除账号", - f"确认删除账号「{account.alias}」?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if answer != QMessageBox.Yes: - return - try: - accounts.delete_account(account.alias, path=self.db_path, config=self.config) - except Exception as exc: - self._show_error(exc) - return - self.login_statuses.pop(account.alias, None) - self.refresh_accounts() - self._set_status("账号已删除") - - def launch_login(self, checked=False): - account = self._selected_account() - if account is None: - return - run_id = _safe_create_run_log( - "chrome_launch", - db_path=self.db_path, - total=1, - options={"alias": account.alias, "debug_port": account.debug_port}, - ) - started = time.monotonic() - _safe_add_run_log_event( - run_id, - f"step=launch_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", - db_path=self.db_path, - account=account, - ) - try: - process = accounts.launch_for_login(account, config=self.config) - except Exception as exc: - elapsed_ms = _elapsed_ms(started) - safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) - _safe_add_run_log_event( - run_id, - f"step=launch_chrome result=failed detail={safe_error} elapsed_ms={elapsed_ms}", - db_path=self.db_path, - account=account, - level="error", - ) - _safe_write_diagnostic_log( - "Chrome启动失败", - level="ERROR", - step="launch_chrome", - account=account, - elapsed_ms=elapsed_ms, - payload={"alias": account.alias, "debug_port": account.debug_port, "error": safe_error}, - exc=exc, - log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - _safe_finish_run_log( - run_id, - db_path=self.db_path, - status="failed", - done=0, - failed_count=1, - summary_json={"ok": False, "alias": account.alias, "error": safe_error}, - ) - self._show_error(safe_error) - return - elapsed_ms = _elapsed_ms(started) - pid = getattr(process, "pid", None) - _safe_add_run_log_event( - run_id, - f"step=launch_chrome result=success detail=账号 {account.alias} pid={pid or ''} elapsed_ms={elapsed_ms}", - db_path=self.db_path, - account=account, - ) - _safe_finish_run_log( - run_id, - db_path=self.db_path, - status="done", - done=1, - success_count=1, - failed_count=0, - summary_json={"ok": True, "alias": account.alias, "debug_port": account.debug_port, "pid": pid}, - ) - self.login_statuses[account.alias] = "已启动" - self.refresh_accounts() - self._set_status("Chrome 已启动,请人工登录") - - def check_login(self, checked=False): - account = self._selected_account() - if account is None: - return - self.login_statuses[account.alias] = "检测中" - self.refresh_accounts() - worker = AccountLoginCheckWorker( - account, - db_path=self.db_path, - config=self.config, - diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, - ) - worker.finished.connect(self._on_login_check_finished) - worker.failed.connect( - lambda _task_id, error, alias=account.alias: self._on_login_check_failed( - alias, - error, - ) - ) - thread = run_worker(worker, start=False) - thread.finished.connect(lambda: self._forget_thread(thread)) - self.threads.append(thread) - thread.start() - - def _forget_thread(self, thread): - if thread in self.threads: - self.threads.remove(thread) - - def _on_login_check_finished(self, payload): - if payload.get("ok") is False and not payload.get("alias"): - return - alias = payload.get("alias") - status = payload.get("status") or {} - if alias: - self.login_statuses[alias] = accounts.login_status_text(status) - self.refresh_accounts() - self._set_status("登录状态已刷新") - - def _on_login_check_failed(self, alias, error): - self.login_statuses[alias] = "检测失败" - self.refresh_accounts() - self._set_status(f"登录检测失败:{error}") - - def create_shortcut(self, checked=False): - account = self._selected_account() - if account is None: - return - try: - shortcut_path = accounts.create_shortcut(account, config=self.config) - except Exception as exc: - self._show_error(exc) - return - self._set_status(f"快捷方式已生成:{shortcut_path}") - QMessageBox.information(self, "账号管理", f"快捷方式已生成:\n{shortcut_path}") - - def _should_warn_plaintext_password(self, values, account=None): - new_password = str((values or {}).get("password") or "") - current_password = str(getattr(account, "password", None) or "") - return bool(new_password) and new_password != current_password - - def _show_plaintext_password_warning(self): - QMessageBox.warning( - self, - PLAINTEXT_SECRET_TITLE, - PLAINTEXT_PASSWORD_WARNING, - ) - - - class MainWindow(QMainWindow): - """Main application window with the fixed five-tab workflow.""" - - def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=None): - super().__init__() - self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config - self.config_path = ( - config_path - or self.config.get("config_path") - or appconfig.CONFIG_PATH - ) - self.db_path = _database_path(db_path, self.config) - self.ai_models_path = ( - ai_models_path - or self.config.get("ai_models_path") - or appconfig.AI_MODELS_PATH - ) - self.setWindowTitle("cmshopee") - self.resize(1180, 760) - self.tabs = QTabWidget() - self.tabs.setObjectName("mainTabs") - self.tabs.setStyleSheet(TAB_STYLE) - self.tabs.currentChanged.connect(self._on_tab_changed) - for title in TAB_TITLES: - self.tabs.addTab(self._build_tab(title), title) - self.tabs.setTabIcon(TAB_TITLES.index("③ 更新shopee"), _warning_dot_icon()) - self.setCentralWidget(self.tabs) - self.statusBar().showMessage("就绪") - - def _build_tab(self, title): - if title == "① 导入采集": - return CollectTab( - db_path=self.db_path, - 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( - db_path=self.db_path, - config=self.config, - config_path=self.config_path, - status_callback=self.statusBar().showMessage, - open_accounts_callback=lambda: self.open_accounts_tab(), - ) - if title == "③ 更新shopee": - return ApplyTab( - db_path=self.db_path, - config=self.config, - status_callback=self.statusBar().showMessage, - open_accounts_callback=lambda: self.open_accounts_tab(), - open_settings_callback=lambda: self.open_settings_tab(), - ) - if title == "④ 账号管理": - return AccountsTab( - db_path=self.db_path, - config=self.config, - status_callback=self.statusBar().showMessage, - ) - return SettingsTab( - config=self.config, - config_path=self.config_path, - ai_models_path=self.ai_models_path, - 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)}") - - def open_accounts_tab(self): - self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理")) - - def open_settings_tab(self): - self.tabs.setCurrentIndex(TAB_TITLES.index("⑤ 设置")) -else: - class MainWindow(QMainWindow): - def __init__(self): - raise RuntimeError("PySide6 未安装,无法启动 GUI") - - -def _ensure_offscreen_for_headless_tests(): - if "PYTEST_CURRENT_TEST" in os.environ and "QT_QPA_PLATFORM" not in os.environ: - os.environ["QT_QPA_PLATFORM"] = "offscreen" - - -def main() -> int: - if QT_IMPORT_ERROR is not None: - print("cmshopee GUI 无法启动:当前 Python 环境未安装 PySide6。") - return 1 - _ensure_offscreen_for_headless_tests() - app = QApplication.instance() or QApplication(sys.argv) - window = MainWindow() - window.show() - return app.exec() diff --git a/app/gui/__init__.py b/app/gui/__init__.py new file mode 100644 index 0000000..aa1ec9e --- /dev/null +++ b/app/gui/__init__.py @@ -0,0 +1,50 @@ +"""PySide6 GUI package entry point.""" + +from __future__ import annotations + +import os +import sys + +from . import widgets as _widgets +from .widgets import * + +QT_IMPORT_ERROR = _widgets.QT_IMPORT_ERROR +QMessageBox = _widgets._RawQMessageBox +run_worker = _widgets._raw_run_worker if QT_IMPORT_ERROR is None else _widgets.run_worker + +if QT_IMPORT_ERROR is None: + from .models import ApplyTaskTableModel, GenerateTaskTableModel, TaskTableModel + from .workers import ( + AccountLoginCheckWorker, + AIModelTestWorker, + ApplyWorker, + CollectWorker, + GenerateWorker, + WriteBackWorker, + ) + from .tabs.accounts import AccountDialog, AccountsTab + from .tabs.apply import ApplyTab + from .tabs.collect import CollectTab + from .tabs.generate import GenerateTab + from .tabs.settings import SettingsTab + from .main_window import MainWindow +else: + class MainWindow(QMainWindow): + def __init__(self): + raise RuntimeError("PySide6 未安装,无法启动 GUI") + + +def _ensure_offscreen_for_headless_tests(): + if "PYTEST_CURRENT_TEST" in os.environ and "QT_QPA_PLATFORM" not in os.environ: + os.environ["QT_QPA_PLATFORM"] = "offscreen" + + +def main() -> int: + if QT_IMPORT_ERROR is not None: + print("cmshopee GUI 无法启动:当前 Python 环境未安装 PySide6。") + return 1 + _ensure_offscreen_for_headless_tests() + app = QApplication.instance() or QApplication(sys.argv) + window = MainWindow() + window.show() + return app.exec() \ No newline at end of file diff --git a/app/gui/main_window.py b/app/gui/main_window.py new file mode 100644 index 0000000..f720680 --- /dev/null +++ b/app/gui/main_window.py @@ -0,0 +1,91 @@ +"""Main GUI window.""" + +from __future__ import annotations + +from .tabs.accounts import AccountsTab +from .tabs.apply import ApplyTab +from .tabs.collect import CollectTab +from .tabs.generate import GenerateTab +from .tabs.settings import SettingsTab +from .widgets import * +class MainWindow(QMainWindow): + """Main application window with the fixed five-tab workflow.""" + + def __init__(self, db_path=None, config=None, config_path=None, ai_models_path=None): + super().__init__() + self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config + self.config_path = ( + config_path + or self.config.get("config_path") + or appconfig.CONFIG_PATH + ) + self.db_path = _database_path(db_path, self.config) + self.ai_models_path = ( + ai_models_path + or self.config.get("ai_models_path") + or appconfig.AI_MODELS_PATH + ) + self.setWindowTitle("cmshopee") + self.resize(1180, 760) + self.tabs = QTabWidget() + self.tabs.setObjectName("mainTabs") + self.tabs.setStyleSheet(TAB_STYLE) + self.tabs.currentChanged.connect(self._on_tab_changed) + for title in TAB_TITLES: + self.tabs.addTab(self._build_tab(title), title) + self.tabs.setTabIcon(TAB_TITLES.index("③ 更新shopee"), _warning_dot_icon()) + self.setCentralWidget(self.tabs) + self.statusBar().showMessage("就绪") + + def _build_tab(self, title): + if title == "① 导入采集": + return CollectTab( + db_path=self.db_path, + 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( + db_path=self.db_path, + config=self.config, + config_path=self.config_path, + status_callback=self.statusBar().showMessage, + open_accounts_callback=lambda: self.open_accounts_tab(), + ) + if title == "③ 更新shopee": + return ApplyTab( + db_path=self.db_path, + config=self.config, + status_callback=self.statusBar().showMessage, + open_accounts_callback=lambda: self.open_accounts_tab(), + open_settings_callback=lambda: self.open_settings_tab(), + ) + if title == "④ 账号管理": + return AccountsTab( + db_path=self.db_path, + config=self.config, + status_callback=self.statusBar().showMessage, + ) + return SettingsTab( + config=self.config, + config_path=self.config_path, + ai_models_path=self.ai_models_path, + 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)}") + + def open_accounts_tab(self): + self.tabs.setCurrentIndex(TAB_TITLES.index("④ 账号管理")) + + def open_settings_tab(self): + self.tabs.setCurrentIndex(TAB_TITLES.index("⑤ 设置")) diff --git a/app/gui/models.py b/app/gui/models.py new file mode 100644 index 0000000..53a1bea --- /dev/null +++ b/app/gui/models.py @@ -0,0 +1,386 @@ +"""Task table models for GUI tabs.""" + +from __future__ import annotations + +from .widgets import * +class TaskTableModel(QAbstractTableModel): + """Table model for task rows shared by workflow tabs.""" + + HEADERS = ["账号", "别名", "商品ID", "阶段"] + + STAGE_TEXT = { + "imported": "待采集", + "collected": "已采集", + "generated": "已生成", + "applied": "已更新", + } + + STATUS_TEXT = { + "running": "处理中", + "failed": "失败", + "skipped": "略过", + "cancelled": "已取消", + } + + def __init__(self, parent=None): + super().__init__(parent) + self.tasks = [] + self.all_tasks = [] + self.account_by_alias = {} + self.filter_mode = "all" + + def set_tasks(self, tasks, accounts): + self.beginResetModel() + self.all_tasks = list(tasks) + self.account_by_alias = { + str(account.alias).strip(): account + for account in accounts + if str(account.alias).strip() + } + self.tasks = self._filtered_tasks() + self.endResetModel() + + def set_filter_mode(self, mode): + self.beginResetModel() + self.filter_mode = mode if mode in {"all", "unmatched"} else "all" + self.tasks = self._filtered_tasks() + self.endResetModel() + + def _filtered_tasks(self): + if self.filter_mode == "unmatched": + return [task for task in self.all_tasks if self.is_unmatched(task)] + return list(self.all_tasks) + + 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.ForegroundRole and index.column() == 3: + return self._stage_color(task) + if role == Qt.ToolTipRole and self.is_unmatched(task): + return "别名未匹配账号,采集时将略过" + 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 is_unmatched(self, task) -> bool: + return str(task.alias).strip() not in self.account_by_alias + + def unmatched_count(self) -> int: + return sum(1 for task in self.all_tasks if self.is_unmatched(task)) + + def _account_name(self, task) -> str: + account = self.account_by_alias.get(str(task.alias).strip()) + if account is not None: + return account.account_name + return task.account_name or "" + + def _stage_text(self, task) -> str: + if self.is_unmatched(task): + return "略过" + if task.status in self.STATUS_TEXT and task.status != "pending": + return self.STATUS_TEXT[task.status] + return self.STAGE_TEXT.get(task.stage, task.stage) + + def _stage_color(self, task): + if self.is_unmatched(task): + return _qcolor(COLOR_MUTED) + base_color = _status_base_color(getattr(task, "status", None)) + if base_color is not None: + return _qcolor(base_color) + if getattr(task, "stage", None) in {"collected", "generated", "applied"}: + return _qcolor(COLOR_SUCCESS) + return _qcolor(COLOR_PENDING) + + def _display_value(self, task, column): + values = [ + self._account_name(task), + task.alias, + task.item_id, + self._stage_text(task), + ] + return values[column] if 0 <= column < len(values) else None + + +class GenerateTaskTableModel(QAbstractTableModel): + """Table model for Tab 2 generation candidates.""" + + HEADERS = ["店铺", "商品ID", "旧标题", "新标题", "状态"] + + STATUS_TEXT = { + "running": "处理中", + "failed": "失败", + "skipped": "略过", + "cancelled": "已取消", + } + + STAGE_TEXT = { + "imported": "未采集", + "collected": "待生成", + "generated": "已生成", + "applied": "已更新", + } + + def __init__(self, parent=None, db_path=None, status_callback=None): + super().__init__(parent) + self.tasks = [] + self.account_by_alias = {} + self.db_path = db_path + self.status_callback = status_callback + self.last_edit_error = None + + 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 in (Qt.DisplayRole, Qt.EditRole): + return self._display_value(task, index.column()) + if role == Qt.ForegroundRole and index.column() == 4: + return self._status_color(task) + if role == Qt.ToolTipRole: + if index.column() == 3 and self._can_edit_title(task): + return "双击可微调新标题,只修改本地待更新内容" + if task.last_error: + return task.last_error + return None + + def setData(self, index, value, role=Qt.EditRole): + if role != Qt.EditRole or not index.isValid() or index.column() != 3: + return False + task = self.tasks[index.row()] + if not self._can_edit_title(task): + self._set_status("该任务不能修改新标题") + return False + title = str(value or "").strip() + if title == str(task.new_title or ""): + return True + try: + db.update_generated_title(task.id, title, path=self.db_path) + updated = db.get_task(task.id, path=self.db_path) + except Exception as exc: + self.last_edit_error = str(exc) + self._set_status(f"新标题修改失败:{exc}") + return False + self.tasks[index.row()] = updated + self.last_edit_error = None + self.dataChanged.emit(index, index, [Qt.DisplayRole, Qt.EditRole, Qt.ToolTipRole]) + self._set_status(f"已修改新标题:商品 {task.item_id}") + return True + + def flags(self, index): + if not index.isValid(): + return Qt.NoItemFlags + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if index.column() == 3 and self._can_edit_title(self.tasks[index.row()]): + flags |= Qt.ItemIsEditable + return flags + + def _can_edit_title(self, task): + return ( + getattr(task, "stage", None) == "generated" + and getattr(task, "status", None) != "running" + and int(getattr(task, "committed", 0) or 0) == 0 + and bool(getattr(task, "new_title", None)) + ) + + def _set_status(self, message): + if self.status_callback is not None: + self.status_callback(message) + + 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): + return self._account_name(task) + + def _account_name(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 _status_text(self, task): + if task.status in self.STATUS_TEXT and task.status != "pending": + return self.STATUS_TEXT[task.status] + return self.STAGE_TEXT.get(task.stage, task.stage) + + def _status_color(self, task): + base_color = _status_base_color(getattr(task, "status", None)) + if base_color is not None: + return _qcolor(base_color) + if getattr(task, "stage", None) in {"generated", "applied"}: + return _qcolor(COLOR_SUCCESS) + return _qcolor(COLOR_PENDING) + + def _display_value(self, task, column): + values = [ + self._account_name(task), + task.item_id, + task.old_title or "", + task.new_title or "", + self._status_text(task), + ] + 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.ForegroundRole: + if index.column() == 4: + return self._stage_color(task) + if index.column() == 5: + return self._result_color(task) + 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) + + def _stage_color(self, task): + if getattr(task, "stage", None) == "applied": + return _qcolor(COLOR_SUCCESS) + return _qcolor(COLOR_PENDING) + + def _result_color(self, task): + base_color = _status_base_color(getattr(task, "status", None)) + if base_color is not None: + return _qcolor(base_color) + if getattr(task, "status", None) == "pending": + return _qcolor(COLOR_PENDING) + if getattr(task, "status", None) == "success" and getattr(task, "stage", None) == "generated": + return _qcolor(COLOR_PENDING) + if getattr(task, "stage", None) == "applied" or getattr(task, "status", None) == "success": + return _qcolor(COLOR_SUCCESS) + return _qcolor(COLOR_PENDING) + diff --git a/app/gui/tabs/__init__.py b/app/gui/tabs/__init__.py new file mode 100644 index 0000000..2f00ad2 --- /dev/null +++ b/app/gui/tabs/__init__.py @@ -0,0 +1 @@ +"""GUI tab modules.""" \ No newline at end of file diff --git a/app/gui/tabs/accounts.py b/app/gui/tabs/accounts.py new file mode 100644 index 0000000..c8b7b76 --- /dev/null +++ b/app/gui/tabs/accounts.py @@ -0,0 +1,434 @@ +"""Tab 4: account management UI.""" + +from __future__ import annotations + +from ..widgets import * +from ..workers import AccountLoginCheckWorker as _RealAccountLoginCheckWorker +class AccountDialog(QDialog): + """Dialog for adding or editing one account.""" + + def __init__(self, parent=None, account=None, default_port=9222, config=None): + super().__init__(parent) + self._account = account + self._config = config + self.setWindowTitle("编辑账号" if account else "新增账号") + + self.account_name_edit = QLineEdit() + self.alias_edit = QLineEdit() + self.region_host_edit = QLineEdit(accounts.DEFAULT_REGION_HOST) + self.debug_port_spin = QSpinBox() + self.debug_port_spin.setRange(1, 65535) + self.debug_port_spin.setValue(int(default_port)) + self.password_edit = QLineEdit() + self.password_edit.setEchoMode(QLineEdit.Password) + self.note_edit = QPlainTextEdit() + self.note_edit.setMaximumHeight(76) + self.slug_edit = QLineEdit() + self.slug_edit.setReadOnly(True) + self.user_data_dir_edit = QLineEdit() + self.user_data_dir_edit.setReadOnly(True) + + if account is not None: + self.account_name_edit.setText(account.account_name) + self.alias_edit.setText(account.alias) + self.region_host_edit.setText(account.region_host) + self.debug_port_spin.setValue(int(account.debug_port)) + self.password_edit.setText(account.password or "") + self.note_edit.setPlainText(account.note or "") + self.slug_edit.setText(account.slug) + self.user_data_dir_edit.setText(account.user_data_dir) + + form = QFormLayout() + form.addRow("账号名", self.account_name_edit) + form.addRow("别名", self.alias_edit) + form.addRow("地区", self.region_host_edit) + form.addRow("调试端口", self.debug_port_spin) + form.addRow("密码", self.password_edit) + form.addRow("备注", self.note_edit) + form.addRow("slug", self.slug_edit) + form.addRow("数据目录", self.user_data_dir_edit) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + + layout = QVBoxLayout(self) + layout.addLayout(form) + layout.addWidget(buttons) + + self.alias_edit.textChanged.connect(self._update_path_preview) + self._update_path_preview() + + def _update_path_preview(self): + alias = self.alias_edit.text().strip() + if not alias: + self.slug_edit.clear() + self.user_data_dir_edit.clear() + return + try: + slug = account_config.make_slug(alias) + self.slug_edit.setText(slug) + if self._account is not None and alias == self._account.alias: + self.user_data_dir_edit.setText(self._account.user_data_dir) + else: + self.user_data_dir_edit.setText( + accounts.preview_user_data_dir(alias, config=self._config) + ) + except Exception: + self.slug_edit.clear() + self.user_data_dir_edit.clear() + + def values(self): + return { + "account_name": self.account_name_edit.text().strip(), + "alias": self.alias_edit.text().strip(), + "region_host": self.region_host_edit.text().strip(), + "debug_port": self.debug_port_spin.value(), + "password": self.password_edit.text(), + "note": self.note_edit.toPlainText().strip(), + } + + + + +def _new_account_dialog(*args, **kwargs): + dialog_class = _package_attr("AccountDialog", AccountDialog) + return dialog_class(*args, **kwargs) + + +def AccountLoginCheckWorker(*args, **kwargs): + return _call_package_attr("AccountLoginCheckWorker", _RealAccountLoginCheckWorker, *args, **kwargs) + +class AccountsTab(QWidget): + COLUMNS = ["账号名", "别名", "地区", "端口", "登录状态", "备注"] + + 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.account_rows = [] + self.login_statuses = {} + self.threads = [] + + self.table = QTableWidget(0, len(self.COLUMNS)) + self.table.setHorizontalHeaderLabels(self.COLUMNS) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.table.verticalHeader().setVisible(False) + + self.add_button = QPushButton("新增") + self.edit_button = QPushButton("编辑") + self.delete_button = QPushButton("删除") + self.delete_button.setObjectName("deleteAccountButton") + self.delete_button.setStyleSheet(_danger_outline_button_style("deleteAccountButton")) + self.launch_button = QPushButton("启动登录") + self.check_button = QPushButton("检测登录") + self.shortcut_button = QPushButton("快捷方式") + + toolbar = QHBoxLayout() + for button in ( + self.add_button, + self.edit_button, + self.delete_button, + self.launch_button, + self.check_button, + self.shortcut_button, + ): + toolbar.addWidget(button) + toolbar.addStretch(1) + + self.empty_label = QLabel("") + + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 18, 18, 18) + layout.addLayout(toolbar) + layout.addWidget(self.table, 1) + layout.addWidget(self.empty_label) + + self.add_button.clicked.connect(self.add_account) + self.edit_button.clicked.connect(self.edit_account) + self.delete_button.clicked.connect(self.delete_account) + self.launch_button.clicked.connect(self.launch_login) + self.check_button.clicked.connect(self.check_login) + self.shortcut_button.clicked.connect(self.create_shortcut) + self.table.itemSelectionChanged.connect(self._update_button_state) + self.table.doubleClicked.connect(self.edit_account) + + self.refresh_accounts() + + def _set_status(self, message): + if self.status_callback is not None: + self.status_callback(message) + + def _selected_account(self): + selected = self.table.selectionModel().selectedRows() + if not selected: + return None + row = selected[0].row() + if row < 0 or row >= len(self.account_rows): + return None + return self.account_rows[row] + + def _update_button_state(self): + has_selection = self._selected_account() is not None + for button in ( + self.edit_button, + self.delete_button, + self.launch_button, + self.check_button, + self.shortcut_button, + ): + button.setEnabled(has_selection) + + def refresh_accounts(self): + try: + self.account_rows = accounts.list_accounts( + path=self.db_path, + config=self.config, + ) + except Exception as exc: + self.account_rows = [] + self._set_status(f"账号读取失败:{exc}") + + self.table.setRowCount(len(self.account_rows)) + for row, account in enumerate(self.account_rows): + status = self.login_statuses.get(account.alias, "未知") + values = [ + account.account_name, + account.alias, + account.region_host, + str(account.debug_port), + _login_status_display(status), + account.note or "", + ] + for column, value in enumerate(values): + item = QTableWidgetItem(value) + if column == 4: + item.setForeground(_login_status_color(status)) + self.table.setItem(row, column, item) + self.empty_label.setText("" if self.account_rows else "暂无账号") + self._update_button_state() + + def _show_error(self, message): + QMessageBox.warning(self, "账号管理", str(message)) + self._set_status(str(message)) + + def add_account(self, checked=False): + try: + default_port = accounts.next_debug_port( + path=self.db_path, + config=self.config, + ) + except Exception: + default_port = appconfig.default_debug_port(self.config) + dialog = _new_account_dialog(self, default_port=default_port, config=self.config) + if dialog.exec() != QDialog.Accepted: + return + values = dialog.values() + if self._should_warn_plaintext_password(values): + self._show_plaintext_password_warning() + try: + accounts.create_account( + path=self.db_path, + config=self.config, + **values, + ) + except Exception as exc: + self._show_error(exc) + return + self.refresh_accounts() + self._set_status("账号已新增") + + def edit_account(self, checked=False): + account = self._selected_account() + if account is None: + return + dialog = _new_account_dialog( + self, + account=account, + default_port=account.debug_port, + config=self.config, + ) + if dialog.exec() != QDialog.Accepted: + return + values = dialog.values() + if self._should_warn_plaintext_password(values, account): + self._show_plaintext_password_warning() + try: + updated = accounts.update_account( + account.alias, + path=self.db_path, + config=self.config, + **values, + ) + except Exception as exc: + self._show_error(exc) + return + if updated.alias != account.alias: + self.login_statuses.pop(account.alias, None) + self.refresh_accounts() + self._set_status("账号已保存") + + def delete_account(self, checked=False): + account = self._selected_account() + if account is None: + return + answer = QMessageBox.question( + self, + "删除账号", + f"确认删除账号「{account.alias}」?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + return + try: + accounts.delete_account(account.alias, path=self.db_path, config=self.config) + except Exception as exc: + self._show_error(exc) + return + self.login_statuses.pop(account.alias, None) + self.refresh_accounts() + self._set_status("账号已删除") + + def launch_login(self, checked=False): + account = self._selected_account() + if account is None: + return + run_id = _safe_create_run_log( + "chrome_launch", + db_path=self.db_path, + total=1, + options={"alias": account.alias, "debug_port": account.debug_port}, + ) + started = time.monotonic() + _safe_add_run_log_event( + run_id, + f"step=launch_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", + db_path=self.db_path, + account=account, + ) + try: + process = accounts.launch_for_login(account, config=self.config) + except Exception as exc: + elapsed_ms = _elapsed_ms(started) + safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + _safe_add_run_log_event( + run_id, + f"step=launch_chrome result=failed detail={safe_error} elapsed_ms={elapsed_ms}", + db_path=self.db_path, + account=account, + level="error", + ) + _safe_write_diagnostic_log( + "Chrome启动失败", + level="ERROR", + step="launch_chrome", + account=account, + elapsed_ms=elapsed_ms, + payload={"alias": account.alias, "debug_port": account.debug_port, "error": safe_error}, + exc=exc, + log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + _safe_finish_run_log( + run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "alias": account.alias, "error": safe_error}, + ) + self._show_error(safe_error) + return + elapsed_ms = _elapsed_ms(started) + pid = getattr(process, "pid", None) + _safe_add_run_log_event( + run_id, + f"step=launch_chrome result=success detail=账号 {account.alias} pid={pid or ''} elapsed_ms={elapsed_ms}", + db_path=self.db_path, + account=account, + ) + _safe_finish_run_log( + run_id, + db_path=self.db_path, + status="done", + done=1, + success_count=1, + failed_count=0, + summary_json={"ok": True, "alias": account.alias, "debug_port": account.debug_port, "pid": pid}, + ) + self.login_statuses[account.alias] = "已启动" + self.refresh_accounts() + self._set_status("Chrome 已启动,请人工登录") + + def check_login(self, checked=False): + account = self._selected_account() + if account is None: + return + self.login_statuses[account.alias] = "检测中" + self.refresh_accounts() + worker = AccountLoginCheckWorker( + account, + db_path=self.db_path, + config=self.config, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + worker.finished.connect(self._on_login_check_finished) + worker.failed.connect( + lambda _task_id, error, alias=account.alias: self._on_login_check_failed( + alias, + error, + ) + ) + thread = run_worker(worker, start=False) + thread.finished.connect(lambda: self._forget_thread(thread)) + self.threads.append(thread) + thread.start() + + def _forget_thread(self, thread): + if thread in self.threads: + self.threads.remove(thread) + + def _on_login_check_finished(self, payload): + if payload.get("ok") is False and not payload.get("alias"): + return + alias = payload.get("alias") + status = payload.get("status") or {} + if alias: + self.login_statuses[alias] = accounts.login_status_text(status) + self.refresh_accounts() + self._set_status("登录状态已刷新") + + def _on_login_check_failed(self, alias, error): + self.login_statuses[alias] = "检测失败" + self.refresh_accounts() + self._set_status(f"登录检测失败:{error}") + + def create_shortcut(self, checked=False): + account = self._selected_account() + if account is None: + return + try: + shortcut_path = accounts.create_shortcut(account, config=self.config) + except Exception as exc: + self._show_error(exc) + return + self._set_status(f"快捷方式已生成:{shortcut_path}") + QMessageBox.information(self, "账号管理", f"快捷方式已生成:\n{shortcut_path}") + + def _should_warn_plaintext_password(self, values, account=None): + new_password = str((values or {}).get("password") or "") + current_password = str(getattr(account, "password", None) or "") + return bool(new_password) and new_password != current_password + + def _show_plaintext_password_warning(self): + QMessageBox.warning( + self, + PLAINTEXT_SECRET_TITLE, + PLAINTEXT_PASSWORD_WARNING, + ) + + diff --git a/app/gui/tabs/apply.py b/app/gui/tabs/apply.py new file mode 100644 index 0000000..1a2d100 --- /dev/null +++ b/app/gui/tabs/apply.py @@ -0,0 +1,820 @@ +"""Tab 3: Shopee update UI.""" + +from __future__ import annotations + +from ..models import ApplyTaskTableModel +from ..widgets import * +from ..workers import ApplyWorker as _RealApplyWorker, WriteBackWorker as _RealWriteBackWorker + + +def ApplyWorker(*args, **kwargs): + return _call_package_attr("ApplyWorker", _RealApplyWorker, *args, **kwargs) + + +def WriteBackWorker(*args, **kwargs): + return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs) + +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, + open_accounts_callback=None, + open_settings_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.open_settings_callback = open_settings_callback + self.apply_worker = None + self.apply_thread = None + self.result_write_back_worker = None + self.result_write_back_thread = None + self.last_apply_summary = None + + self.batch_filter = QComboBox() + self.batch_filter.setObjectName("applyBatchFilter") + self.shop_filter = QComboBox() + self.shop_filter.setObjectName("applyShopFilter") + self.item_filter = QLineEdit() + self.item_filter.setObjectName("applyItemFilter") + self.item_filter.setPlaceholderText("商品ID") + 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("商品ID")) + filter_layout.addWidget(self.item_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.batch_progress_label = _build_batch_progress_overview("applyBatchProgressOverview") + self.risk_label = QLabel("可先点击「检查本轮更新」确认当前筛选范围;点击「开始更新」后会再次确认并按批提交线上。") + ( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + ) = _build_empty_state_card("applyEmptyStateCard") + if self.open_accounts_callback is not None: + self.empty_state_button.clicked.connect(self.open_accounts_callback) + 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.setContextMenuPolicy(Qt.CustomContextMenu) + self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.task_table.verticalHeader().setVisible(False) + self.run_log_view = QPlainTextEdit() + self.run_log_view.setObjectName("applyRunLogView") + self.run_log_view.setReadOnly(True) + self.run_log_view.setMaximumHeight(128) + self.run_log_view.setPlaceholderText("运行日志") + + self.preview_update_button = QPushButton("检查本轮更新") + self.preview_update_button.setObjectName("previewUpdateButton") + self.start_update_button = QPushButton("开始更新") + self.start_update_button.setObjectName("startUpdateButton") + self.start_update_button.setMinimumWidth(118) + self.start_update_button.setStyleSheet( + "QPushButton#startUpdateButton { " + "font-weight: 600; padding: 6px 16px; " + f"color: {COLOR_WARNING}; border: 1px solid {COLOR_WARNING}; " + "border-radius: 4px; }" + ) + self.stop_update_button = QPushButton("停止") + self.reset_update_button = QPushButton("重置更新状态") + self.reset_update_button.setObjectName("resetUpdateButton") + self.reset_update_button.setVisible(False) + 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.preview_update_button) + 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.batch_progress_label) + layout.addWidget(self.empty_state_card) + layout.addWidget(self.task_table, 1) + layout.addWidget(QLabel("运行日志")) + layout.addWidget(self.run_log_view) + layout.addLayout(action_layout) + + self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) + self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) + self.item_filter.textChanged.connect(self.refresh_tasks) + self.status_filter.currentIndexChanged.connect(self.refresh_tasks) + self.refresh_button.clicked.connect(self.refresh_tasks) + self.preview_update_button.clicked.connect(self.preview_update) + self.start_update_button.clicked.connect(self.start_update) + self.stop_update_button.clicked.connect(self.stop_update) + self.reset_update_button.clicked.connect(self.reset_apply_status) + self.task_table.customContextMenuRequested.connect(self.show_task_context_menu) + self.write_back_button.clicked.connect(self.write_back_results) + + self.refresh_tasks() + self._load_latest_run_log() + + 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" + item_query = self.item_filter.text().strip() + self._populate_batch_filter(batches, selected_batch) + selected_batch = self.batch_filter.currentData() + all_batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path) + batch_tasks = [ + task for task in all_batch_tasks + 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_item(task, item_query) + and self._matches_status(task, selected_status) + ] + except Exception as exc: + self.model.set_tasks([], []) + self.summary_label.setText("任务读取失败") + _set_batch_progress_overview(self.batch_progress_label, []) + _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) + 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)} 条" + ) + _set_batch_progress_overview(self.batch_progress_label, all_batch_tasks) + self._update_empty_state(batch_tasks, filtered_tasks, account_rows) + self._update_write_back_button() + + def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows): + if not account_rows: + _set_empty_state( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + "第一步:前往『④账号管理』配置并登录账号,再回到③更新 Shopee。", + self.open_accounts_callback is not None, + ) + return + if not batch_tasks: + _set_empty_state( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + "还没有可更新任务。请先在②AI生成完成新标题或新封面。", + ) + return + if not filtered_tasks: + _set_empty_state( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + "当前筛选没有可更新任务,请调整批次、店铺、商品ID或状态筛选。", + ) + return + _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) + + def start_update(self, checked=False): + self._start_update(dry_run=False) + + def preview_update(self, checked=False): + self._start_update(dry_run=True) + + def _start_update(self, dry_run=False): + if self.apply_thread is not None: + self._set_status("更新正在进行...") + return + tasks = [ + task for task in self.model.tasks + if self._is_actionable_task(task) + ] + if not tasks: + self._set_status("当前筛选结果没有可更新任务") + return + update_cfg = self._shopee_update_config() + dry_run = bool(dry_run) + safety_error = self._update_safety_error(tasks, dry_run=dry_run) + if safety_error: + self._show_update_safety_error(safety_error) + self._set_status(safety_error.replace("\n", " ")) + return + answer = QMessageBox.question( + self, + "确认检查本轮更新" if dry_run else "确认开始更新", + self._confirmation_message(tasks, dry_run=dry_run), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self._set_status("已取消检查本轮更新" if dry_run else "已取消开始更新") + return + batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) + worker = ApplyWorker( + tasks, + db_path=self.db_path, + config=self.config, + close_success_tab=bool(update_cfg.get("close_success_tab", False)), + dry_run=dry_run, + parallel_accounts=bool(update_cfg.get("parallel_accounts", False)), + max_parallel_accounts=max( + 1, + int(update_cfg.get("max_parallel_accounts", 1) or 1), + ), + batch_size=batch_size, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + worker.progress.connect(self._on_apply_progress) + worker.row_updated.connect(self._on_apply_row_updated) + worker.log.connect(self._on_apply_log) + worker.failed.connect(self._on_apply_failed) + worker.finished.connect(self._on_apply_finished) + worker.cancelled.connect(self._on_apply_cancelled) + thread = run_worker(worker, thread_name="ApplyWorker", start=False) + thread.finished.connect(lambda: self._forget_apply_thread(thread)) + self.apply_worker = worker + self.apply_thread = thread + self._set_apply_running(True) + self.run_log_view.clear() + if dry_run: + self._set_status(f"开始检查本轮更新:{len(tasks)} 条") + else: + self._set_status(f"开始更新:{len(tasks)} 条,按每批最多 {batch_size} 条执行") + thread.start() + + def stop_update(self, checked=False): + if self.apply_worker is not None: + self.apply_worker.cancel() + self._set_status("正在停止更新...") + + def write_back_results(self, checked=False): + batch_ids = self._active_batch_ids() + if not batch_ids: + self._set_status("没有可回写结果的批次") + return + self._start_result_write_back(batch_ids, auto=False) + + def show_task_context_menu(self, position): + index = self.task_table.indexAt(position) + if index.isValid(): + self.task_table.setCurrentIndex(index) + menu = QMenu(self) + reset_action = menu.addAction("重置更新状态") + reset_action.setEnabled( + self.apply_thread is None + and self.result_write_back_thread is None + and self._selected_task() is not None + ) + reset_action.triggered.connect(self.reset_apply_status) + menu.exec(self.task_table.viewport().mapToGlobal(position)) + + def reset_apply_status(self, checked=False): + if self.apply_thread is not None or self.result_write_back_thread is not None: + self._set_status("更新或回写正在进行,不能重置") + return + task = self._selected_task() + if task is None: + self._set_status("请选择要重置更新状态的任务") + return + if not (getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)): + self._set_status("选中任务没有新标题或新封面,不能重置为可更新") + return + lines = [ + "确定重置当前选中任务的本地更新状态吗?", + "", + f"商品ID:{task.item_id}", + f"店铺:{self.model.account_name_for(task)}", + "", + "将保留新标题和新封面路径,只把本地状态退回可更新。", + "不会触碰 Shopee,也不会自动回写 Excel。", + ] + if getattr(task, "committed", 0): + lines.extend([ + "", + "注意:该记录已经提交过线上。本地重置不会回滚 Shopee,重复更新会再次提交线上。", + ]) + answer = QMessageBox.question( + self, + "重置更新状态", + "\n".join(lines), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self._set_status("已取消重置更新状态") + return + try: + db.reset_apply_status(task.id, path=self.db_path) + message = ( + "action=reset_apply_status step=db_write result=success " + f"detail=退回可更新 task_id={task.id}" + ) + run_id = _write_reset_run_log(self.db_path, task, "reset_apply_status", message) + except Exception as exc: + QMessageBox.warning(self, "重置更新状态", str(exc)) + self._set_status(f"重置更新状态失败:{exc}") + return + self.refresh_tasks() + self._append_run_log(message) + self._set_status( + f"已重置更新状态:商品 {task.item_id},run_id={run_id}" + ) + + def _selected_task(self): + index = self.task_table.currentIndex() + if index.isValid(): + return self.model.task_at(index.row()) + if self.model.rowCount() > 0: + return self.model.task_at(0) + return None + + def _is_actionable_task(self, task): + return ( + getattr(task, "stage", None) == "generated" + and getattr(task, "status", None) in {"success", "pending", "failed"} + and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) + ) + + 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 _item_filter_label(self): + return self.item_filter.text().strip() 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_item(self, task, item_query): + if not item_query: + return True + return item_query in str(getattr(task, "item_id", "")) + + 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, dry_run=False): + update_cfg = self._shopee_update_config() + cover_text = "允许" if update_cfg.get("allow_cover_update") else "不允许" + close_text = "是" if update_cfg.get("close_success_tab") else "否" + batch_size = max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) + batch_count = (len(tasks) + batch_size - 1) // batch_size if tasks else 0 + parallel_text = ( + f"开启,最多 {update_cfg.get('max_parallel_accounts', 1)} 个账号" + if update_cfg.get("parallel_accounts") + else "关闭" + ) + intro = ( + "即将检查当前筛选结果。\n\n" + if dry_run + else "即将按当前筛选结果分批更新 Shopee 线上商品。\n\n" + ) + return ( + intro + + f"批次:{self._batch_filter_label()}\n" + + f"店铺:{self._shop_filter_label()}\n" + + f"商品ID:{self._item_filter_label()}\n" + + f"状态:{self._status_label()}\n" + + f"任务数:{len(tasks)}\n" + + f"预计批次:{batch_count}\n\n" + + "安全设置:" + + f"封面更新={cover_text}," + + f"每批最大更新条数={batch_size}," + + f"成功后关闭新页={close_text}," + + f"多账号并行={parallel_text}\n\n" + + ( + "检查只写运行日志,不打开 Shopee、不点击「更新」、不改任务状态。" + if dry_run + else f"确认后会打开商品编辑页、替换标题/允许时替换封面,并按每批最多 {batch_size} 条点击「更新」提交线上;点击停止后不再开始下一条或下一批。" + ) + ) + + def _update_safety_error(self, tasks, dry_run=False): + update_cfg = self._shopee_update_config() + if dry_run: + return None + if not update_cfg.get("allow_real_submit", False): + return ( + "设置未开启「允许真实提交线上商品」,已阻止本次更新。\n" + "请到⑤设置 > Shopee 更新安全开启该开关后再开始更新。" + ) + if not update_cfg.get("allow_cover_update", False): + cover_tasks = [ + str(getattr(task, "item_id", "")) + for task in tasks + if getattr(task, "new_cover_path", None) + ] + if cover_tasks: + return ( + "设置未开启「允许更新封面」,当前任务包含新封面路径,已阻止本次更新。\n" + "请到⑤设置 > Shopee 更新安全开启该开关,或先筛掉含新封面的任务。" + ) + return None + + def _show_update_safety_error(self, message): + box = QMessageBox(self) + box.setIcon(QMessageBox.Warning) + box.setWindowTitle("更新安全开关") + box.setText(str(message)) + settings_button = None + if self.open_settings_callback is not None: + settings_button = box.addButton("前往设置", QMessageBox.ActionRole) + box.addButton(QMessageBox.Ok) + box.exec() + if settings_button is not None and box.clickedButton() is settings_button: + self.open_settings_callback() + + def _shopee_update_config(self): + defaults = appconfig.default_config().get("shopee_update", {}) + loaded = self.config.get("shopee_update", {}) + if not isinstance(loaded, dict): + loaded = {} + merged = dict(defaults) + merged.update(loaded) + return merged + + def _set_apply_running(self, running): + self.preview_update_button.setEnabled(not running) + self.start_update_button.setEnabled(not running) + self.stop_update_button.setEnabled(running) + self.reset_update_button.setEnabled(not running) + self.refresh_button.setEnabled(not running) + self.batch_filter.setEnabled(not running) + self.shop_filter.setEnabled(not running) + self.item_filter.setEnabled(not running) + self.status_filter.setEnabled(not running) + self._update_write_back_button() + + def _set_result_write_back_running(self, running): + self.preview_update_button.setEnabled(not running) + self.start_update_button.setEnabled(not running) + self.reset_update_button.setEnabled(not running) + self.refresh_button.setEnabled(not running) + self.batch_filter.setEnabled(not running) + self.shop_filter.setEnabled(not running) + self.item_filter.setEnabled(not running) + self.status_filter.setEnabled(not running) + self.write_back_button.setEnabled(False if running else bool(self._active_batch_ids())) + + def _forget_apply_thread(self, thread): + if self.apply_thread is thread: + self.apply_thread = None + self.apply_worker = None + + def _forget_result_write_back_thread(self, thread): + if self.result_write_back_thread is thread: + self.result_write_back_thread = None + self.result_write_back_worker = None + self._update_write_back_button() + + def _on_apply_progress(self, payload): + self._set_status("更新进度:" + self._apply_progress_text(payload)) + + def _on_apply_log(self, message): + self._append_run_log(message) + self._set_status(message) + + def _on_apply_row_updated(self, task_id, fields): + self.refresh_tasks() + + def _on_apply_failed(self, task_id, error): + self._set_status(f"任务 {task_id} 更新失败:{error}") + + def _on_apply_finished(self, payload): + self._set_apply_running(False) + self.refresh_tasks() + if payload.get("blocked"): + self._show_apply_blocked(payload) + return + self.last_apply_summary = dict(payload) + prefix = "检查本轮更新完成:" if payload.get("dry_run") else "更新完成:" + message = prefix + self._apply_progress_text(payload) + batch_ids = payload.get("batch_ids") or self._active_batch_ids() + if (not payload.get("dry_run")) and payload.get("done", 0) > 0 and batch_ids: + if self._start_result_write_back( + batch_ids, + auto=True, + apply_summary=payload, + ): + self._set_status(f"{message},正在自动回写结果到 Excel...") + return + self._set_status(message) + self._show_apply_summary(payload) + + def _on_apply_cancelled(self, payload): + self._set_apply_running(False) + self.refresh_tasks() + self._set_status("更新已停止:" + self._apply_progress_text(payload)) + + def _apply_progress_text(self, payload): + success_label = "可更新" if payload.get("dry_run") else "成功" + return "完成{done}/{total},{success_label}{applied},略过{skipped},失败{failed}".format( + done=payload.get("done", 0), + total=payload.get("total", 0), + success_label=success_label, + applied=payload.get("applied", 0), + skipped=payload.get("skipped", 0), + failed=payload.get("failed", 0), + ) + + def _append_run_log(self, message): + self.run_log_view.appendPlainText(str(message)) + + def _load_latest_run_log(self): + try: + logs = db.list_run_logs(limit=1, run_type="apply", path=self.db_path) + if not logs: + return + events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path) + except Exception: + return + lines = [ + f"{event.created_at} [{event.level}] {event.message}" + for event in reversed(events) + ] + self.run_log_view.setPlainText("\n".join(lines)) + scroll_bar = self.run_log_view.verticalScrollBar() + scroll_bar.setValue(scroll_bar.maximum()) + + def _show_apply_blocked(self, payload): + lines = ["更新前检查未通过。"] + if payload.get("no_accounts"): + lines.append("当前没有配置账号。") + duplicate_ports = payload.get("duplicate_ports") or [] + if duplicate_ports: + for item in duplicate_ports: + lines.append( + "以下账号调试端口冲突:端口 {port} -> {aliases}".format( + port=item.get("debug_port"), + aliases="、".join(item.get("aliases") or []), + ) + ) + not_running = payload.get("not_running") or [] + if not_running: + lines.append( + "以下账号 Chrome 未启动或调试端口不可访问:" + + "、".join(self._account_label(item) for item in not_running) + ) + logged_out = payload.get("logged_out") or [] + if logged_out: + lines.append( + "以下账号未登录 Shopee:" + + "、".join(self._account_label(item) for item in logged_out) + ) + self._show_account_guide("\n".join(lines)) + + def _show_account_guide(self, message): + full_message = ( + f"{message}\n\n" + "本轮更新已中止,不会自动打开账号 Chrome,也不会提交任何商品。\n" + "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" + ) + QMessageBox.warning(self, "账号未就绪", full_message) + self._set_status(full_message.replace("\n", " ")) + if self.open_accounts_callback is not None: + self.open_accounts_callback() + + def _account_label(self, item): + if isinstance(item, dict): + name = item.get("account_name") or item.get("alias") or "" + alias = item.get("alias") or "" + reason = item.get("reason") + else: + name = getattr(item, "account_name", "") or getattr(item, "alias", "") + alias = getattr(item, "alias", "") + reason = getattr(item, "reason", None) + label = f"{name}({alias})" if alias and name != alias else (name or alias) + return f"{label}: {reason}" if reason else label + + def _active_batch_ids(self): + selected_batch = self.batch_filter.currentData() + if selected_batch: + return [selected_batch] + batch_ids = [] + for task in self.model.tasks: + batch_id = getattr(task, "batch_id", None) + if batch_id and batch_id not in batch_ids: + batch_ids.append(batch_id) + return batch_ids + + def _update_write_back_button(self): + if getattr(self, "write_back_button", None) is None: + return + enabled = ( + self.apply_thread is None + and self.result_write_back_thread is None + and bool(self._active_batch_ids()) + ) + self.write_back_button.setEnabled(enabled) + + def _start_result_write_back(self, batch_ids, auto=False, apply_summary=None): + if self.result_write_back_thread is not None: + self._set_status("Excel 结果回写正在进行...") + return False + worker = WriteBackWorker( + batch_ids, + db_path=self.db_path, + mode="results", + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + worker.failed.connect( + lambda task_id, error, auto=auto, apply_summary=apply_summary: + self._on_result_write_back_failed( + task_id, + error, + auto=auto, + apply_summary=apply_summary, + ) + ) + worker.finished.connect( + lambda payload, auto=auto, apply_summary=apply_summary: + self._on_result_write_back_finished( + payload, + auto=auto, + apply_summary=apply_summary, + ) + ) + thread = run_worker(worker, thread_name="ResultWriteBackWorker", start=False) + thread.finished.connect(lambda: self._forget_result_write_back_thread(thread)) + self.result_write_back_worker = worker + self.result_write_back_thread = thread + self._set_result_write_back_running(True) + self._set_status("正在自动回写更新结果到 Excel..." if auto else "正在回写更新结果到 Excel...") + thread.start() + return True + + def _on_result_write_back_failed(self, task_id, error, auto=False, apply_summary=None): + message = f"Excel {'自动' if auto else ''}回写更新结果失败:{error}" + if "被占用" in str(error): + message += "\n请关闭原 Excel 后点击「回写结果到 Excel」手动重试;SQLite 已保留更新结果。" + if auto and apply_summary: + message = self._apply_summary_message(apply_summary, error=message) + QMessageBox.warning(self, "回写结果到 Excel", message) + self._set_status(message.replace("\n", " ")) + + def _on_result_write_back_finished(self, payload, auto=False, apply_summary=None): + self._set_result_write_back_running(False) + self.refresh_tasks() + if payload.get("ok") is False: + error = payload.get("error") or "未知错误" + retry_hint = ",可点击「回写结果到 Excel」手动重试" if auto else "" + self._set_status(f"Excel {'自动' if auto else ''}回写更新结果失败:{error}{retry_hint}") + return + self._set_status( + "Excel {prefix}回写更新结果完成:文件{files},行{rows}".format( + prefix="自动" if auto else "", + files=payload.get("files", 0), + rows=payload.get("rows", 0), + ) + ) + if auto and apply_summary: + self._show_apply_summary(apply_summary, write_back_payload=payload) + elif not auto: + QMessageBox.information( + self, + "回写结果到 Excel", + "结果回写完成:文件{files},行{rows}".format( + files=payload.get("files", 0), + rows=payload.get("rows", 0), + ), + ) + + def _show_apply_summary(self, apply_summary, write_back_payload=None): + QMessageBox.information( + self, + "检查本轮更新完成" if apply_summary.get("dry_run") else "更新完成", + self._apply_summary_message(apply_summary, write_back_payload), + ) + + def _apply_summary_message(self, apply_summary, write_back_payload=None, error=None): + dry_run = bool(apply_summary.get("dry_run")) + lines = [ + "检查本轮更新完成,未打开 Shopee、未提交线上、未改任务状态。" + if dry_run + else "更新完成。", + "{success_label}:{applied},失败:{failed},略过:{skipped}".format( + success_label="可更新" if dry_run else "成功", + applied=apply_summary.get("applied", 0), + failed=apply_summary.get("failed", 0), + skipped=apply_summary.get("skipped", 0), + ), + ] + if write_back_payload: + lines.append( + "Excel 回写:文件{files},行{rows}".format( + files=write_back_payload.get("files", 0), + rows=write_back_payload.get("rows", 0), + ) + ) + if error: + lines.append(str(error)) + return "\n".join(lines) + + diff --git a/app/gui/tabs/collect.py b/app/gui/tabs/collect.py new file mode 100644 index 0000000..a208bf3 --- /dev/null +++ b/app/gui/tabs/collect.py @@ -0,0 +1,840 @@ +"""Tab 1: Excel import and data collection UI.""" + +from __future__ import annotations + +from ..models import TaskTableModel +from ..widgets import * +from ..workers import CollectWorker as _RealCollectWorker, WriteBackWorker as _RealWriteBackWorker + + +def CollectWorker(*args, **kwargs): + return _call_package_attr("CollectWorker", _RealCollectWorker, *args, **kwargs) + + +def WriteBackWorker(*args, **kwargs): + return _call_package_attr("WriteBackWorker", _RealWriteBackWorker, *args, **kwargs) + +class CollectTab(QWidget): + """Tab 1: import Excel files and list imported tasks.""" + + STATUS_FILTERS = [ + ("全部状态", "all"), + ("待采集", "to_collect"), + ("已采集", "collected"), + ("已生成", "generated"), + ("已更新", "applied"), + ("失败", "failed"), + ("略过", "skipped"), + ] + + def __init__( + self, + parent=None, + db_path=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 + self.collect_worker = None + self.collect_thread = None + self.write_back_worker = None + self.write_back_thread = None + self.last_collect_run_id = None + + self.import_button = QPushButton("导入 Excel...") + self.refresh_button = QPushButton("刷新") + self.collect_button = QPushButton("采集旧标题/旧封面") + 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.shop_filter = QComboBox() + self.shop_filter.setObjectName("collectShopFilter") + self.item_filter = QLineEdit() + self.item_filter.setObjectName("collectItemFilter") + self.item_filter.setPlaceholderText("商品ID") + self.status_filter = QComboBox() + self.status_filter.setObjectName("collectStatusFilter") + for label, value in self.STATUS_FILTERS: + self.status_filter.addItem(label, value) + self.delete_batch_button = QPushButton("删除批次") + self.delete_batch_button.setObjectName("deleteBatchButton") + self.delete_batch_button.setStyleSheet(_danger_outline_button_style("deleteBatchButton")) + self.delete_batch_button.setEnabled(False) + + toolbar = QHBoxLayout() + toolbar.addWidget(self.import_button) + toolbar.addWidget(self.refresh_button) + toolbar.addWidget(self.collect_button) + toolbar.addWidget(self.stop_collect_button) + toolbar.addWidget(self.write_back_button) + toolbar.addStretch(1) + + 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("商品ID")) + filter_layout.addWidget(self.item_filter, 1) + filter_layout.addWidget(QLabel("状态")) + filter_layout.addWidget(self.status_filter, 1) + filter_layout.addWidget(self.delete_batch_button) + + self.summary_label = QLabel("未导入任务") + self.summary_label.setTextFormat(Qt.RichText) + self.batch_progress_label = _build_batch_progress_overview("collectBatchProgressOverview") + self.match_detail_label = QLabel("") + self.show_all_button = QPushButton("全部") + self.show_unmatched_button = QPushButton("未匹配(0)") + self.show_unmatched_button.setObjectName("showUnmatchedButton") + + summary_layout = QHBoxLayout() + summary_layout.addWidget(self.summary_label) + summary_layout.addStretch(1) + summary_layout.addWidget(self.show_all_button) + summary_layout.addWidget(self.show_unmatched_button) + + self.table = QTableView() + self.model = TaskTableModel(self.table) + self.table.setModel(self.model) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.table.verticalHeader().setVisible(False) + + self.run_log_view = QPlainTextEdit() + self.run_log_view.setObjectName("collectRunLogView") + self.run_log_view.setReadOnly(True) + self.run_log_view.setMaximumHeight(128) + self.run_log_view.setPlaceholderText("采集运行日志") + + self.empty_label = QLabel("") + ( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + ) = _build_empty_state_card("collectEmptyStateCard") + if self.open_accounts_callback is not None: + self.empty_state_button.clicked.connect(self.open_accounts_callback) + + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 18, 18, 18) + layout.addLayout(toolbar) + layout.addLayout(filter_layout) + layout.addLayout(summary_layout) + layout.addWidget(self.match_detail_label) + layout.addWidget(self.batch_progress_label) + layout.addWidget(self.empty_state_card) + layout.addWidget(self.table, 1) + layout.addWidget(QLabel("采集运行日志")) + layout.addWidget(self.run_log_view) + layout.addWidget(self.empty_label) + + 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.shop_filter.currentIndexChanged.connect(self.refresh_tasks) + self.item_filter.textChanged.connect(self.refresh_tasks) + self.status_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) + self.show_all_button.clicked.connect(self.show_all_tasks) + self.show_unmatched_button.clicked.connect(self.show_unmatched_tasks) + + self.refresh_tasks() + self._load_latest_collect_run_log() + + def _set_status(self, message): + if self.status_callback is not None: + self.status_callback(message) + + def _on_collect_log(self, message): + self._append_collect_log(message) + self._set_status(message) + + def _append_collect_log(self, message): + self.run_log_view.appendPlainText(str(message)) + + def _load_latest_collect_run_log(self): + try: + logs = db.list_run_logs(limit=1, run_type="collect", path=self.db_path) + if not logs: + return + events = db.list_run_log_events(logs[0].id, limit=30, path=self.db_path) + except Exception: + return + lines = [ + f"{event.created_at} [{event.level}] {event.message}" + for event in reversed(events) + ] + self.run_log_view.setPlainText("\n".join(lines)) + scroll_bar = self.run_log_view.verticalScrollBar() + scroll_bar.setValue(scroll_bar.maximum()) + + def _log_collect_run_event(self, run_id, message, level="info"): + safe_message = diagnostics.redact_log_text(message) + try: + db.add_run_log_event(run_id, safe_message, level=level, path=self.db_path) + except Exception: + return + self._append_collect_log(safe_message) + + def _show_error(self, message): + QMessageBox.warning(self, "导入采集", str(message)) + self._set_status(str(message)) + + def _show_account_guide(self, message): + full_message = ( + f"{message}\n\n" + "本轮采集已中止,不会自动打开账号 Chrome。\n" + "请先到「④ 账号管理」配置账号、启动对应账号 Chrome,并确认已人工登录 Shopee。" + ) + QMessageBox.warning(self, "账号未就绪", full_message) + self._set_status(full_message.replace("\n", " ")) + if self.open_accounts_callback is not None: + self.open_accounts_callback() + + def _choose_excel_files(self): + files, _selected_filter = QFileDialog.getOpenFileNames( + self, + "选择 Excel 文件", + "", + "Excel 文件 (*.xlsx *.xlsm)", + ) + return files + + def import_excel(self, checked=False): + file_paths = self._choose_excel_files() + if not file_paths: + return + run_id = _safe_create_run_log( + "import", + db_path=self.db_path, + total=len(file_paths), + options={"files": file_paths}, + ) + started = time.monotonic() + _safe_add_run_log_event( + run_id, + f"step=select_files result=success detail=选择 Excel 文件 {len(file_paths)} 个", + db_path=self.db_path, + ) + try: + _safe_add_run_log_event( + run_id, + "step=parse_file result=start detail=开始解析 Excel 并写入 SQLite", + db_path=self.db_path, + ) + result = excel.import_tasks(file_paths, path=self.db_path) + except Exception as exc: + elapsed_ms = _elapsed_ms(started) + safe_error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + _safe_add_run_log_event( + run_id, + f"step=import result=failed detail={safe_error} elapsed_ms={elapsed_ms}", + db_path=self.db_path, + level="error", + ) + _safe_write_diagnostic_log( + "Excel导入失败", + level="ERROR", + step="import", + elapsed_ms=elapsed_ms, + payload={"files": file_paths, "error": safe_error}, + exc=exc, + log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + _safe_finish_run_log( + run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "error": safe_error, "elapsed_ms": elapsed_ms}, + ) + self._show_error(safe_error) + return + elapsed_ms = _elapsed_ms(started) + self.has_import_result = True + self.last_import_stats = result.get("stats") or {} + self.current_batch_id = result.get("batch_id") + file_errors = self.last_import_stats.get("file_errors") or [] + row_errors = self.last_import_stats.get("row_errors") or [] + for item in file_errors: + missing = ",".join(item.get("missing_columns") or []) + detail = "file={file} sheet={sheet} error={error}{missing}".format( + file=os.path.basename(str(item.get("file") or "")), + sheet=item.get("sheet") or "", + error=item.get("error") or "", + missing=f" missing={missing}" if missing else "", + ) + _safe_add_run_log_event( + run_id, + f"step=parse_file result=failed detail={detail}", + db_path=self.db_path, + level="error", + ) + for item in row_errors: + detail = "file={file} sheet={sheet} row={row} error={error}".format( + file=os.path.basename(str(item.get("file") or "")), + sheet=item.get("sheet") or "", + row=item.get("row") or "", + error=item.get("error") or "", + ) + _safe_add_run_log_event( + run_id, + f"step=row_validate result=failed detail={detail}", + db_path=self.db_path, + level="warning", + ) + _safe_add_run_log_event( + run_id, + "step=db_insert result=success detail=batch_id={batch_id} files={files} total={total} valid={valid} invalid={invalid} inserted={inserted} elapsed_ms={elapsed_ms}".format( + batch_id=self.current_batch_id or "", + files=self.last_import_stats.get("files", 0), + total=self.last_import_stats.get("total", 0), + valid=self.last_import_stats.get("valid", 0), + invalid=self.last_import_stats.get("invalid", 0), + inserted=self.last_import_stats.get("inserted", 0), + elapsed_ms=elapsed_ms, + ), + db_path=self.db_path, + ) + _safe_finish_run_log( + run_id, + db_path=self.db_path, + status="done", + done=self.last_import_stats.get("files", 0), + success_count=self.last_import_stats.get("inserted", 0), + failed_count=len(file_errors) + len(row_errors), + summary_json={ + "ok": True, + "batch_id": self.current_batch_id, + "stats": self.last_import_stats, + "elapsed_ms": elapsed_ms, + }, + ) + self.refresh_tasks() + self._set_status( + "导入完成:有效{valid},无效{invalid},入库{inserted},未匹配{unmatched}".format( + valid=self.last_import_stats.get("valid", 0), + invalid=self.last_import_stats.get("invalid", 0), + inserted=self.last_import_stats.get("inserted", 0), + unmatched=self.model.unmatched_count(), + ) + ) + + def refresh_tasks(self, checked=False): + try: + db.init_db(self.db_path) + batches = db.list_batches(path=self.db_path) + selected_batch = self.batch_filter.currentData() + selected_shop = self.shop_filter.currentData() + selected_status = self.status_filter.currentData() or "all" + item_query = self.item_filter.text().strip() + 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) + self._populate_shop_filter(task_rows, account_rows, selected_shop) + selected_shop = self.shop_filter.currentData() + filtered_rows = [ + task for task in task_rows + if self._matches_shop(task, selected_shop) + and self._matches_item(task, item_query) + and self._matches_status(task, selected_status, account_rows) + ] + except Exception as exc: + self.model.set_tasks([], []) + self.empty_label.setText("任务读取失败") + _set_batch_progress_overview(self.batch_progress_label, []) + _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) + self._set_status(f"任务读取失败:{exc}") + return + self.model.set_tasks(filtered_rows, account_rows) + self._update_summary(task_rows, account_rows) + _set_batch_progress_overview(self.batch_progress_label, task_rows) + self._update_empty_state(task_rows, account_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 _populate_shop_filter(self, task_rows, account_rows, selected_shop): + aliases = {str(task.alias).strip() for task in task_rows if str(task.alias).strip()} + previous = selected_shop if selected_shop in aliases else None + account_by_alias = { + str(account.alias).strip(): account + for account in account_rows + if str(account.alias).strip() + } + self.shop_filter.blockSignals(True) + self.shop_filter.clear() + self.shop_filter.addItem("全部店铺", None) + for alias in sorted(aliases): + 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 _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 _matches_shop(self, task, selected_shop): + return selected_shop is None or str(task.alias).strip() == selected_shop + + def _matches_item(self, task, item_query): + if not item_query: + return True + return item_query in str(getattr(task, "item_id", "")) + + def _matches_status(self, task, selected_status, account_rows): + if selected_status in (None, "all"): + return True + if selected_status == "to_collect": + return task.stage == "imported" and task.status in {"pending", "success"} + if selected_status in {"collected", "generated", "applied"}: + return task.stage == selected_status + if selected_status == "failed": + return task.status == "failed" + if selected_status == "skipped": + return task.status == "skipped" or self._is_unmatched_task(task, account_rows) + return True + + def _is_unmatched_task(self, task, account_rows): + aliases = { + str(account.alias).strip() + for account in account_rows + if str(account.alias).strip() + } + return str(task.alias).strip() not in aliases + + 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.tasks) + if not tasks: + self._set_status("没有可采集任务") + return + worker = CollectWorker( + tasks, + db_path=self.db_path, + config=self.config, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + worker.progress.connect(self._on_collect_progress) + worker.row_updated.connect(self._on_collect_row_updated) + worker.log.connect(self._on_collect_log) + worker.failed.connect(self._on_collect_failed) + worker.finished.connect(self._on_collect_finished) + worker.cancelled.connect(self._on_collect_cancelled) + self.run_log_view.clear() + thread = run_worker(worker, thread_name="CollectWorker", start=False) + thread.finished.connect(lambda: self._forget_collect_thread(thread)) + self.collect_worker = worker + self.collect_thread = thread + self._set_collect_running(True) + thread.start() + + def stop_collect(self, checked=False): + if self.collect_worker is not None: + self.collect_worker.cancel() + self._set_status("正在停止采集...") + + def write_back_old_data(self, checked=False): + batch_id = self._active_batch_id() + if not batch_id: + self._set_status("没有可回写批次") + return + self._start_write_back(batch_id) + + def _start_write_back(self, batch_id, auto=False): + if self.write_back_thread is not None: + self._set_status("Excel 回写正在进行...") + return False + worker = WriteBackWorker( + batch_id, + db_path=self.db_path, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + worker.failed.connect( + lambda task_id, error, auto=auto: self._on_write_back_failed( + task_id, + error, + auto=auto, + ) + ) + worker.finished.connect( + lambda payload, auto=auto: self._on_write_back_finished( + payload, + auto=auto, + ) + ) + thread = run_worker(worker, thread_name="WriteBackWorker", start=False) + thread.finished.connect(lambda: self._forget_write_back_thread(thread)) + self.write_back_worker = worker + self.write_back_thread = thread + self._set_write_back_running(True) + self._set_status("正在自动回写旧数据到 Excel..." if auto else "正在回写旧数据到 Excel...") + thread.start() + return True + + def _active_batch_id(self): + if self.current_batch_id: + return self.current_batch_id + batch_ids = { + task.batch_id + for task in self.model.all_tasks + if getattr(task, "batch_id", None) + } + if len(batch_ids) == 1: + return next(iter(batch_ids)) + return None + + def _set_collect_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.stop_collect_button.setEnabled(running) + self.batch_filter.setEnabled(not running) + self.shop_filter.setEnabled(not running) + self.item_filter.setEnabled(not running) + self.status_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.shop_filter.setEnabled(not running) + self.item_filter.setEnabled(not running) + self.status_filter.setEnabled(not running) + self._update_delete_batch_button() + + def _forget_collect_thread(self, thread): + if self.collect_thread is thread: + self.collect_thread = None + self.collect_worker = None + + def _forget_write_back_thread(self, thread): + if self.write_back_thread is thread: + self.write_back_thread = None + self.write_back_worker = None + + def _on_collect_progress(self, payload): + self._set_status( + "采集进度:{done}/{total},成功{collected},略过{skipped},失败{failed}".format( + done=payload.get("done", 0), + total=payload.get("total", 0), + collected=payload.get("collected", 0), + skipped=payload.get("skipped", 0), + failed=payload.get("failed", 0), + ) + ) + + def _on_collect_row_updated(self, task_id, fields): + self.refresh_tasks() + + def _on_collect_failed(self, task_id, error): + self._set_status(f"任务 {task_id} 采集失败:{error}") + + def _on_collect_finished(self, payload): + self._set_collect_running(False) + self.last_collect_run_id = payload.get("run_id") or self.last_collect_run_id + self.refresh_tasks() + self._load_latest_collect_run_log() + if payload.get("blocked"): + self._show_collect_blocked(payload) + return + message = "采集完成:成功{collected},略过{skipped},失败{failed}".format( + collected=payload.get("collected", 0), + skipped=payload.get("skipped", 0), + failed=payload.get("failed", 0), + ) + if payload.get("collected", 0) > 0: + batch_id = self._active_batch_id() + if batch_id and self._start_write_back(batch_id, auto=True): + if self.last_collect_run_id: + self._log_collect_run_event( + self.last_collect_run_id, + "step=excel_write_back result=start detail=采集成功后自动回写旧数据到 Excel", + ) + self._set_status(f"{message},正在自动回写 Excel...") + return + if not batch_id: + self._set_status(f"{message},但没有可回写批次") + return + self._set_status(f"{message},Excel 回写已在进行") + return + self._set_status(message) + + def _show_collect_blocked(self, payload): + lines = ["采集前检查未通过。"] + if payload.get("no_accounts"): + lines.append("当前没有配置账号。") + not_running = payload.get("not_running") or [] + if not_running: + lines.append( + "以下账号 Chrome 未启动或调试端口不可访问:" + + "、".join(self._account_label(item) for item in not_running) + ) + logged_out = payload.get("logged_out") or [] + if logged_out: + lines.append( + "以下账号未登录 Shopee:" + + "、".join(self._account_label(item) for item in logged_out) + ) + self._show_account_guide("\n".join(lines)) + + def _account_label(self, item): + if isinstance(item, dict): + name = item.get("account_name") or item.get("alias") or "" + alias = item.get("alias") or "" + reason = item.get("reason") + else: + name = getattr(item, "account_name", "") or getattr(item, "alias", "") + alias = getattr(item, "alias", "") + reason = getattr(item, "reason", None) + label = f"{name}({alias})" if alias and name != alias else (name or alias) + return f"{label}: {reason}" if reason else label + + def _on_collect_cancelled(self, payload): + self._set_collect_running(False) + self.refresh_tasks() + self._set_status( + "采集已停止:完成{done}/{total}".format( + done=payload.get("done", 0), + total=payload.get("total", 0), + ) + ) + + def _on_write_back_failed(self, task_id, error, auto=False): + message = f"Excel {'自动' if auto else ''}回写失败:{error}" + if "被占用" in str(error): + if auto: + message += "\n请关闭原 Excel 后点击「回写旧数据到 Excel」手动重试;SQLite 已保留采集结果,也可另存副本。" + else: + message += "\n请关闭原 Excel 后重试;SQLite 已保留采集结果,也可另存副本。" + QMessageBox.warning(self, "回写旧数据", message) + self._set_status(message.replace("\n", " ")) + if auto and self.last_collect_run_id: + self._log_collect_run_event( + self.last_collect_run_id, + f"step=excel_write_back result=failed detail={error}", + level="error", + ) + + def _on_write_back_finished(self, payload, auto=False): + self._set_write_back_running(False) + if payload.get("ok") is False: + error = payload.get("error") or "未知错误" + retry_hint = ",可点击「回写旧数据到 Excel」手动重试" if auto else "" + self._set_status(f"Excel {'自动' if auto else ''}回写失败:{error}{retry_hint}") + if auto and self.last_collect_run_id: + self._log_collect_run_event( + self.last_collect_run_id, + f"step=excel_write_back result=failed detail={error}", + level="error", + ) + return + self.refresh_tasks() + self._set_status( + "Excel {prefix}回写完成:文件{files},行{rows}".format( + prefix="自动" if auto else "", + files=payload.get("files", 0), + rows=payload.get("rows", 0), + ) + ) + if auto and self.last_collect_run_id: + self._log_collect_run_event( + self.last_collect_run_id, + "step=excel_write_back result=success detail=旧数据已回写 Excel", + ) + + def show_all_tasks(self, checked=False): + self.model.set_filter_mode("all") + self._update_empty_label(len(self.model.all_tasks)) + + def show_unmatched_tasks(self, checked=False): + self.model.set_filter_mode("unmatched") + self._update_empty_label(len(self.model.all_tasks)) + + def _update_summary(self, task_rows, account_rows): + stats = self.last_import_stats or {} + unmatched = self._unmatched_count(task_rows, account_rows) + matched = len(task_rows) - unmatched + files = stats.get("files", 0 if not task_rows else 1) + total = stats.get("total", len(task_rows)) + valid = stats.get("valid", len(task_rows)) + invalid = stats.get("invalid", 0) + invalid_text = _danger_metric_text(f"无效{invalid}", invalid > 0) + unmatched_text = _danger_metric_text(f"未匹配{unmatched}", unmatched > 0) + self.summary_label.setText( + f"{files} 文件 · {total} 行 · 有效{valid}/{invalid_text} · 匹配{matched} · {unmatched_text}" + ) + self.match_detail_label.setText(self._match_detail(task_rows, account_rows)) + self.show_unmatched_button.setText(f"未匹配({unmatched})") + self.show_unmatched_button.setEnabled(unmatched > 0) + self.show_unmatched_button.setStyleSheet( + _danger_outline_button_style("showUnmatchedButton") if unmatched > 0 else "" + ) + if unmatched == 0 and self.model.filter_mode == "unmatched": + self.model.set_filter_mode("all") + + def _match_detail(self, task_rows, account_rows): + account_by_alias = { + str(account.alias).strip(): account + for account in account_rows + if str(account.alias).strip() + } + counts = {} + for task in task_rows: + account = account_by_alias.get(str(task.alias).strip()) + if account is None: + continue + name = account.account_name or account.alias + counts[name] = counts.get(name, 0) + 1 + if not counts: + return "匹配明细:无" + parts = [f"{name}{count}" for name, count in sorted(counts.items())] + return "匹配明细:" + " · ".join(parts) + + def _unmatched_count(self, task_rows, account_rows): + aliases = { + str(account.alias).strip() + for account in account_rows + if str(account.alias).strip() + } + return sum(1 for task in task_rows if str(task.alias).strip() not in aliases) + + def _update_empty_state(self, task_rows, account_rows): + if not account_rows: + self.empty_label.setText("") + _set_empty_state( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + "第一步:前往『④账号管理』配置并登录账号,再回到①导入 Excel。", + self.open_accounts_callback is not None, + ) + return + if not task_rows: + self.empty_label.setText("") + _set_empty_state( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + "还没有导入任务。请点击「导入 Excel...」导入待处理商品。", + ) + return + _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) + self._update_empty_label(len(task_rows)) + + def _update_empty_label(self, total_rows): + if total_rows == 0: + self.empty_label.setText("暂无任务") + return + if self.model.rowCount() == 0 and self.model.filter_mode == "unmatched": + self.empty_label.setText("当前筛选没有未匹配任务") + return + if self.model.rowCount() == 0: + self.empty_label.setText("当前筛选没有匹配任务") + return + unmatched = self.model.unmatched_count() + self.empty_label.setText( + "" if unmatched == 0 else f"{unmatched} 条任务别名未匹配账号,阶段显示为“略过”" + ) + + diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py new file mode 100644 index 0000000..f087003 --- /dev/null +++ b/app/gui/tabs/generate.py @@ -0,0 +1,856 @@ +"""Tab 2: AI generation UI.""" + +from __future__ import annotations + +from ..models import GenerateTaskTableModel +from ..widgets import * +from ..workers import GenerateWorker as _RealGenerateWorker + + +def GenerateWorker(*args, **kwargs): + return _call_package_attr("GenerateWorker", _RealGenerateWorker, *args, **kwargs) + +class GenerateTab(QWidget): + """Tab 2: prompt area plus generation task filters/list.""" + + STATUS_FILTERS = [ + ("全部状态", "all"), + ("待生成", "to_generate"), + ("已生成", "generated"), + ("失败", "failed"), + ("略过", "skipped"), + ("已更新", "applied"), + ] + + def __init__( + self, + parent=None, + db_path=None, + config=None, + config_path=None, + status_callback=None, + title_prompt_path=None, + cover_prompts_dir=None, + open_accounts_callback=None, + ): + super().__init__(parent) + self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config + self.config_path = ( + config_path + or self.config.get("config_path") + or appconfig.CONFIG_PATH + ) + self.db_path = _database_path(db_path, self.config) + self.status_callback = status_callback + self.open_accounts_callback = open_accounts_callback + self.title_prompt_path = title_prompt_path or prompts.TITLE_PROMPT_PATH + self.cover_prompts_dir = cover_prompts_dir or prompts.COVER_PROMPTS_DIR + self.current_cover_template = None + self.generate_worker = None + self.generate_thread = None + + self.title_prompt_edit = QPlainTextEdit() + self.title_prompt_edit.setObjectName("titlePromptEdit") + self.title_prompt_edit.setPlaceholderText("标题提示词") + self.title_prompt_edit.setPlainText( + prompts.load_title_prompt(self.title_prompt_path) + ) + self.save_title_button = QPushButton("保存标题提示词") + self.cover_prompt_edit = QPlainTextEdit() + self.cover_prompt_edit.setObjectName("coverPromptEdit") + self.cover_prompt_edit.setPlaceholderText("封面提示词") + self.cover_template_combo = QComboBox() + self.cover_template_combo.setObjectName("coverTemplateCombo") + self.new_cover_template_button = QPushButton("新建") + self.save_cover_template_button = QPushButton("保存") + self.cover_template_actions_button = QPushButton("模板操作") + self.cover_template_actions_button.setObjectName("coverTemplateActionsButton") + self.cover_template_actions_menu = QMenu(self) + self.save_cover_template_as_action = self.cover_template_actions_menu.addAction("另存为") + self.save_cover_template_as_action.setObjectName("saveCoverTemplateAsAction") + self.rename_cover_template_action = self.cover_template_actions_menu.addAction("重命名") + self.rename_cover_template_action.setObjectName("renameCoverTemplateAction") + self.delete_cover_template_action = self.cover_template_actions_menu.addAction("删除") + self.delete_cover_template_action.setObjectName("deleteCoverTemplateAction") + self.cover_template_actions_button.setMenu(self.cover_template_actions_menu) + self.insert_title_button = QPushButton("插入标题") + self.preview_prompt_button = QPushButton("预览") + self.generate_button = QPushButton("开始生成") + self.stop_generate_button = QPushButton("停止") + self.reset_generate_button = QPushButton("重置生成结果") + self.reset_generate_button.setObjectName("resetGenerateButton") + self.stop_generate_button.setEnabled(False) + ai_settings = appconfig.ai_config(self.config) + self.generate_cover_checkbox = QCheckBox("生成封面图片(成本较高)") + self.generate_cover_checkbox.setObjectName("generateCoverCheckbox") + self.generate_cover_checkbox.setToolTip("关闭后只生成标题并保存为可更新,不调用图片模型") + self.generate_cover_checkbox.setChecked(bool(ai_settings.get("generate_cover", False))) + self.progress_label = QLabel("进度:标题0/0 · 图片0/0 · 失败0") + self.title_progress_label = QLabel("标题 0/0") + self.title_progress_label.setObjectName("generateTitleProgressLabel") + self.title_progress_bar = QProgressBar() + self.title_progress_bar.setObjectName("generateTitleProgressBar") + self.title_progress_bar.setTextVisible(False) + self.title_progress_bar.setRange(0, 1) + self.title_progress_bar.setValue(0) + self.cover_progress_label = QLabel("图片 0/0") + self.cover_progress_label.setObjectName("generateCoverProgressLabel") + self.cover_progress_bar = QProgressBar() + self.cover_progress_bar.setObjectName("generateCoverProgressBar") + self.cover_progress_bar.setTextVisible(False) + self.cover_progress_bar.setRange(0, 1) + self.cover_progress_bar.setValue(0) + self.failed_progress_label = QLabel("失败 0") + self.failed_progress_label.setObjectName("generateFailedProgressLabel") + + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + left_layout.setContentsMargins(0, 0, 12, 0) + left_layout.addWidget(QLabel("标题提示词")) + left_layout.addWidget(self.title_prompt_edit, 1) + left_layout.addWidget(self.save_title_button) + left_layout.addWidget(QLabel("封面提示词")) + left_layout.addWidget(self.cover_template_combo) + cover_template_layout = QHBoxLayout() + cover_template_layout.addWidget(self.new_cover_template_button) + cover_template_layout.addWidget(self.save_cover_template_button) + cover_template_layout.addWidget(self.cover_template_actions_button) + cover_template_layout.addStretch(1) + left_layout.addLayout(cover_template_layout) + left_layout.addWidget(self.cover_prompt_edit, 2) + cover_action_layout = QHBoxLayout() + cover_action_layout.addWidget(self.insert_title_button) + cover_action_layout.addWidget(self.preview_prompt_button) + left_layout.addLayout(cover_action_layout) + + self.batch_filter = QComboBox() + self.batch_filter.setObjectName("batchFilter") + self.shop_filter = QComboBox() + self.shop_filter.setObjectName("shopFilter") + self.item_filter = QLineEdit() + self.item_filter.setObjectName("generateItemFilter") + self.item_filter.setPlaceholderText("商品ID") + self.status_filter = QComboBox() + self.status_filter.setObjectName("statusFilter") + 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("商品ID")) + filter_layout.addWidget(self.item_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.batch_progress_label = _build_batch_progress_overview("generateBatchProgressOverview") + ( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + ) = _build_empty_state_card("generateEmptyStateCard") + if self.open_accounts_callback is not None: + self.empty_state_button.clicked.connect(self.open_accounts_callback) + self.task_table = QTableView() + self.model = GenerateTaskTableModel(self.task_table, db_path=self.db_path, status_callback=self._set_status) + self.task_table.setModel(self.model) + self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.task_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.task_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.EditKeyPressed) + self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) + self.task_table.verticalHeader().setVisible(False) + + self.run_log_view = QPlainTextEdit() + self.run_log_view.setObjectName("generateRunLogView") + self.run_log_view.setReadOnly(True) + self.run_log_view.setMaximumHeight(128) + self.run_log_view.setPlaceholderText("AI生成运行日志") + + right_panel = QWidget() + right_layout = QVBoxLayout(right_panel) + right_layout.setContentsMargins(12, 0, 0, 0) + right_layout.addLayout(filter_layout) + right_layout.addWidget(self.summary_label) + right_layout.addWidget(self.batch_progress_label) + right_layout.addWidget(self.empty_state_card) + right_layout.addWidget(self.task_table, 1) + right_layout.addWidget(QLabel("AI生成运行日志")) + right_layout.addWidget(self.run_log_view) + + self.splitter = QSplitter(Qt.Horizontal) + self.splitter.addWidget(left_panel) + self.splitter.addWidget(right_panel) + self.splitter.setStretchFactor(0, 1) + self.splitter.setStretchFactor(1, 3) + self.splitter.setSizes([280, 860]) + + title_progress_layout = QHBoxLayout() + title_progress_layout.addWidget(self.title_progress_label) + title_progress_layout.addWidget(self.title_progress_bar, 1) + cover_progress_layout = QHBoxLayout() + cover_progress_layout.addWidget(self.cover_progress_label) + cover_progress_layout.addWidget(self.cover_progress_bar, 1) + cover_progress_layout.addWidget(self.failed_progress_label) + progress_layout = QVBoxLayout() + progress_layout.addLayout(title_progress_layout) + progress_layout.addLayout(cover_progress_layout) + + button_layout = QHBoxLayout() + button_layout.addStretch(1) + button_layout.addWidget(self.generate_button) + button_layout.addWidget(self.stop_generate_button) + button_layout.addWidget(self.reset_generate_button) + + bottom_layout = QHBoxLayout() + bottom_layout.addWidget(self.generate_cover_checkbox) + bottom_layout.addLayout(progress_layout, 1) + bottom_layout.addLayout(button_layout) + + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 18, 18, 18) + layout.addWidget(self.splitter, 1) + layout.addLayout(bottom_layout) + + self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) + self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) + self.item_filter.textChanged.connect(self.refresh_tasks) + self.status_filter.currentIndexChanged.connect(self.refresh_tasks) + self.refresh_button.clicked.connect(self.refresh_tasks) + self.save_title_button.clicked.connect(self.save_title_prompt) + self.cover_template_combo.currentIndexChanged.connect(self.load_selected_cover_template) + self.new_cover_template_button.clicked.connect(self.new_cover_template) + self.save_cover_template_button.clicked.connect(self.save_cover_template) + self.save_cover_template_as_action.triggered.connect(self.save_cover_template_as) + self.rename_cover_template_action.triggered.connect(self.rename_cover_template) + self.delete_cover_template_action.triggered.connect(self.delete_cover_template) + self.insert_title_button.clicked.connect(self.insert_title_placeholder) + self.preview_prompt_button.clicked.connect(self.preview_cover_prompt) + self.generate_cover_checkbox.toggled.connect(self._on_generate_cover_toggled) + self.generate_button.clicked.connect(self.start_generate) + self.stop_generate_button.clicked.connect(self.stop_generate) + self.reset_generate_button.clicked.connect(self.reset_generated_result) + self.task_table.doubleClicked.connect(self.show_task_images) + + self.refresh_cover_templates() + self.refresh_tasks() + self._load_latest_generate_run_log() + + def _set_status(self, message): + if self.status_callback is not None: + self.status_callback(message) + + def _on_generate_log(self, message): + self._append_generate_log(message) + self._set_status(message) + + def _append_generate_log(self, message): + self.run_log_view.appendPlainText(str(message)) + scroll_bar = self.run_log_view.verticalScrollBar() + scroll_bar.setValue(scroll_bar.maximum()) + + def _on_generate_cover_toggled(self, checked): + previous = bool(appconfig.ai_config(self.config).get("generate_cover", False)) + if self._save_generate_cover_setting(show_status=True): + return + self.generate_cover_checkbox.blockSignals(True) + self.generate_cover_checkbox.setChecked(previous) + self.generate_cover_checkbox.blockSignals(False) + + def _save_generate_cover_setting(self, show_status=True): + generate_cover = bool(self.generate_cover_checkbox.isChecked()) + ai_settings = appconfig.ai_config(self.config) + ai_settings["generate_cover"] = generate_cover + payload = { + key: value + for key, value in self.config.items() + if key not in {"config_path", "ai_models_path"} + } + payload["ai"] = ai_settings + try: + saved = appconfig.save_config(payload, path=self.config_path) + except Exception as exc: + self._set_status(f"生成封面开关保存失败:{exc}") + return False + internal = { + key: value + for key, value in self.config.items() + if key in {"config_path", "ai_models_path"} + } + self.config.clear() + self.config.update(saved) + self.config.update(internal) + if self.config_path != appconfig.CONFIG_PATH: + self.config["config_path"] = self.config_path + if show_status: + mode = "会同时生成封面图片" if generate_cover else "只生成标题,不生成图片" + self._set_status(f"AI生成设置已保存:{mode}") + return True + + def _load_latest_generate_run_log(self): + try: + logs = db.list_run_logs(limit=1, run_type="generate", path=self.db_path) + if not logs: + return + events = db.list_run_log_events(logs[0].id, limit=40, path=self.db_path) + except Exception: + return + lines = [ + f"{event.created_at} [{event.level}] {event.message}" + for event in reversed(events) + ] + self.run_log_view.setPlainText("\n".join(lines)) + scroll_bar = self.run_log_view.verticalScrollBar() + scroll_bar.setValue(scroll_bar.maximum()) + + def save_title_prompt(self, checked=False): + try: + prompts.save_title_prompt( + self.title_prompt_edit.toPlainText(), + self.title_prompt_path, + ) + except Exception as exc: + self._show_prompt_error(exc) + return + self._set_status("标题提示词已保存") + + def refresh_cover_templates(self, selected=None): + try: + template_names = prompts.list_cover_templates(self.cover_prompts_dir) + except Exception as exc: + template_names = [] + self._show_prompt_error(exc) + current = selected if selected is not None else self.current_cover_template + self.cover_template_combo.blockSignals(True) + self.cover_template_combo.clear() + if template_names: + for name in template_names: + self.cover_template_combo.addItem(name, name) + index = self.cover_template_combo.findData(current) + self.cover_template_combo.setCurrentIndex(index if index >= 0 else 0) + else: + self.cover_template_combo.addItem("默认", None) + self.cover_template_combo.setCurrentIndex(0) + self.cover_template_combo.blockSignals(False) + self.load_selected_cover_template() + + def load_selected_cover_template(self, index=None): + name = self.cover_template_combo.currentData() + self.current_cover_template = name + if name is None: + self.cover_prompt_edit.setPlainText("") + return + try: + self.cover_prompt_edit.setPlainText( + prompts.load_cover_template(name, self.cover_prompts_dir) + ) + except Exception as exc: + self.cover_prompt_edit.setPlainText("") + self._show_prompt_error(exc) + + def new_cover_template(self, checked=False): + name = self._ask_template_name("新建封面提示词模板") + if not name: + return + try: + prompts.save_cover_template(name, "", self.cover_prompts_dir) + except Exception as exc: + self._show_prompt_error(exc) + return + self.refresh_cover_templates(selected=name) + self._set_status(f"封面提示词模板已新建:{name}") + + def save_cover_template(self, checked=False): + name = self.current_cover_template + if name is None: + self.save_cover_template_as() + return + try: + prompts.save_cover_template( + name, + self.cover_prompt_edit.toPlainText(), + self.cover_prompts_dir, + ) + except Exception as exc: + self._show_prompt_error(exc) + return + self._set_status(f"封面提示词模板已保存:{name}") + + def save_cover_template_as(self, checked=False): + name = self._ask_template_name("另存封面提示词模板") + if not name: + return + try: + prompts.save_cover_template( + name, + self.cover_prompt_edit.toPlainText(), + self.cover_prompts_dir, + ) + except Exception as exc: + self._show_prompt_error(exc) + return + self.refresh_cover_templates(selected=name) + self._set_status(f"封面提示词模板已另存为:{name}") + + def rename_cover_template(self, checked=False): + old_name = self.current_cover_template + if old_name is None: + self._set_status("没有可重命名的封面提示词模板") + return + new_name = self._ask_template_name("重命名封面提示词模板", text=old_name) + if not new_name or new_name == old_name: + return + try: + prompts.rename_cover_template(old_name, new_name, self.cover_prompts_dir) + except Exception as exc: + self._show_prompt_error(exc) + return + self.refresh_cover_templates(selected=new_name) + self._set_status(f"封面提示词模板已重命名:{new_name}") + + def delete_cover_template(self, checked=False): + name = self.current_cover_template + if name is None: + self._set_status("没有可删除的封面提示词模板") + return + choice = QMessageBox.question( + self, + "删除封面提示词模板", + f"确定删除「{name}」吗?", + ) + if choice != QMessageBox.Yes: + return + try: + prompts.delete_cover_template(name, self.cover_prompts_dir) + except Exception as exc: + self._show_prompt_error(exc) + return + self.refresh_cover_templates() + self._set_status(f"封面提示词模板已删除:{name}") + + def insert_title_placeholder(self, checked=False): + self.cover_prompt_edit.insertPlainText("{新标题}") + + def preview_cover_prompt(self, checked=False): + task = self._selected_task() + if task is None: + self._set_status("没有可预览的任务") + return + rendered = prompts.render_prompt( + self.cover_prompt_edit.toPlainText(), + self._prompt_context(task), + ) + QMessageBox.information(self, "封面提示词预览", rendered) + self._set_status("封面提示词预览已生成") + + def start_generate(self, checked=False): + if self.generate_thread is not None: + self._set_status("AI 生成正在进行...") + return + if not self._save_generate_cover_setting(show_status=False): + return + generate_cover = bool(self.generate_cover_checkbox.isChecked()) + tasks = [ + task for task in self.model.tasks + if getattr(task, "stage", None) == "collected" + ] + if not tasks: + self._set_status("当前筛选结果没有可生成任务") + return + prompt_values = { + "title": self.title_prompt_edit.toPlainText(), + "cover": self.cover_prompt_edit.toPlainText(), + } + worker = GenerateWorker( + tasks, + prompt_values, + db_path=self.db_path, + config=self.config, + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + worker.progress.connect(self._on_generate_progress) + worker.row_updated.connect(self._on_generate_row_updated) + worker.log.connect(self._on_generate_log) + worker.failed.connect(self._on_generate_failed) + worker.finished.connect(self._on_generate_finished) + worker.cancelled.connect(self._on_generate_cancelled) + self.run_log_view.clear() + thread = run_worker(worker, thread_name="GenerateWorker", start=False) + thread.finished.connect(lambda: self._forget_generate_thread(thread)) + self.generate_worker = worker + self.generate_thread = thread + self._set_generate_running(True) + self._update_generate_progress( + { + "total": len(tasks), + "title_done": 0, + "cover_done": 0, + "cover_total": len(tasks) if generate_cover else 0, + "generated_done": 0, + "failed": 0, + "generate_cover": generate_cover, + } + ) + self._set_status(f"开始 AI 生成:{len(tasks)} 条") + thread.start() + + def stop_generate(self, checked=False): + if self.generate_worker is not None: + self.generate_worker.cancel() + self._append_generate_log("[停止] 已收到停止请求,当前正在运行的任务结束后停止") + self._set_status("正在停止 AI 生成...") + + def reset_generated_result(self, checked=False): + if self.generate_thread is not None: + self._set_status("AI 生成正在进行,不能重置") + return + task = self._selected_task() + if task is None: + self._set_status("请选择要重置生成结果的任务") + return + has_ai_result = bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) + if not has_ai_result and getattr(task, "stage", None) not in {"generated", "applied"}: + self._set_status("选中任务没有可重置的生成结果") + return + lines = [ + "确定重置当前选中任务的本地生成结果吗?", + "", + f"商品ID:{task.item_id}", + f"店铺:{self.model.account_name_for(task)}", + "", + "将清空新标题、新封面路径和错误信息,并退回到已采集状态。", + "默认不删除本地新封面文件,不触碰 Shopee,也不会自动回写 Excel。", + ] + if getattr(task, "new_cover_path", None): + lines.append(f"本地新封面文件保留:{task.new_cover_path}") + if getattr(task, "committed", 0): + lines.extend([ + "", + "注意:该记录曾经提交过线上。本地重置不会回滚 Shopee,后续重新生成/更新可能再次提交线上。", + ]) + answer = QMessageBox.question( + self, + "重置生成结果", + "\n".join(lines), + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + self._set_status("已取消重置生成结果") + return + try: + db.reset_generated(task.id, path=self.db_path) + message = ( + "action=reset_generated step=db_write result=success " + f"detail=清空AI生成结果 task_id={task.id}" + ) + run_id = _write_reset_run_log(self.db_path, task, "reset_generated", message) + except Exception as exc: + QMessageBox.warning(self, "重置生成结果", str(exc)) + self._set_status(f"重置生成结果失败:{exc}") + return + self.refresh_tasks() + self._append_generate_log(message) + self._set_status( + f"已重置生成结果:商品 {task.item_id},run_id={run_id}" + ) + + def show_task_images(self, index): + if index.isValid() and index.column() == 3: + return + task = self.model.task_at(index.row()) if index.isValid() else self._selected_task() + if task is None: + self._set_status("没有可预览的任务") + return + dialog = QDialog(self) + dialog.setWindowTitle(f"封面对照:{task.item_id}") + layout = QVBoxLayout(dialog) + images_layout = QHBoxLayout() + images_layout.addWidget(self._image_panel("旧封面", task.old_cover_path)) + images_layout.addWidget(self._image_panel("新封面", task.new_cover_path)) + layout.addLayout(images_layout) + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(dialog.reject) + layout.addWidget(buttons) + dialog.resize(720, 420) + dialog.exec() + + def _image_panel(self, title, path): + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel(title)) + image_label = QLabel() + image_label.setAlignment(Qt.AlignCenter) + image_label.setMinimumSize(260, 260) + image_label.setWordWrap(True) + if path and os.path.exists(str(path)): + pixmap = QPixmap(str(path)) + if not pixmap.isNull(): + image_label.setPixmap( + pixmap.scaled( + 260, + 260, + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + ) + else: + image_label.setText(f"图片无法读取\n{path}") + else: + image_label.setText(f"无图片\n{path or ''}".strip()) + layout.addWidget(image_label, 1) + return panel + + def _set_generate_running(self, running): + self.generate_button.setEnabled(not running) + self.stop_generate_button.setEnabled(running) + self.reset_generate_button.setEnabled(not running) + self.refresh_button.setEnabled(not running) + self.batch_filter.setEnabled(not running) + self.shop_filter.setEnabled(not running) + self.item_filter.setEnabled(not running) + self.status_filter.setEnabled(not running) + self.save_title_button.setEnabled(not running) + self.new_cover_template_button.setEnabled(not running) + self.save_cover_template_button.setEnabled(not running) + self.cover_template_actions_button.setEnabled(not running) + self.save_cover_template_as_action.setEnabled(not running) + self.rename_cover_template_action.setEnabled(not running) + self.delete_cover_template_action.setEnabled(not running) + self.generate_cover_checkbox.setEnabled(not running) + + def _forget_generate_thread(self, thread): + if self.generate_thread is thread: + self.generate_thread = None + self.generate_worker = None + + def _on_generate_progress(self, payload): + self._update_generate_progress(payload) + self._set_status("生成进度:" + self._generate_progress_text(payload)) + + def _on_generate_row_updated(self, task_id, fields): + self.refresh_tasks() + + def _on_generate_failed(self, task_id, error): + self._set_status(f"AI 生成失败:{error}") + + def _on_generate_finished(self, payload): + self._set_generate_running(False) + self.refresh_tasks() + self._load_latest_generate_run_log() + self._update_generate_progress(payload) + if payload.get("error"): + self._set_status(f"AI 生成失败:{payload.get('error')}") + return + self._set_status("AI 生成完成:" + self._generate_progress_text(payload)) + + def _on_generate_cancelled(self, payload): + self._set_generate_running(False) + self.refresh_tasks() + self._load_latest_generate_run_log() + self._update_generate_progress(payload) + self._set_status("AI 生成已停止:" + self._generate_progress_text(payload)) + + def _update_generate_progress(self, payload): + total = max(0, int(payload.get("total", 0) or 0)) + title_done = max(0, int(payload.get("title_done", 0) or 0)) + cover_done = max(0, int(payload.get("cover_done", 0) or 0)) + cover_total = self._cover_total_for_progress(payload, total) + failed = max(0, int(payload.get("failed", 0) or 0)) + self.progress_label.setText("进度:" + self._generate_progress_text(payload)) + self.title_progress_label.setText(f"标题 {title_done}/{total}") + self.cover_progress_label.setText(f"图片 {cover_done}/{cover_total}") + self.failed_progress_label.setText(f"失败 {failed}") + self._set_progress_bar(self.title_progress_bar, title_done, total) + self._set_progress_bar(self.cover_progress_bar, cover_done, cover_total) + + def _set_progress_bar(self, bar, done, total): + maximum = max(1, int(total or 0)) + value = min(max(0, int(done or 0)), maximum) + bar.setRange(0, maximum) + bar.setValue(value) + + def _cover_total_for_progress(self, payload, total): + cover_total = payload.get("cover_total") + if cover_total is None: + cover_total = total if payload.get("generate_cover", True) else 0 + return max(0, int(cover_total or 0)) + + def _generate_progress_text(self, payload): + total = max(0, int(payload.get("total", 0) or 0)) + cover_total = self._cover_total_for_progress(payload, total) + return "标题{title}/{total} · 图片{cover}/{cover_total} · 失败{failed}".format( + title=payload.get("title_done", 0), + cover=payload.get("cover_done", 0), + cover_total=cover_total, + total=payload.get("total", 0), + failed=payload.get("failed", 0), + ) + + def _selected_task(self): + index = self.task_table.currentIndex() + if index.isValid(): + return self.model.task_at(index.row()) + if self.model.rowCount() > 0: + return self.model.task_at(0) + return None + + def _prompt_context(self, task): + return { + "old_title": task.old_title, + "new_title": task.new_title, + "item_id": task.item_id, + "account_name": self.model.account_name_for(task), + "alias": task.alias, + } + + def _ask_template_name(self, title, text=""): + value, ok = QInputDialog.getText( + self, + title, + "模板名", + QLineEdit.Normal, + text, + ) + if not ok: + return None + return str(value).strip() + + def _show_prompt_error(self, error): + message = str(error) + QMessageBox.warning(self, "提示词管理", message) + self._set_status(message) + + def refresh_tasks(self, checked=False): + try: + db.init_db(self.db_path) + batches = db.list_batches(path=self.db_path) + accounts_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 "all" + item_query = self.item_filter.text().strip() + self._populate_batch_filter(batches, selected_batch) + selected_batch = self.batch_filter.currentData() + batch_tasks = db.list_tasks(batch_id=selected_batch, path=self.db_path) + self._populate_shop_filter(batch_tasks, accounts_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_item(task, item_query) + and self._matches_status(task, selected_status) + ] + except Exception as exc: + self.model.set_tasks([], []) + self.summary_label.setText("任务读取失败") + _set_batch_progress_overview(self.batch_progress_label, []) + _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) + self._set_status(f"AI 生成任务读取失败:{exc}") + return + self.model.set_tasks(filtered_tasks, accounts_rows) + self.summary_label.setText( + f"任务 {len(filtered_tasks)}/{len(batch_tasks)} 条" + ) + _set_batch_progress_overview(self.batch_progress_label, batch_tasks) + self._update_empty_state(batch_tasks, filtered_tasks, accounts_rows) + + def _update_empty_state(self, batch_tasks, filtered_tasks, account_rows): + if not account_rows: + _set_empty_state( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + "第一步:前往『④账号管理』配置并登录账号,再回到②生成标题和封面。", + self.open_accounts_callback is not None, + ) + return + if not batch_tasks: + _set_empty_state( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + "还没有可生成任务。请先在①导入采集完成旧标题和旧封面采集。", + ) + return + if not filtered_tasks: + _set_empty_state( + self.empty_state_card, + self.empty_state_label, + self.empty_state_button, + "当前筛选没有匹配的生成任务,请调整批次、店铺、商品ID或状态筛选。", + ) + return + _set_empty_state(self.empty_state_card, self.empty_state_label, self.empty_state_button) + + 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 _matches_shop(self, task, selected_shop): + return selected_shop is None or str(task.alias).strip() == selected_shop + + def _matches_item(self, task, item_query): + if not item_query: + return True + return item_query in str(getattr(task, "item_id", "")) + + def _matches_status(self, task, selected_status): + if selected_status in (None, "all"): + return True + if selected_status == "to_generate": + return task.stage == "collected" and task.status in {"success", "pending"} + if selected_status == "generated": + return task.stage == "generated" + if selected_status == "applied": + return task.stage == "applied" + if selected_status == "failed": + return task.status == "failed" + if selected_status == "skipped": + return task.status == "skipped" + return True + + diff --git a/app/gui/tabs/settings.py b/app/gui/tabs/settings.py new file mode 100644 index 0000000..720d23c --- /dev/null +++ b/app/gui/tabs/settings.py @@ -0,0 +1,800 @@ +"""Tab 5: settings UI.""" + +from __future__ import annotations + +from ..widgets import * +from ..workers import AIModelTestWorker as _RealAIModelTestWorker + + +def AIModelTestWorker(*args, **kwargs): + return _call_package_attr("AIModelTestWorker", _RealAIModelTestWorker, *args, **kwargs) + +class SettingsTab(QWidget): + """Tab 5: AI model definitions stored in config/ai_models.json.""" + + CATEGORY_ITEMS = [("文本", "text"), ("图像", "image")] + API_TYPE_ITEMS = [("chat", "chat"), ("images_edits", "images_edits"), ("auto", "auto")] + RESOLUTION_ITEMS = ["512", "1k", "2k", "4k"] + + def __init__( + self, + parent=None, + config=None, + config_path=None, + ai_models_path=None, + status_callback=None, + ): + super().__init__(parent) + self.config = appconfig.load_config() if config is None else config + self.config_path = ( + config_path + or self.config.get("config_path") + or appconfig.CONFIG_PATH + ) + self.ai_models_path = ( + ai_models_path + or self.config.get("ai_models_path") + or appconfig.AI_MODELS_PATH + ) + self.status_callback = status_callback + self.models = [] + self.current_model_name = None + self.test_worker = None + self.test_thread = None + self._compat_test_item_id = "" + + self.model_combo = QComboBox() + self.model_combo.setObjectName("aiModelCombo") + self.add_model_button = QPushButton("新增") + self.delete_model_button = QPushButton("删除") + + self.enabled_checkbox = QCheckBox("启用") + self.name_edit = QLineEdit() + self.name_edit.setObjectName("modelNameEdit") + self.category_combo = QComboBox() + self.category_combo.setObjectName("modelCategoryCombo") + for label, value in self.CATEGORY_ITEMS: + self.category_combo.addItem(label, value) + self.api_type_combo = QComboBox() + self.api_type_combo.setObjectName("modelApiTypeCombo") + for label, value in self.API_TYPE_ITEMS: + self.api_type_combo.addItem(label, value) + self.model_id_edit = QLineEdit() + self.model_id_edit.setObjectName("modelIdEdit") + self.url_edit = QLineEdit() + self.url_edit.setObjectName("modelUrlEdit") + self.api_key_edit = QLineEdit() + self.api_key_edit.setObjectName("modelApiKeyEdit") + self.api_key_edit.setEchoMode(QLineEdit.Password) + self.connect_timeout_spin = QSpinBox() + self.connect_timeout_spin.setObjectName("connectTimeoutSpin") + self.connect_timeout_spin.setRange(1, 3600) + self.connect_timeout_spin.setValue(30) + self.save_model_button = QPushButton("保存") + self.test_connection_button = QPushButton("测试连接") + self.test_result_label = QLabel("") + self.test_result_label.setWordWrap(True) + self.default_text_model_combo = QComboBox() + self.default_text_model_combo.setObjectName("defaultTextModelCombo") + self.default_image_model_combo = QComboBox() + self.default_image_model_combo.setObjectName("defaultImageModelCombo") + self.title_concurrency_spin = QSpinBox() + self.title_concurrency_spin.setObjectName("titleConcurrencySpin") + self.title_concurrency_spin.setRange(1, 64) + self.image_concurrency_spin = QSpinBox() + self.image_concurrency_spin.setObjectName("imageConcurrencySpin") + self.image_concurrency_spin.setRange(1, 64) + self.retry_spin = QSpinBox() + self.retry_spin.setObjectName("retrySpin") + self.retry_spin.setRange(0, 20) + self.resolution_combo = QComboBox() + self.resolution_combo.setObjectName("resolutionCombo") + for resolution in self.RESOLUTION_ITEMS: + self.resolution_combo.addItem(resolution, resolution) + self.response_timeout_label = QLabel("") + self.jpg_quality_spin = QSpinBox() + self.jpg_quality_spin.setObjectName("jpgQualitySpin") + self.jpg_quality_spin.setRange(1, 100) + self.chrome_path_edit = QLineEdit() + self.chrome_path_edit.setObjectName("chromePathEdit") + self.user_data_root_edit = QLineEdit() + self.user_data_root_edit.setObjectName("userDataRootEdit") + self.image_dir_edit = QLineEdit() + self.image_dir_edit.setObjectName("imageDirEdit") + self.db_path_edit = QLineEdit() + self.db_path_edit.setObjectName("dbPathEdit") + self.default_debug_port_spin = QSpinBox() + self.default_debug_port_spin.setObjectName("defaultDebugPortSpin") + self.default_debug_port_spin.setRange(1, 65535) + self.debug_port_start_spin = QSpinBox() + self.debug_port_start_spin.setObjectName("debugPortStartSpin") + self.debug_port_start_spin.setRange(1, 65535) + self.debug_port_end_spin = QSpinBox() + self.debug_port_end_spin.setObjectName("debugPortEndSpin") + self.debug_port_end_spin.setRange(1, 65535) + self.cdp_ready_timeout_spin = QSpinBox() + self.cdp_ready_timeout_spin.setObjectName("cdpReadyTimeoutSpin") + self.cdp_ready_timeout_spin.setRange(1, 3600) + self.save_config_button = QPushButton("保存设置") + self.allow_real_submit_checkbox = QCheckBox("允许真实提交线上商品") + self.allow_real_submit_checkbox.setObjectName("allowRealSubmitCheckbox") + self.allow_cover_update_checkbox = QCheckBox("允许更新封面") + self.allow_cover_update_checkbox.setObjectName("allowCoverUpdateCheckbox") + self.max_items_per_run_spin = QSpinBox() + self.max_items_per_run_spin.setObjectName("maxItemsPerRunSpin") + self.max_items_per_run_spin.setRange(1, 9999) + self.max_items_per_run_spin.setToolTip("作为每批最大更新条数;正式更新会分批处理当前筛选全部可更新记录。") + self.close_success_tab_checkbox = QCheckBox("成功后关闭本次新开编辑页") + self.close_success_tab_checkbox.setObjectName("closeSuccessTabCheckbox") + self.parallel_accounts_checkbox = QCheckBox("多账号并行更新") + self.parallel_accounts_checkbox.setObjectName("parallelAccountsCheckbox") + self.max_parallel_accounts_spin = QSpinBox() + self.max_parallel_accounts_spin.setObjectName("maxParallelAccountsSpin") + self.max_parallel_accounts_spin.setRange(1, 16) + self.max_parallel_accounts_label = QLabel("最大并行账号数") + self.parallel_accounts_group = QWidget() + self.parallel_accounts_group.setObjectName("parallelAccountsGroup") + parallel_accounts_layout = QHBoxLayout(self.parallel_accounts_group) + parallel_accounts_layout.setContentsMargins(0, 0, 0, 0) + parallel_accounts_layout.setSpacing(12) + parallel_accounts_layout.addWidget(self.parallel_accounts_checkbox) + parallel_accounts_layout.addWidget(self.max_parallel_accounts_label) + parallel_accounts_layout.addWidget(self.max_parallel_accounts_spin) + parallel_accounts_layout.addStretch(1) + + model_picker_layout = QHBoxLayout() + model_picker_layout.addWidget(self.model_combo, 1) + model_picker_layout.addWidget(self.add_model_button) + model_picker_layout.addWidget(self.delete_model_button) + + action_layout = QHBoxLayout() + action_layout.addWidget(self.save_model_button) + action_layout.addWidget(self.test_connection_button) + action_layout.addStretch(1) + + form = self._three_column_form( + [ + ("状态", self.enabled_checkbox), + ("服务商名", self.name_edit), + ("类别", self.category_combo), + ("api_type", self.api_type_combo), + ("模型ID", self.model_id_edit), + ("连接超时(秒)", self.connect_timeout_spin), + ("网址", self.url_edit, True), + ("密钥", self.api_key_edit, True), + ] + ) + + ai_form = self._three_column_form( + [ + ("标题大模型", self.default_text_model_combo), + ("图片大模型", self.default_image_model_combo), + ("标题并发数", self.title_concurrency_spin), + ("图片并发数", self.image_concurrency_spin), + ("失败重试次数", self.retry_spin), + ("分辨率", self.resolution_combo), + ("返回超时", self.response_timeout_label), + ("jpg质量", self.jpg_quality_spin), + ] + ) + + port_range_layout = QHBoxLayout() + port_range_layout.setContentsMargins(0, 0, 0, 0) + port_range_layout.addWidget(self.debug_port_start_spin) + port_range_layout.addWidget(QLabel("到")) + port_range_layout.addWidget(self.debug_port_end_spin) + port_range_widget = QWidget() + port_range_widget.setLayout(port_range_layout) + + path_form = self._three_column_form( + [ + ("Chrome路径", self.chrome_path_edit, True), + ("账号数据根目录", self.user_data_root_edit), + ("图片目录", self.image_dir_edit), + ("DB路径", self.db_path_edit), + ("默认调试端口", self.default_debug_port_spin), + ("调试端口范围", port_range_widget), + ("CDP就绪超时(秒)", self.cdp_ready_timeout_spin), + ] + ) + + self.shopee_update_form_layout = self._three_column_form( + [ + ("每批最大更新条数", self.max_items_per_run_spin), + ("", self.allow_real_submit_checkbox), + ("", self.close_success_tab_checkbox), + ("", self.allow_cover_update_checkbox), + ("", self.parallel_accounts_group, 2), + ] + ) + + panel = QWidget() + panel.setMaximumWidth(1800) + panel_layout = QVBoxLayout(panel) + self.settings_panel_layout = panel_layout + panel_layout.setContentsMargins(13, 18, 13, 18) + self.ai_model_section_title = self._section_title( + "AI 模型", + "settingsAiModelSectionTitle", + ) + self.model_detail_section_title = self._section_title( + "模型详情", + "settingsModelDetailSectionTitle", + ) + self.generation_section_title = self._section_title( + "角色与生成参数", + "settingsGenerationSectionTitle", + ) + self.shopee_update_section_title = self._section_title( + "Shopee 更新安全 / 执行模式", + "settingsShopeeUpdateSectionTitle", + ) + self.infrastructure_section_title = self._section_title( + "基础设施(路径与端口)", + "settingsInfrastructureSectionTitle", + ) + panel_layout.addWidget(self.ai_model_section_title) + panel_layout.addLayout(model_picker_layout) + panel_layout.addSpacing(14) + panel_layout.addWidget(self.model_detail_section_title) + panel_layout.addLayout(form) + panel_layout.addLayout(action_layout) + panel_layout.addWidget(self.test_result_label) + panel_layout.addSpacing(18) + panel_layout.addWidget(self.generation_section_title) + panel_layout.addLayout(ai_form) + panel_layout.addSpacing(18) + panel_layout.addWidget(self.shopee_update_section_title) + panel_layout.addLayout(self.shopee_update_form_layout) + panel_layout.addSpacing(18) + panel_layout.addWidget(self.infrastructure_section_title) + panel_layout.addLayout(path_form) + panel_layout.addWidget(self.save_config_button) + panel_layout.addStretch(1) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll_content = QWidget() + scroll_layout = QHBoxLayout(scroll_content) + scroll_layout.setContentsMargins(0, 0, 0, 0) + scroll_layout.addStretch(1) + scroll_layout.addWidget(panel) + scroll_layout.addStretch(1) + scroll.setWidget(scroll_content) + + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 18, 18, 18) + layout.addWidget(scroll, 1) + + self.model_combo.currentIndexChanged.connect(self.load_selected_model) + self.add_model_button.clicked.connect(self.add_model) + self.delete_model_button.clicked.connect(self.delete_model) + self.save_model_button.clicked.connect(self.save_model) + self.test_connection_button.clicked.connect(self.test_connection) + self.resolution_combo.currentIndexChanged.connect( + self._update_response_timeout_label + ) + self.save_config_button.clicked.connect(self.save_app_settings) + + self.refresh_models() + self._populate_app_settings() + + def _three_column_form(self, fields): + layout = QGridLayout() + layout.setHorizontalSpacing(18) + layout.setVerticalSpacing(8) + for column in (1, 3, 5): + layout.setColumnStretch(column, 1) + row = 0 + column_pair = 0 + for field in fields: + label = field[0] + widget = field[1] + span_pairs = self._form_field_span_pairs(field) + if span_pairs > 3 - column_pair: + row += 1 + column_pair = 0 + column = column_pair * 2 + self._add_form_field(layout, row, column, label, widget, span_pairs) + column_pair += span_pairs + if column_pair >= 3: + row += 1 + column_pair = 0 + return layout + + def _form_field_span_pairs(self, field): + if len(field) <= 2: + return 1 + span = field[2] + if isinstance(span, bool): + return 3 if span else 1 + return max(1, min(3, int(span or 1))) + + def _add_form_field(self, layout, row, column, label, widget, span_pairs): + if label: + layout.addWidget(QLabel(label), row, column) + layout.addWidget(widget, row, column + 1, 1, span_pairs * 2 - 1) + else: + layout.addWidget(widget, row, column, 1, span_pairs * 2) + + def _section_title(self, text, object_name): + label = QLabel(text) + label.setObjectName(object_name) + label.setStyleSheet("color: #24292f; font-weight: 600; padding-top: 4px;") + return label + + def _set_status(self, message): + if self.status_callback is not None: + self.status_callback(message) + + def refresh_models(self, selected=None): + try: + self.models = appconfig.list_ai_models( + path=self.ai_models_path, + reveal_api_key=True, + ) + except Exception as exc: + self.models = [] + self.current_model_name = None + self._show_error(exc) + + current = selected or self.current_model_name + self.model_combo.blockSignals(True) + self.model_combo.clear() + for model in self.models: + label = f"{model['name']} · {self._category_label(model['category'])}" + if not model.get("enabled", True): + label += " · 已停用" + self.model_combo.addItem(label, model["name"]) + index = self.model_combo.findData(current) + self.model_combo.setCurrentIndex(index if index >= 0 else (0 if self.models else -1)) + self.model_combo.blockSignals(False) + self.load_selected_model() + if hasattr(self, "default_text_model_combo"): + self._populate_role_model_combos() + + def load_selected_model(self, index=None): + name = self.model_combo.currentData() + model = self._model_by_name(name) + self.current_model_name = model["name"] if model else None + self._populate_form(model) + self._update_button_state() + + def add_model(self, checked=False): + name = self._unique_model_name("新文本模型") + model = { + "name": name, + "category": "text", + "enabled": True, + "url": "", + "model": "", + "api_key": "", + "api_type": "chat", + "connect_timeout_seconds": 30, + "timeout_seconds": 0, + "extra_body": {}, + } + try: + appconfig.add_ai_model(model, path=self.ai_models_path) + except Exception as exc: + self._show_error(exc) + return + self.refresh_models(selected=name) + self._set_status(f"AI 模型已新增:{name}") + + def save_model(self, checked=False): + model = self._form_values() + if model is None: + return + current = self._current_model() + if self._should_warn_plaintext_api_key(model, current): + self._show_plaintext_api_key_warning() + try: + if self.current_model_name is None: + appconfig.add_ai_model(model, path=self.ai_models_path) + else: + appconfig.update_ai_model( + self.current_model_name, + path=self.ai_models_path, + **model, + ) + except Exception as exc: + self._show_error(exc) + return + self.refresh_models(selected=model["name"]) + self._set_status(f"AI 模型已保存:{model['name']}") + + def delete_model(self, checked=False): + model = self._current_model() + if model is None: + return + if not self._can_delete_model(model): + self._set_status("每个类别至少保留一个模型,当前模型不能删除") + return + answer = QMessageBox.question( + self, + "删除 AI 模型", + f"确认删除模型「{model['name']}」?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + return + try: + appconfig.delete_ai_model(model["name"], path=self.ai_models_path) + except Exception as exc: + self._show_error(exc) + return + self.refresh_models() + self._set_status(f"AI 模型已删除:{model['name']}") + + def test_connection(self, checked=False): + if self.test_thread is not None: + self._set_status("模型连接测试正在进行...") + return + model = self._current_model() + if model is None: + return + if self.name_edit.text().strip() != model["name"]: + self._set_status("请先保存模型名称变更后再测试连接") + return + worker = AIModelTestWorker( + model["name"], + ai_models_path=self.ai_models_path, + db_path=_database_path(config=self.config), + diagnostic_log_dir=diagnostics.DEFAULT_LOG_DIR, + ) + worker.finished.connect(self._on_test_finished) + worker.failed.connect(self._on_test_failed) + thread = run_worker(worker, thread_name="AIModelTestWorker", start=False) + thread.finished.connect(lambda: self._forget_test_thread(thread)) + self.test_worker = worker + self.test_thread = thread + self._set_test_running(True) + self.test_result_label.setText("正在测试连接...") + self._set_status(f"正在测试 AI 模型连接:{model['name']}") + thread.start() + + def save_app_settings(self, checked=False): + settings = self._app_settings_values() + if settings is None: + return + try: + saved = appconfig.save_config(settings, path=self.config_path) + except Exception as exc: + self._show_error(exc) + return + self._replace_config(saved) + self._populate_app_settings() + self._set_status("设置已保存") + QMessageBox.information(self, "保存设置", "设置已保存") + + def _app_settings_values(self): + start_port = self.debug_port_start_spin.value() + end_port = self.debug_port_end_spin.value() + default_port = self.default_debug_port_spin.value() + if start_port > end_port: + self._show_error("调试端口范围起始值不能大于结束值") + return None + if not (start_port <= default_port <= end_port): + self._show_error("默认调试端口必须在调试端口范围内") + return None + text_model = self.default_text_model_combo.currentData() + image_model = self.default_image_model_combo.currentData() + if not text_model or not image_model: + self._show_error("标题大模型和图片大模型不能为空") + return None + + ai_cfg = appconfig.ai_config(self.config) + ai_cfg.update( + { + "default_text_model": text_model, + "default_image_model": image_model, + "title_concurrency": self.title_concurrency_spin.value(), + "image_concurrency": self.image_concurrency_spin.value(), + "retry": self.retry_spin.value(), + "jpg_quality": self.jpg_quality_spin.value(), + "resolution": self.resolution_combo.currentData() or "1k", + "resolution_timeouts": dict(ai_cfg.get("resolution_timeouts", {})), + } + ) + + settings = { + key: value + for key, value in self.config.items() + if key not in {"config_path", "ai_models_path"} + } + settings.update( + { + "chrome_path": self.chrome_path_edit.text().strip(), + "user_data_root": self.user_data_root_edit.text().strip(), + "image_dir": self.image_dir_edit.text().strip(), + "db_path": self.db_path_edit.text().strip(), + "default_debug_port": default_port, + "debug_port_range": [start_port, end_port], + "cdp_ready_timeout": self.cdp_ready_timeout_spin.value(), + "ai": ai_cfg, + "shopee_update": { + "test_item_id": str(self._compat_test_item_id or ""), + "allow_real_submit": self.allow_real_submit_checkbox.isChecked(), + "allow_cover_update": self.allow_cover_update_checkbox.isChecked(), + "max_items_per_run": self.max_items_per_run_spin.value(), + "close_success_tab": self.close_success_tab_checkbox.isChecked(), + "dry_run": False, + "parallel_accounts": self.parallel_accounts_checkbox.isChecked(), + "max_parallel_accounts": self.max_parallel_accounts_spin.value(), + }, + } + ) + return settings + + def _replace_config(self, saved): + internal = {} + if self.config_path != appconfig.CONFIG_PATH: + internal["config_path"] = self.config_path + if self.ai_models_path != appconfig.AI_MODELS_PATH: + internal["ai_models_path"] = self.ai_models_path + self.config.clear() + self.config.update(saved) + self.config.update(internal) + + def _populate_app_settings(self): + self._populate_role_model_combos() + ai_cfg = appconfig.ai_config(self.config) + self._set_combo_by_data( + self.default_text_model_combo, + ai_cfg.get("default_text_model", ""), + ) + self._set_combo_by_data( + self.default_image_model_combo, + ai_cfg.get("default_image_model", ""), + ) + self.title_concurrency_spin.setValue( + int(ai_cfg.get("title_concurrency", 4) or 4) + ) + self.image_concurrency_spin.setValue( + int(ai_cfg.get("image_concurrency", 4) or 4) + ) + self.retry_spin.setValue(int(ai_cfg.get("retry", 2) or 0)) + self._set_combo_by_data( + self.resolution_combo, + str(ai_cfg.get("resolution", "1k")), + ) + self.jpg_quality_spin.setValue(int(ai_cfg.get("jpg_quality", 90) or 90)) + self.chrome_path_edit.setText(appconfig.chrome_path(self.config)) + self.user_data_root_edit.setText(appconfig.user_data_root(self.config)) + self.image_dir_edit.setText(appconfig.image_dir(self.config)) + self.db_path_edit.setText(appconfig.db_path(self.config)) + self.default_debug_port_spin.setValue( + int(appconfig.default_debug_port(self.config)) + ) + start_port, end_port = appconfig.debug_port_range(self.config) + self.debug_port_start_spin.setValue(int(start_port)) + self.debug_port_end_spin.setValue(int(end_port)) + self.cdp_ready_timeout_spin.setValue( + int(appconfig.cdp_ready_timeout(self.config)) + ) + update_cfg = self._shopee_update_config() + self._compat_test_item_id = str(update_cfg.get("test_item_id", "")) + self.allow_real_submit_checkbox.setChecked( + bool(update_cfg.get("allow_real_submit", False)) + ) + self.allow_cover_update_checkbox.setChecked( + bool(update_cfg.get("allow_cover_update", False)) + ) + self.max_items_per_run_spin.setValue( + max(1, int(update_cfg.get("max_items_per_run", 1) or 1)) + ) + self.close_success_tab_checkbox.setChecked( + bool(update_cfg.get("close_success_tab", False)) + ) + self.parallel_accounts_checkbox.setChecked( + bool(update_cfg.get("parallel_accounts", False)) + ) + self.max_parallel_accounts_spin.setValue( + max(1, int(update_cfg.get("max_parallel_accounts", 2) or 2)) + ) + self._update_response_timeout_label() + + def _shopee_update_config(self): + defaults = appconfig.default_config().get("shopee_update", {}) + loaded = self.config.get("shopee_update", {}) + if not isinstance(loaded, dict): + loaded = {} + merged = dict(defaults) + merged.update(loaded) + return merged + + def _populate_role_model_combos(self): + ai_cfg = appconfig.ai_config(self.config) + self._populate_role_combo( + self.default_text_model_combo, + "text", + ai_cfg.get("default_text_model"), + ) + self._populate_role_combo( + self.default_image_model_combo, + "image", + ai_cfg.get("default_image_model"), + ) + + def _populate_role_combo(self, combo, category, selected): + combo.blockSignals(True) + combo.clear() + for model in self.models: + if model.get("category") == category and model.get("enabled", True): + combo.addItem(model.get("name", ""), model.get("name", "")) + if combo.count() == 0: + combo.addItem("无可用模型", None) + index = combo.findData(selected) + combo.setCurrentIndex(index if index >= 0 else 0) + combo.blockSignals(False) + + def _update_response_timeout_label(self, index=None): + ai_cfg = appconfig.ai_config(self.config) + resolution = self.resolution_combo.currentData() or ai_cfg.get("resolution", "1k") + timeouts = ai_cfg.get("resolution_timeouts", {}) + timeout = timeouts.get(str(resolution)) + if timeout is None: + self.response_timeout_label.setText("未配置") + return + self.response_timeout_label.setText(f"{int(timeout)} 秒") + + def _form_values(self): + current = self._current_model() or {} + name = self.name_edit.text().strip() + if not name: + self._show_error("AI 模型服务商名不能为空") + return None + extra_body = current.get("extra_body", {}) + if not isinstance(extra_body, dict): + extra_body = {} + return { + "name": name, + "category": self.category_combo.currentData() or "text", + "enabled": self.enabled_checkbox.isChecked(), + "url": self.url_edit.text().strip(), + "model": self.model_id_edit.text().strip(), + "api_key": self.api_key_edit.text(), + "api_type": self.api_type_combo.currentData() or "auto", + "connect_timeout_seconds": self.connect_timeout_spin.value(), + "timeout_seconds": int(current.get("timeout_seconds", 0) or 0), + "extra_body": dict(extra_body), + } + + def _populate_form(self, model): + widgets = [ + self.enabled_checkbox, + self.name_edit, + self.category_combo, + self.api_type_combo, + self.model_id_edit, + self.url_edit, + self.api_key_edit, + self.connect_timeout_spin, + ] + for widget in widgets: + widget.blockSignals(True) + if model is None: + self.enabled_checkbox.setChecked(False) + self.name_edit.clear() + self.category_combo.setCurrentIndex(0) + self.api_type_combo.setCurrentIndex(0) + self.model_id_edit.clear() + self.url_edit.clear() + self.api_key_edit.clear() + self.connect_timeout_spin.setValue(30) + else: + self.enabled_checkbox.setChecked(bool(model.get("enabled", True))) + self.name_edit.setText(model.get("name", "")) + self._set_combo_by_data(self.category_combo, model.get("category", "text")) + self._set_combo_by_data(self.api_type_combo, model.get("api_type", "auto")) + self.model_id_edit.setText(model.get("model", "")) + self.url_edit.setText(model.get("url", "")) + self.api_key_edit.setText(model.get("api_key", "")) + self.connect_timeout_spin.setValue( + int(model.get("connect_timeout_seconds", 30) or 30) + ) + for widget in widgets: + widget.blockSignals(False) + + def _set_combo_by_data(self, combo, value): + index = combo.findData(value) + combo.setCurrentIndex(index if index >= 0 else 0) + + def _update_button_state(self): + has_model = self._current_model() is not None + testing = self.test_thread is not None + for widget in ( + self.enabled_checkbox, + self.name_edit, + self.category_combo, + self.api_type_combo, + self.model_id_edit, + self.url_edit, + self.api_key_edit, + self.connect_timeout_spin, + self.save_model_button, + ): + widget.setEnabled(has_model and not testing) + self.add_model_button.setEnabled(not testing) + self.delete_model_button.setEnabled( + has_model and not testing and self._can_delete_model(self._current_model()) + ) + self.test_connection_button.setEnabled(has_model and not testing) + + def _set_test_running(self, running): + self._update_button_state() + self.test_connection_button.setEnabled( + not running and self._current_model() is not None + ) + + def _forget_test_thread(self, thread): + if self.test_thread is thread: + self.test_thread = None + self.test_worker = None + self._set_test_running(False) + + def _on_test_finished(self, payload): + if payload.get("ok"): + status = payload.get("status") + suffix = f"(HTTP {status})" if status else "" + message = f"测试连接成功:{payload.get('name')}{suffix}" + else: + error = payload.get("error") or "连接失败" + status = payload.get("status") + status_text = f"HTTP {status}," if status else "" + message = f"测试连接失败:{status_text}{error}" + self.test_result_label.setText(message) + self._set_status(message) + + def _on_test_failed(self, _task_id, error): + message = f"测试连接失败:{error}" + self.test_result_label.setText(message) + self._set_status(message) + + def _show_error(self, error): + message = str(error) + QMessageBox.warning(self, "设置", message) + self._set_status(message) + + def _should_warn_plaintext_api_key(self, model, current): + new_key = str((model or {}).get("api_key") or "") + current_key = str((current or {}).get("api_key") or "") + return bool(new_key) and new_key != current_key + + def _show_plaintext_api_key_warning(self): + QMessageBox.warning( + self, + PLAINTEXT_SECRET_TITLE, + PLAINTEXT_API_KEY_WARNING, + ) + + def _current_model(self): + return self._model_by_name(self.current_model_name) + + def _model_by_name(self, name): + for model in self.models: + if model.get("name") == name: + return model + return None + + def _unique_model_name(self, base): + names = {model.get("name") for model in self.models} + if base not in names: + return base + counter = 2 + while f"{base} {counter}" in names: + counter += 1 + return f"{base} {counter}" + + def _can_delete_model(self, model): + if model is None: + return False + category = model.get("category") + return sum(1 for item in self.models if item.get("category") == category) > 1 + + def _category_label(self, category): + return {"text": "文本", "image": "图像"}.get(category, category) + + diff --git a/app/gui/widgets.py b/app/gui/widgets.py new file mode 100644 index 0000000..2fac6dd --- /dev/null +++ b/app/gui/widgets.py @@ -0,0 +1,428 @@ +"""PySide6 GUI entry point.""" + +from __future__ import annotations + +import os +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed + +try: + from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt + from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap + from PySide6.QtWidgets import ( + QAbstractItemView, + QApplication, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFormLayout, + QGridLayout, + QHBoxLayout, + QHeaderView, + QInputDialog, + QLabel, + QLineEdit, + QMainWindow, + QMenu, + QMessageBox, + QPlainTextEdit, + QProgressBar, + QPushButton, + QScrollArea, + QSplitter, + QTableView, + QSpinBox, + QTableWidget, + QTableWidgetItem, + QTabWidget, + QVBoxLayout, + QWidget, + ) + QT_IMPORT_ERROR = None +except ModuleNotFoundError as exc: + QApplication = None + QMainWindow = object + QTabWidget = None + QVBoxLayout = None + QWidget = object + QT_IMPORT_ERROR = exc + + +TAB_TITLES = [ + "① 导入采集", + "② AI生成", + "③ 更新shopee", + "④ 账号管理", + "⑤ 设置", +] + +TAB_STYLE = """ +QTabWidget::pane { + border-top: 1px solid #c9d1d9; +} +QTabBar::tab { + min-width: 128px; + min-height: 34px; + padding: 8px 18px; + margin-right: 8px; + border: 1px solid #c9d1d9; + border-bottom-color: #b8c0ca; + background: #f4f6f8; + color: #24292f; +} +QTabBar::tab:selected { + background: #ffffff; + border-color: #687785; + border-bottom-color: #ffffff; + font-weight: 600; +} +QTabBar::tab:hover:!selected { + background: #eaf2ff; +} +""" +COLOR_SUCCESS = "#1a7f37" +COLOR_DANGER = "#cf222e" +COLOR_INFO = "#0969da" +COLOR_PENDING = "#9a6700" +COLOR_MUTED = "#6e7781" +COLOR_WARNING = "#bc4c00" +COLOR_DANGER_BG = "#ffebe9" + + + +_RawQMessageBox = QMessageBox if QT_IMPORT_ERROR is None else None + + +class _GuiAttributeProxy: + def __init__(self, name, fallback): + self._name = name + self._fallback = fallback + + def _target(self): + package = sys.modules.get("app.gui") + if package is not None: + value = getattr(package, self._name, self._fallback) + if value is not self: + return value + return self._fallback + + def __getattr__(self, item): + return getattr(self._target(), item) + + def __call__(self, *args, **kwargs): + return self._target()(*args, **kwargs) + + +def _package_attr(name, fallback): + package = sys.modules.get("app.gui") + if package is not None: + value = getattr(package, name, fallback) + if value is not fallback: + return value + return fallback + + +def _call_package_attr(name, fallback, *args, **kwargs): + return _package_attr(name, fallback)(*args, **kwargs) + + +QMessageBox = _GuiAttributeProxy("QMessageBox", _RawQMessageBox) +from .. import accounts, ai, appconfig, chrome, db, diagnostics, editor, excel, image_paths, prompts +from .. import config as account_config +from ..workers import BaseWorker, run_worker as _raw_run_worker + + +def run_worker(*args, **kwargs): + return _package_attr("run_worker", _raw_run_worker)(*args, **kwargs) + + +PLAINTEXT_SECRET_TITLE = "本地明文保存提示" +PLAINTEXT_API_KEY_WARNING = ( + "API Key 会以本地明文保存到 config/ai_models.json,仅供本机调用 AI 使用。" + "该文件已 gitignore,UI 打码显示,日志/导出不记录明文。" +) +PLAINTEXT_PASSWORD_WARNING = ( + "密码会以本地明文保存到本地 SQLite,仅供人工参考,不会自动登录/自动填。" + "数据库文件已 gitignore,请勿提交或分享。" +) + + +def _qcolor(color): + return QColor(color) + + +def _status_base_color(status): + if status == "running": + return COLOR_INFO + if status == "failed": + return COLOR_DANGER + if status in {"skipped", "cancelled"}: + return COLOR_MUTED + return None + + +def _danger_metric_text(text, active): + if not active: + return text + return f'{text}' + + +def _danger_outline_button_style(object_name): + return ( + f"QPushButton#{object_name} {{ " + f"color: {COLOR_DANGER}; border: 1px solid {COLOR_DANGER}; " + "font-weight: 600; padding: 3px 10px; border-radius: 4px; " + "}" + ) + + +def _login_status_display(status): + return f"● {status or '未知'}" + + +def _login_status_color(status): + status_text = str(status or "") + if status_text == "已登录": + return _qcolor(COLOR_SUCCESS) + if status_text == "检测中": + return _qcolor(COLOR_INFO) + if "未登录" in status_text or "失败" in status_text: + return _qcolor(COLOR_DANGER) + return _qcolor(COLOR_MUTED) + + +def _warning_dot_icon(size=12): + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(COLOR_WARNING)) + margin = max(1, size // 6) + painter.drawEllipse(margin, margin, size - margin * 2, size - margin * 2) + painter.end() + return QIcon(pixmap) + + +def _build_empty_state_card(object_name, button_text="前往账号管理"): + card = QWidget() + card.setObjectName(object_name) + card.setStyleSheet( + f"QWidget#{object_name} {{ " + "background: #f6f8fa; border: 1px solid #d0d7de; " + "border-radius: 6px; padding: 10px; " + "}" + ) + layout = QHBoxLayout(card) + layout.setContentsMargins(12, 10, 12, 10) + label = QLabel("") + label.setWordWrap(True) + button = QPushButton(button_text) + button.setObjectName(f"{object_name}Button") + button.setVisible(False) + layout.addWidget(label, 1) + layout.addWidget(button) + card.setVisible(False) + return card, label, button + + +def _set_empty_state(card, label, button, message=None, show_button=False): + active = bool(message) + card.setVisible(active) + label.setText(message or "") + button.setVisible(active and show_button) + + +BATCH_PROGRESS_FIELDS = [ + ("total", "总数"), + ("imported", "导入"), + ("collected", "已采集"), + ("generated", "已生成"), + ("applied", "已更新"), + ("failed", "失败"), + ("skipped", "略过"), +] + + +def _summarize_batch_progress(tasks): + summary = {key: 0 for key, _label in BATCH_PROGRESS_FIELDS} + task_rows = list(tasks or []) + summary["total"] = len(task_rows) + for task in task_rows: + status = getattr(task, "status", None) + stage = getattr(task, "stage", None) + if status == "failed": + summary["failed"] += 1 + elif status == "skipped": + summary["skipped"] += 1 + elif stage == "applied": + summary["applied"] += 1 + elif stage == "generated": + summary["generated"] += 1 + elif stage == "collected": + summary["collected"] += 1 + else: + summary["imported"] += 1 + return summary + + +def _format_batch_progress(summary): + parts = [f"{label}{summary.get(key, 0)}" for key, label in BATCH_PROGRESS_FIELDS] + return "批次进度:" + " · ".join(parts) + + +def _build_batch_progress_overview(object_name): + label = QLabel("") + label.setObjectName(object_name) + label.setWordWrap(True) + label.setStyleSheet( + f"QLabel#{object_name} {{ " + "background: #f6f8fa; border: 1px solid #d0d7de; " + "border-radius: 6px; padding: 8px 10px; color: #24292f; " + "}" + ) + label.setVisible(False) + return label + + +def _set_batch_progress_overview(label, tasks): + summary = _summarize_batch_progress(tasks) + active = summary["total"] > 0 + label.setVisible(active) + label.setText(_format_batch_progress(summary) if active else "") + + +def _database_path(db_path=None, config=None) -> str: + return db_path or appconfig.db_path(config) + + +def _write_reset_run_log(db_path, task, action, message): + run_id = db.create_run_log( + "reset", + total=1, + options={ + "action": action, + "task_id": getattr(task, "id", None), + "alias": getattr(task, "alias", None), + "item_id": getattr(task, "item_id", None), + }, + path=db_path, + ) + db.add_run_log_event( + run_id, + message, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + path=db_path, + ) + db.finish_run_log( + run_id, + status="done", + done=1, + success_count=1, + failed_count=0, + summary_json={ + "action": action, + "task_id": getattr(task, "id", None), + "alias": getattr(task, "alias", None), + "item_id": getattr(task, "item_id", None), + }, + path=db_path, + ) + return run_id + + +def _safe_create_run_log(run_type, db_path=None, dry_run=False, total=0, options=None): + try: + return db.create_run_log( + run_type, + dry_run=dry_run, + total=total, + options=options or {}, + path=db_path, + ) + except Exception: + return None + + +def _safe_finish_run_log(run_id, db_path=None, **fields): + if run_id is None: + return + try: + db.finish_run_log(run_id, path=db_path, **fields) + except Exception: + return + + +def _safe_add_run_log_event( + run_id, + message, + db_path=None, + task=None, + account=None, + level="info", + task_id=None, + alias=None, + item_id=None, +): + safe_message = diagnostics.redact_log_text(message) + if task is not None: + task_id = getattr(task, "id", task_id) + alias = getattr(task, "alias", alias) + item_id = getattr(task, "item_id", item_id) + if account is not None: + alias = getattr(account, "alias", alias) + if run_id is None: + return safe_message + try: + db.add_run_log_event( + run_id, + safe_message, + task_id=task_id, + alias=alias, + item_id=item_id, + level=level, + path=db_path, + ) + except Exception: + pass + return safe_message + + +def _safe_write_diagnostic_log( + message, + level="INFO", + step=None, + task=None, + account=None, + elapsed_ms=None, + payload=None, + exc=None, + log_dir=None, +): + try: + diagnostics.write_diagnostic_log( + message, + level=level, + step=step, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None) or getattr(account, "alias", None), + item_id=getattr(task, "item_id", None), + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=log_dir, + ) + except Exception: + return + + +def _elapsed_ms(started): + return int((time.monotonic() - started) * 1000) + +__all__ = [name for name in globals() if not name.startswith("__")] \ No newline at end of file diff --git a/app/gui/workers.py b/app/gui/workers.py new file mode 100644 index 0000000..ce200db --- /dev/null +++ b/app/gui/workers.py @@ -0,0 +1,1772 @@ +"""Concrete PySide6 workers used by GUI tabs.""" + +from __future__ import annotations + +from .widgets import * +class GenerateWorker(BaseWorker): + """Generate titles and covers for collected tasks.""" + + def __init__( + self, + tasks, + prompt_values, + db_path=None, + config=None, + diagnostic_log_dir=None, + ): + super().__init__() + self.tasks = list(tasks) + self.prompt_values = dict(prompt_values or {}) + self.db_path = db_path + self.config = config + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None + self._account_by_alias = {} + self._task_positions = {} + self._eligible_total = 0 + + def execute(self): + account_rows = accounts.list_accounts(path=self.db_path, config=self.config) + account_by_alias = { + str(account.alias).strip(): account + for account in account_rows + if str(account.alias).strip() + } + self._account_by_alias = account_by_alias + eligible = [ + task for task in self.tasks + if getattr(task, "stage", None) == "collected" + ] + self._eligible_total = len(eligible) + self._task_positions = { + getattr(task, "id", None): index + for index, task in enumerate(eligible, start=1) + } + batch_ids = self._batch_ids(eligible) + self._run_id = self._create_run_log(eligible, batch_ids) + ai_cfg = appconfig.ai_config(self.config) + generate_cover = bool(ai_cfg.get("generate_cover", False)) + if generate_cover: + start_message = "[开始] 本轮生成 {total} 条:标题{total},图片{total};标题并发{title_concurrency},图片并发{image_concurrency}".format( + total=len(eligible), + title_concurrency=ai_cfg.get("title_concurrency", 1), + image_concurrency=ai_cfg.get("image_concurrency", 1), + ) + else: + start_message = "[开始] 本轮生成 {total} 条:本轮仅生成标题,不生成图片;标题并发{title_concurrency}".format( + total=len(eligible), + title_concurrency=ai_cfg.get("title_concurrency", 1), + ) + self._log_run_event(start_message) + try: + summary = ai.generate_batch( + self.tasks, + self.prompt_values, + ai_cfg={ + "config": self.config, + "db_path": self.db_path, + "image_dir": appconfig.image_dir(self.config), + "account_by_alias": account_by_alias, + "on_task_update": self._emit_row_update, + "on_event": self._on_generation_event, + "on_error": self._on_generation_error, + "generate_cover": generate_cover, + }, + on_progress=self.progress.emit, + should_stop=self.should_cancel, + ) + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + summary = { + "ok": False, + "error": error, + "total": len(eligible), + "title_done": 0, + "cover_done": 0, + "cover_total": len(eligible) if generate_cover else 0, + "generated_done": 0, + "failed": len(eligible), + "cancelled": self.should_cancel(), + "generate_cover": generate_cover, + } + self._log_run_event( + f"[失败] AI 生成运行失败:{error}", + level="error", + ) + self._write_diagnostic_log( + "AI生成运行失败", + level="ERROR", + step="execute", + payload={"error": error}, + exc=exc, + ) + summary["run_id"] = self._run_id + summary["batch_ids"] = batch_ids + status = "cancelled" if summary.get("cancelled") else "done" + level = "warning" if summary.get("cancelled") or summary.get("error") else "info" + self._log_run_event(self._format_generate_completion(summary), level=level) + self._finish_run_log(status, summary) + return summary + + def _emit_row_update(self, task_id, fields): + self.row_updated.emit(int(task_id), dict(fields or {})) + + def _on_generation_event(self, payload): + task = payload.get("task") + message = self._format_generation_event(payload) + if not message: + return + self._log_run_event(message, task=task, level=payload.get("level") or "info") + + def _format_generation_event(self, payload): + task = payload.get("task") + phase = payload.get("phase") or "generate" + step = payload.get("step") or "unknown" + result = payload.get("result") or "start" + detail = self._short_detail(payload.get("detail")) + if phase == "title": + if result == "start" and step == "title_submit": + return f"[标题] {self._task_progress_label(task)} 开始生成" + if result == "success" and step == "title_done": + return f"[标题] {self._task_progress_label(task)} 成功" + if result == "success" and step == "db_write": + suffix = f",{detail}" if detail else "" + return f"[标题] {self._task_progress_label(task)} 已保存{suffix}" + if result == "retry": + return self._retry_message("标题", task, payload, detail) + if result == "failed": + return f"[失败] {self._task_plain_label(task)} 标题生成失败:{detail or '未知错误'}" + if result == "cancelled": + return f"[停止] {self._task_plain_label(task)} 标题生成已取消" + return None + if phase == "cover": + if result == "start" and step == "cover_submit": + return f"[图片] {self._task_progress_label(task)} 开始生成" + if result == "success" and step == "db_write": + suffix = f",已保存 {detail}" if detail else "" + return f"[图片] {self._task_progress_label(task)} 成功{suffix}" + if result == "retry": + return self._retry_message("图片", task, payload, detail) + if result == "failed": + return f"[失败] {self._task_plain_label(task)} 图片生成失败:{detail or '未知错误'}" + if result == "cancelled": + return f"[停止] {self._task_plain_label(task)} 图片生成已取消" + return None + return None + + def _retry_message(self, label, task, payload, detail): + attempt = int(payload.get("attempt", 0) or 0) + attempts = int(payload.get("attempts", 0) or 0) + max_retries = max(0, attempts - 1) + retry_text = f"准备重试 {attempt}/{max_retries}" if max_retries else "准备重试" + reason = f":{detail}" if detail else "" + return f"[{label}] {self._task_progress_label(task)} 调用失败,{retry_text}{reason}" + + def _task_progress_label(self, task): + index = self._task_positions.get(getattr(task, "id", None), 0) + total = self._eligible_total or 0 + item_id = getattr(task, "item_id", "") or "未知商品" + shop = self._task_shop_label(task) + shop_text = f"({shop})" if shop else "" + return f"{index}/{total} 商品 {item_id}{shop_text}" + + def _task_plain_label(self, task): + item_id = getattr(task, "item_id", "") or "未知商品" + shop = self._task_shop_label(task) + return f"商品 {item_id}({shop})" if shop else f"商品 {item_id}" + + def _task_shop_label(self, task): + alias = str(getattr(task, "alias", "") or "").strip() + account = self._account_by_alias.get(alias) + if account is not None: + return getattr(account, "account_name", None) or getattr(account, "alias", None) or alias + return getattr(task, "account_name", None) or alias + + def _short_detail(self, detail): + if detail is None: + return "" + text = diagnostics.redact_log_text(str(detail)).replace("\r", " ").replace("\n", " ").strip() + if len(text) > 180: + return text[:177] + "..." + return text + + def _format_generate_completion(self, summary): + progress = self._summary_text(summary) + if summary.get("cancelled"): + return f"[停止] AI 生成已停止:{progress}" + if summary.get("error"): + return f"[失败] AI 生成失败:{summary.get('error')},{progress}" + return f"[完成] AI 生成完成:{progress}" + + def _summary_text(self, summary): + cover_total = summary.get("cover_total", summary.get("total", 0)) + return "标题{title}/{total},图片{cover}/{cover_total},失败{failed}".format( + title=summary.get("title_done", 0), + cover=summary.get("cover_done", 0), + cover_total=cover_total, + total=summary.get("total", 0), + failed=summary.get("failed", 0), + ) + + def _on_generation_error(self, payload): + task = payload.get("task") + phase = payload.get("phase") or "generate" + step = payload.get("step") or "unknown" + error = diagnostics.redact_log_text(payload.get("error") or "未知错误") + self._write_diagnostic_log( + "AI生成任务失败", + level="ERROR", + step=step, + task=task, + payload={"phase": phase, "error": error}, + exc=payload.get("exception"), + ) + + def _batch_ids(self, tasks): + batch_ids = [] + for task in tasks: + batch_id = getattr(task, "batch_id", None) + if batch_id and batch_id not in batch_ids: + batch_ids.append(batch_id) + return batch_ids + + def _create_run_log(self, eligible, batch_ids): + try: + ai_cfg = appconfig.ai_config(self.config) + return db.create_run_log( + "generate", + dry_run=False, + total=len(eligible), + options={ + "batch_ids": batch_ids, + "default_text_model": ai_cfg.get("default_text_model"), + "default_image_model": ai_cfg.get("default_image_model"), + "resolution": ai_cfg.get("resolution"), + "title_concurrency": ai_cfg.get("title_concurrency"), + "image_concurrency": ai_cfg.get("image_concurrency"), + "generate_cover": ai_cfg.get("generate_cover", False), + }, + path=self.db_path, + ) + except Exception: + return None + + def _finish_run_log(self, status, summary): + if self._run_id is None: + return + try: + generated_done = summary.get("generated_done") + if generated_done is None: + generated_done = summary.get("cover_done", 0) + if not summary.get("generate_cover", True) and not generated_done: + generated_done = summary.get("title_done", 0) + done = int(generated_done or 0) + int(summary.get("failed", 0) or 0) + db.finish_run_log( + self._run_id, + status=status, + done=done, + success_count=generated_done, + skipped_count=0, + failed_count=summary.get("failed", 0), + summary_json=summary, + path=self.db_path, + ) + except Exception: + return + + def _log_run_event(self, message, task=None, level="info"): + safe_message = diagnostics.redact_log_text(message) + self.log.emit(str(safe_message)) + if self._run_id is None: + return + try: + db.add_run_log_event( + self._run_id, + safe_message, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + level=level, + path=self.db_path, + ) + except Exception: + return + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + task=None, + payload=None, + exc=None, + ): + try: + diagnostics.write_diagnostic_log( + message, + level=level, + step=step, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + except Exception: + return + +class ApplyWorker(BaseWorker): + """Apply generated title/cover changes, optionally previewing or grouping by account.""" + + def __init__( + self, + tasks, + db_path=None, + config=None, + preflight=True, + close_success_tab=False, + dry_run=False, + parallel_accounts=False, + max_parallel_accounts=1, + batch_size=None, + diagnostic_log_dir=None, + ): + super().__init__() + self.tasks = list(tasks) + self.db_path = db_path + self.config = config + self.preflight = preflight + self.close_success_tab = close_success_tab + self.dry_run = bool(dry_run) + self.parallel_accounts = bool(parallel_accounts) + self.max_parallel_accounts = max(1, int(max_parallel_accounts or 1)) + self.batch_size = None if batch_size is None else max(1, int(batch_size or 1)) + self._current_batch_size = None + self._batch_count = 0 + self._progress_lock = threading.Lock() + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None + + def execute(self): + account_rows = accounts.list_accounts(path=self.db_path, config=self.config) + account_by_alias = { + str(account.alias).strip(): account + for account in account_rows + if str(account.alias).strip() + } + eligible = [task for task in self.tasks if self._is_actionable_task(task)] + batch_ids = self._batch_ids(eligible) + total = len(eligible) + batch_size = self._effective_batch_size(total) + batches = self._task_batches(eligible, batch_size) + self._current_batch_size = batch_size + self._batch_count = len(batches) + counters = { + "done": 0, + "applied": 0, + "skipped": 0, + "failed": 0, + } + self._run_id = self._create_run_log(eligible, batch_ids) + self._log_run_event( + "step=start result=start detail=运行开始:{mode},任务{total},每批最多{batch_size},批次{batch_count},{parallel}".format( + mode="检查本轮更新" if self.dry_run else "真实更新", + total=total, + batch_size=batch_size, + batch_count=len(batches), + parallel=( + f"多账号并行最多{self.max_parallel_accounts}" + if self.parallel_accounts + else "串行" + ), + ) + ) + + if self.preflight and not self.dry_run: + self._log_run_event("step=preflight result=start detail=账号就绪检查") + blocked = self._preflight_block(eligible, account_rows, account_by_alias) + if blocked: + self._log_preflight_blocked(blocked) + summary = self._summary( + ok=False, + total=total, + counters=counters, + batch_ids=batch_ids, + blocked=True, + extra=blocked, + ) + self._finish_run_log("blocked", summary) + return summary + self._log_run_event("step=preflight result=success detail=账号检查通过") + elif not self.preflight: + self._log_run_event( + "step=preflight result=skipped detail=测试模式跳过更新前检查", + level="warning", + ) + + for batch_index, batch_tasks in enumerate(batches, start=1): + if self.should_cancel(): + break + self._log_batch_start(batch_index, len(batches), batch_tasks, counters, total) + if self.dry_run: + for task in batch_tasks: + if self.should_cancel(): + break + outcome = self._preview_task(task, account_by_alias) + self._record_outcome(counters, total, outcome) + elif self.parallel_accounts and self.max_parallel_accounts > 1: + self._run_parallel_by_account(batch_tasks, account_by_alias, counters, total) + else: + for task in batch_tasks: + if self.should_cancel(): + break + outcome = self._apply_one_task(task, account_by_alias) + self._record_outcome(counters, total, outcome) + + summary = self._summary( + ok=counters["failed"] == 0, + total=total, + counters=counters, + batch_ids=batch_ids, + ) + self._finish_run_log("cancelled" if self.should_cancel() else "done", summary) + return summary + + def _is_actionable_task(self, task): + return ( + getattr(task, "stage", None) == "generated" + and getattr(task, "status", None) in {"success", "pending", "failed"} + and bool(getattr(task, "new_title", None) or getattr(task, "new_cover_path", None)) + ) + + def _preflight_block(self, eligible, account_rows, account_by_alias): + if not account_rows: + return { + "reason": "NO_ACCOUNTS", + "no_accounts": True, + } + duplicate_ports = self._duplicate_debug_ports(account_rows, eligible, account_by_alias) + if duplicate_ports: + return { + "reason": "DUPLICATE_DEBUG_PORT", + "duplicate_ports": duplicate_ports, + } + required_accounts = [] + seen_aliases = set() + for task in eligible: + alias = str(task.alias).strip() + account = account_by_alias.get(alias) + if account is not None and alias not in seen_aliases: + required_accounts.append(account) + seen_aliases.add(alias) + not_running = [] + logged_out = [] + for account in required_accounts: + self._log_run_event( + f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", + level="info", + ) + if not chrome.is_running(account.debug_port): + self._log_run_event( + f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}", + level="warning", + ) + not_running.append(self._account_payload(account, "CDP 端口未响应")) + continue + self._log_run_event( + f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}", + level="info", + ) + self._log_run_event( + f"step=login_check result=start detail=账号 {account.alias}", + level="info", + ) + status = self._login_status(account) + if not status.get("logged_in"): + reason = self._login_skip_reason(status) + self._log_run_event( + f"step=login_check result=blocked detail=账号 {account.alias} {reason}", + level="warning", + ) + logged_out.append( + self._account_payload(account, reason) + ) + else: + self._log_run_event( + f"step=login_check result=success detail=账号 {account.alias}", + level="info", + ) + if not_running or logged_out: + return { + "reason": "ACCOUNT_NOT_READY", + "not_running": not_running, + "logged_out": logged_out, + } + return None + + def _duplicate_debug_ports(self, account_rows, eligible, account_by_alias): + required_aliases = { + str(task.alias).strip() + for task in eligible + if account_by_alias.get(str(task.alias).strip()) is not None + } + by_port = {} + for account in account_rows: + if account.alias not in required_aliases: + continue + by_port.setdefault(int(account.debug_port), []).append(account) + duplicates = [] + for port, rows in by_port.items(): + if len(rows) > 1: + duplicates.append( + { + "debug_port": port, + "aliases": [row.alias for row in rows], + } + ) + return duplicates + + def _effective_batch_size(self, total): + if self.batch_size is None: + return max(1, int(total or 1)) + return self.batch_size + + def _task_batches(self, tasks, batch_size): + if not tasks: + return [] + return [ + tasks[index:index + batch_size] + for index in range(0, len(tasks), batch_size) + ] + + def _log_batch_start(self, batch_index, batch_count, batch_tasks, counters, total): + first = counters["done"] + 1 + last = min(first + len(batch_tasks) - 1, total) + label = "检查批次" if self.dry_run else "更新批次" + self._log_run_event( + f"step=batch result=start detail={label} {batch_index}/{batch_count} 开始:任务 {first}-{last}/{total}" + ) + + def _run_parallel_by_account(self, eligible, account_by_alias, counters, total): + groups = self._group_tasks_by_alias(eligible) + max_workers = min(self.max_parallel_accounts, len(groups)) + if max_workers <= 1: + for group_tasks in groups: + self._run_task_group(group_tasks, account_by_alias, counters, total) + return + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = [ + executor.submit( + self._run_task_group, + group_tasks, + account_by_alias, + counters, + total, + ) + for group_tasks in groups + ] + for future in as_completed(futures): + future.result() + + def _group_tasks_by_alias(self, tasks): + groups = [] + index_by_alias = {} + for task in tasks: + alias = str(task.alias).strip() + if alias not in index_by_alias: + index_by_alias[alias] = len(groups) + groups.append([]) + groups[index_by_alias[alias]].append(task) + return groups + + def _run_task_group(self, tasks, account_by_alias, counters, total): + for task in tasks: + if self.should_cancel(): + break + outcome = self._apply_one_task(task, account_by_alias) + self._record_outcome(counters, total, outcome) + + def _preview_task(self, task, account_by_alias): + account = account_by_alias.get(str(task.alias).strip()) + if account is None: + reason = "别名未匹配账号" + self._log_run_event( + f"step=preview result=skipped detail=检查:任务 {task.id} 商品 {task.item_id} 将略过:{reason}", + task=task, + level="warning", + ) + return "skipped" + action_parts = [] + if getattr(task, "new_title", None): + action_parts.append("标题") + if getattr(task, "new_cover_path", None): + action_parts.append("封面") + action_text = "+".join(action_parts) or "无变更" + self._log_run_event( + "step=preview result=success detail=检查:任务 {task_id} 商品 {item_id} 账号 {alias} 将更新 {action}".format( + task_id=task.id, + item_id=task.item_id, + alias=account.alias, + action=action_text, + ), + task=task, + ) + return "applied" + + def _apply_one_task(self, task, account_by_alias): + account = account_by_alias.get(str(task.alias).strip()) + if account is None: + reason = "别名未匹配账号" + db.mark_skipped(task.id, reason, path=self.db_path) + self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) + self._log_run_event( + f"step=preflight result=skipped detail=任务 {task.id} 商品 {task.item_id} 已略过:{reason}", + task=task, + level="warning", + ) + return "skipped" + + started = time.monotonic() + current_step = "db_write" + + def on_step(event): + nonlocal current_step + if isinstance(event, dict): + step = str(event.get("step") or "apply_task") + result = str(event.get("result") or "start") + detail = event.get("detail") + else: + step = str(event) + result = "start" + detail = None + current_step = step + level = "error" if result == "failed" else "info" + detail_text = "任务 {task_id} 商品 {item_id}".format( + task_id=task.id, + item_id=task.item_id, + ) + if detail: + detail_text = f"{detail_text} {detail}" + self._log_run_event( + f"step={step} result={result} detail={detail_text}", + task=task, + level=level, + ) + + try: + self._log_run_event( + f"step=apply_task result=start detail=任务 {task.id} 商品 {task.item_id} 开始更新,账号 {account.alias}", + task=task, + ) + current_step = "db_write" + self._log_run_event( + f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 标记更新运行", + task=task, + ) + db.mark_running(task.id, "apply", path=self.db_path) + self.row_updated.emit(task.id, {"status": "running", "last_error": None}) + result = editor.apply_task( + account, + task, + close_success_tab=self.close_success_tab, + on_step=on_step, + ) + committed = bool(result.get("committed")) and not result.get("error") + error = result.get("error") + current_step = "db_write" + self._log_run_event( + f"step=db_write result=start detail=任务 {task.id} 商品 {task.item_id} 保存更新结果", + task=task, + ) + if committed: + db.set_applied(task.id, True, path=self.db_path) + elapsed_ms = self._elapsed_ms(started) + self.row_updated.emit( + task.id, + { + "stage": "applied", + "status": "success", + "committed": 1, + "last_error": None, + }, + ) + self._log_run_event( + f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 更新成功 elapsed_ms={elapsed_ms}", + task=task, + ) + return "applied" + + error = diagnostics.redact_log_text(error or "更新未提交") + failed_step = self._failed_apply_step(result, current_step) + db.set_applied(task.id, False, error, path=self.db_path) + elapsed_ms = self._elapsed_ms(started) + self.failed.emit(task.id, str(error)) + self.row_updated.emit( + task.id, + {"status": "failed", "last_error": str(error), "committed": 0}, + ) + self._log_run_event( + f"step={failed_step} result=failed detail={error} elapsed_ms={elapsed_ms}", + task=task, + level="error", + ) + self._log_run_event( + f"step=db_write result=success detail=任务 {task.id} 商品 {task.item_id} 保存失败状态 elapsed_ms={elapsed_ms}", + task=task, + ) + self._write_diagnostic_log( + "Shopee更新任务失败", + level="ERROR", + step=failed_step, + task=task, + elapsed_ms=elapsed_ms, + payload={"error": error, "result": result}, + ) + return "failed" + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + db.set_applied(task.id, False, error, path=self.db_path) + elapsed_ms = self._elapsed_ms(started) + self.failed.emit(task.id, error) + self.row_updated.emit( + task.id, + {"status": "failed", "last_error": error, "committed": 0}, + ) + self._log_run_event( + f"step={current_step} result=failed detail={error} elapsed_ms={elapsed_ms}", + task=task, + level="error", + ) + self._write_diagnostic_log( + "Shopee更新任务异常", + level="ERROR", + step=current_step, + task=task, + elapsed_ms=elapsed_ms, + payload={"error": error}, + exc=exc, + ) + return "failed" + def _record_outcome(self, counters, total, outcome): + with self._progress_lock: + counters["done"] += 1 + if outcome == "applied": + counters["applied"] += 1 + elif outcome == "skipped": + counters["skipped"] += 1 + else: + counters["failed"] += 1 + self._emit_progress( + counters["done"], + total, + counters["applied"], + counters["skipped"], + counters["failed"], + ) + + def _account_payload(self, account, reason=None): + payload = { + "account_name": account.account_name, + "alias": account.alias, + "debug_port": account.debug_port, + } + if reason: + payload["reason"] = reason + return payload + + def _emit_progress(self, done, total, applied, skipped, failed): + self.progress.emit( + { + "done": done, + "total": total, + "applied": applied, + "skipped": skipped, + "failed": failed, + "dry_run": self.dry_run, + "batch_size": self._current_batch_size, + "batch_count": self._batch_count, + } + ) + + def _login_status(self, account): + try: + return accounts.detect_login(account, path=self.db_path, config=self.config) + except Exception as exc: + return { + "logged_in": False, + "reason": f"LOGIN_CHECK_FAILED: {exc}", + } + + def _login_skip_reason(self, status): + reason = status.get("reason") + return f"账号未登录: {reason}" if reason else "账号未登录" + + def _batch_ids(self, tasks): + batch_ids = [] + for task in tasks: + batch_id = getattr(task, "batch_id", None) + if batch_id and batch_id not in batch_ids: + batch_ids.append(batch_id) + return batch_ids + + def _summary(self, ok, total, counters, batch_ids, blocked=False, extra=None): + summary = { + "ok": ok, + "total": total, + "done": counters["done"], + "applied": counters["applied"], + "skipped": counters["skipped"], + "failed": counters["failed"], + "batch_ids": batch_ids, + "dry_run": self.dry_run, + "parallel_accounts": self.parallel_accounts, + "batch_size": self._current_batch_size, + "batch_count": self._batch_count, + "run_id": self._run_id, + } + if blocked: + summary["blocked"] = True + if extra: + summary.update(extra) + return summary + + def _create_run_log(self, eligible, batch_ids): + try: + return db.create_run_log( + "apply", + dry_run=self.dry_run, + total=len(eligible), + options={ + "batch_ids": batch_ids, + "close_success_tab": self.close_success_tab, + "dry_run": self.dry_run, + "parallel_accounts": self.parallel_accounts, + "max_parallel_accounts": self.max_parallel_accounts, + "batch_size": self._current_batch_size, + "batch_count": self._batch_count, + }, + path=self.db_path, + ) + except Exception: + return None + + def _finish_run_log(self, status, summary): + if self._run_id is None: + return + try: + db.finish_run_log( + self._run_id, + status=status, + done=summary.get("done", 0), + success_count=summary.get("applied", 0), + skipped_count=summary.get("skipped", 0), + failed_count=summary.get("failed", 0), + summary_json=summary, + path=self.db_path, + ) + except Exception: + return + + def _log_run_event(self, message, task=None, level="info"): + safe_message = diagnostics.redact_log_text(message) + self.log.emit(str(safe_message)) + if self._run_id is None: + return + try: + db.add_run_log_event( + self._run_id, + safe_message, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + level=level, + path=self.db_path, + ) + except Exception: + return + + + def _log_preflight_blocked(self, blocked): + if blocked.get("no_accounts"): + self._log_run_event( + "step=preflight result=blocked detail=当前没有配置账号", + level="warning", + ) + for item in blocked.get("duplicate_ports") or []: + self._log_run_event( + "step=preflight result=blocked detail=调试端口重复 debug_port={port} aliases={aliases}".format( + port=item.get("debug_port") or "", + aliases=",".join(item.get("aliases") or []), + ), + level="warning", + ) + for item in blocked.get("not_running") or []: + self._log_run_event( + "step=check_chrome result=blocked detail=账号 {alias} Chrome 未启动或调试端口不可访问: {reason}".format( + alias=item.get("alias") or "", + reason=item.get("reason") or "", + ), + level="warning", + ) + for item in blocked.get("logged_out") or []: + self._log_run_event( + "step=login_check result=blocked detail=账号 {alias} 未登录 Shopee: {reason}".format( + alias=item.get("alias") or "", + reason=item.get("reason") or "", + ), + level="warning", + ) + + def _failed_apply_step(self, result, fallback): + if not isinstance(result, dict): + return fallback or "apply_task" + title = result.get("title") + if isinstance(title, dict) and not title.get("ok", True): + return "change_title" + cover = result.get("cover") + if isinstance(cover, dict) and not cover.get("ok", True): + return "replace_cover" + update = result.get("update") + if isinstance(update, dict): + return "click_update" + return fallback or "apply_task" + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + task=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + task=task, + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) + +class CollectWorker(BaseWorker): + """Collect old title and cover for imported tasks.""" + + def __init__( + self, + tasks, + db_path=None, + config=None, + preflight=True, + diagnostic_log_dir=None, + ): + super().__init__() + self.tasks = list(tasks) + self.db_path = db_path + self.config = config + self.preflight = preflight + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None + + def execute(self): + account_rows = accounts.list_accounts(path=self.db_path, config=self.config) + account_by_alias = { + str(account.alias).strip(): account + for account in account_rows + if str(account.alias).strip() + } + eligible = [ + task for task in self.tasks + if getattr(task, "stage", None) == "imported" + ] + batch_ids = self._batch_ids(eligible) + total = len(eligible) + collected = 0 + skipped = 0 + failed = 0 + done = 0 + + self._run_id = self._create_run_log(eligible, batch_ids) + self._log_run_event( + f"step=preflight result=start detail=采集运行开始 total={total}" + ) + + if self.preflight: + blocked = self._preflight_block(eligible, account_rows, account_by_alias) + if blocked: + self._log_preflight_blocked(blocked) + summary = self._summary( + ok=False, + total=total, + done=done, + collected=collected, + skipped=skipped, + failed=failed, + batch_ids=batch_ids, + blocked=True, + extra=blocked, + ) + self._finish_run_log("blocked", summary) + return summary + self._log_run_event("step=preflight result=success detail=账号检查通过") + else: + self._log_run_event( + "step=preflight result=skipped detail=测试模式跳过采集前检查", + level="warning", + ) + + for task in eligible: + if self.should_cancel(): + break + account = account_by_alias.get(str(task.alias).strip()) + if account is None: + skipped += 1 + done += 1 + reason = "别名未匹配账号" + db.mark_skipped(task.id, reason, path=self.db_path) + self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) + self._log_run_event( + "step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( + task_id=task.id, + item_id=task.item_id, + reason=reason, + ), + task=task, + level="warning", + ) + self._emit_progress(done, total, collected, skipped, failed) + continue + + status = self._login_status(account) + if not status.get("logged_in"): + skipped += 1 + done += 1 + reason = self._login_skip_reason(status) + db.mark_skipped(task.id, reason, path=self.db_path) + self.row_updated.emit(task.id, {"status": "skipped", "last_error": reason}) + self._log_run_event( + "step=preflight result=skipped detail=任务 {task_id} 商品 {item_id} {reason}".format( + task_id=task.id, + item_id=task.item_id, + reason=reason, + ), + task=task, + level="warning", + ) + self._emit_progress(done, total, collected, skipped, failed) + continue + + started = time.monotonic() + current_step = "db_write" + + def on_step(step): + nonlocal current_step + current_step = str(step) + self._log_run_event( + "step={step} result=start detail=任务 {task_id} 商品 {item_id}".format( + step=current_step, + task_id=task.id, + item_id=task.item_id, + ), + task=task, + ) + + try: + self._log_run_event( + "step=db_write result=start detail=任务 {task_id} 商品 {item_id} 标记采集运行".format( + task_id=task.id, + item_id=task.item_id, + ), + task=task, + ) + db.mark_running(task.id, "collect", path=self.db_path) + self.row_updated.emit(task.id, {"status": "running"}) + result = editor.collect( + account, + { + "item_id": task.item_id, + "old_cover_path": self._old_cover_path(account, task), + }, + on_step=on_step, + ) + current_step = "db_write" + self._log_run_event( + "step=db_write result=start detail=任务 {task_id} 商品 {item_id} 保存采集结果".format( + task_id=task.id, + item_id=task.item_id, + ), + task=task, + ) + db.set_collected( + task.id, + result.get("old_title", ""), + result.get("old_cover_path", ""), + path=self.db_path, + ) + collected += 1 + elapsed_ms = self._elapsed_ms(started) + self.row_updated.emit( + task.id, + { + "stage": "collected", + "status": "success", + "old_title": result.get("old_title", ""), + "old_cover_path": result.get("old_cover_path", ""), + }, + ) + self._log_run_event( + "step=db_write result=success detail=任务 {task_id} 商品 {item_id} 采集成功 elapsed_ms={elapsed_ms}".format( + task_id=task.id, + item_id=task.item_id, + elapsed_ms=elapsed_ms, + ), + task=task, + ) + except Exception as exc: + failed += 1 + error = str(exc) or exc.__class__.__name__ + safe_error = diagnostics.redact_log_text(error) + elapsed_ms = self._elapsed_ms(started) + db.mark_failed(task.id, "collect", safe_error, path=self.db_path) + self.failed.emit(task.id, safe_error) + self.row_updated.emit(task.id, {"status": "failed", "last_error": safe_error}) + self._log_run_event( + "step={step} result=failed detail={error} elapsed_ms={elapsed_ms}".format( + step=current_step, + error=safe_error, + elapsed_ms=elapsed_ms, + ), + task=task, + level="error", + ) + self._write_diagnostic_log( + "采集任务失败", + level="ERROR", + step=current_step, + task=task, + elapsed_ms=elapsed_ms, + payload={"error": safe_error}, + exc=exc, + ) + finally: + done += 1 + self._emit_progress(done, total, collected, skipped, failed) + + summary = self._summary( + ok=failed == 0, + total=total, + done=done, + collected=collected, + skipped=skipped, + failed=failed, + batch_ids=batch_ids, + ) + self._finish_run_log("cancelled" if self.should_cancel() else "done", summary) + return summary + + def _preflight_block(self, eligible, account_rows, account_by_alias): + if not account_rows: + return { + "reason": "NO_ACCOUNTS", + "no_accounts": True, + } + required_accounts = [] + seen_aliases = set() + for task in eligible: + alias = str(task.alias).strip() + account = account_by_alias.get(alias) + if account is not None and alias not in seen_aliases: + required_accounts.append(account) + seen_aliases.add(alias) + not_running = [] + logged_out = [] + for account in required_accounts: + self._log_run_event( + f"step=check_chrome result=start detail=账号 {account.alias} debug_port={account.debug_port}", + level="info", + ) + if not chrome.is_running(account.debug_port): + self._log_run_event( + f"step=check_chrome result=blocked detail=账号 {account.alias} CDP 端口未响应 debug_port={account.debug_port}", + level="warning", + ) + not_running.append(self._account_payload(account, "CDP 端口未响应")) + continue + self._log_run_event( + f"step=check_chrome result=success detail=账号 {account.alias} debug_port={account.debug_port}", + level="info", + ) + self._log_run_event( + f"step=login_check result=start detail=账号 {account.alias}", + level="info", + ) + status = self._login_status(account) + if not status.get("logged_in"): + reason = self._login_skip_reason(status) + self._log_run_event( + f"step=login_check result=blocked detail=账号 {account.alias} {reason}", + level="warning", + ) + logged_out.append( + self._account_payload(account, reason) + ) + else: + self._log_run_event( + f"step=login_check result=success detail=账号 {account.alias}", + level="info", + ) + if not_running or logged_out: + return { + "reason": "ACCOUNT_NOT_READY", + "not_running": not_running, + "logged_out": logged_out, + } + return None + + def _account_payload(self, account, reason=None): + payload = { + "account_name": account.account_name, + "alias": account.alias, + "debug_port": account.debug_port, + } + if reason: + payload["reason"] = reason + return payload + + def _emit_progress(self, done, total, collected, skipped, failed): + self.progress.emit( + { + "done": done, + "total": total, + "collected": collected, + "skipped": skipped, + "failed": failed, + } + ) + + def _login_status(self, account): + try: + return accounts.detect_login(account, path=self.db_path, config=self.config) + except Exception as exc: + return { + "logged_in": False, + "reason": f"LOGIN_CHECK_FAILED: {exc}", + } + + def _login_skip_reason(self, status): + reason = status.get("reason") + return f"账号未登录: {reason}" if reason else "账号未登录" + + def _old_cover_path(self, account, task): + image_root = appconfig.image_dir(self.config) + return image_paths.task_image_path(image_root, task, account, "old") + + def _batch_ids(self, tasks): + batch_ids = [] + for task in tasks: + batch_id = getattr(task, "batch_id", None) + if batch_id and batch_id not in batch_ids: + batch_ids.append(batch_id) + return batch_ids + + def _summary( + self, + ok, + total, + done, + collected, + skipped, + failed, + batch_ids, + blocked=False, + extra=None, + ): + summary = { + "ok": ok, + "total": total, + "done": done, + "collected": collected, + "skipped": skipped, + "failed": failed, + "batch_ids": batch_ids, + "run_id": self._run_id, + } + if blocked: + summary["blocked"] = True + if extra: + summary.update(extra) + return summary + + def _create_run_log(self, eligible, batch_ids): + try: + return db.create_run_log( + "collect", + dry_run=False, + total=len(eligible), + options={ + "batch_ids": batch_ids, + "preflight": self.preflight, + }, + path=self.db_path, + ) + except Exception: + return None + + def _finish_run_log(self, status, summary): + if self._run_id is None: + return + try: + db.finish_run_log( + self._run_id, + status=status, + done=summary.get("done", 0), + success_count=summary.get("collected", 0), + skipped_count=summary.get("skipped", 0), + failed_count=summary.get("failed", 0), + summary_json=summary, + path=self.db_path, + ) + except Exception: + return + + def _log_run_event(self, message, task=None, level="info"): + safe_message = diagnostics.redact_log_text(message) + self.log.emit(str(safe_message)) + if self._run_id is None: + return + try: + db.add_run_log_event( + self._run_id, + safe_message, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + level=level, + path=self.db_path, + ) + except Exception: + return + + def _log_preflight_blocked(self, blocked): + if blocked.get("no_accounts"): + self._log_run_event( + "step=preflight result=blocked detail=当前没有配置账号", + level="warning", + ) + for item in blocked.get("not_running") or []: + self._log_run_event( + "step=preflight result=blocked detail=账号 {alias} Chrome 未启动或调试端口不可访问: {reason}".format( + alias=item.get("alias") or "", + reason=item.get("reason") or "", + ), + level="warning", + ) + for item in blocked.get("logged_out") or []: + self._log_run_event( + "step=preflight result=blocked detail=账号 {alias} 未登录 Shopee: {reason}".format( + alias=item.get("alias") or "", + reason=item.get("reason") or "", + ), + level="warning", + ) + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + task=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + try: + diagnostics.write_diagnostic_log( + message, + level=level, + step=step, + task_id=getattr(task, "id", None), + alias=getattr(task, "alias", None), + item_id=getattr(task, "item_id", None), + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + except Exception: + return + + def _elapsed_ms(self, started): + return int((time.monotonic() - started) * 1000) + +class WriteBackWorker(BaseWorker): + """Write Excel fields back in a background thread.""" + + def __init__(self, batch_id, db_path=None, excel_path=None, mode="old", diagnostic_log_dir=None): + super().__init__() + self.batch_id = batch_id + self.db_path = db_path + self.excel_path = excel_path + self.mode = mode + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None + + def execute(self): + batch_ids = self._batch_ids() + self._run_id = _safe_create_run_log( + "write_back", + db_path=self.db_path, + total=len(batch_ids), + options={ + "batch_ids": batch_ids, + "mode": self.mode, + "excel_path": self.excel_path, + }, + ) + self._log_run_event( + f"step=start result=start detail=Excel 回写开始 mode={self.mode} batch_count={len(batch_ids)}" + ) + results = [] + try: + for batch_id in batch_ids: + started = time.monotonic() + self._log_run_event( + f"step=write_excel result=start detail=batch_id={batch_id} mode={self.mode}" + ) + result = self._write_one(batch_id) + results.append(result) + self._log_run_event( + "step=write_excel result=success detail=batch_id={batch_id} files={files} rows={rows} elapsed_ms={elapsed_ms}".format( + batch_id=batch_id, + files=result.get("files", 0), + rows=result.get("rows", 0), + elapsed_ms=self._elapsed_ms(started), + ) + ) + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + self._log_run_event( + f"step=write_excel result=failed detail={error}", + level="error", + ) + self._write_diagnostic_log( + "Excel回写失败", + level="ERROR", + step="write_excel", + payload={"batch_ids": batch_ids, "mode": self.mode, "error": error}, + exc=exc, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="failed", + done=len(results), + success_count=sum(result.get("rows", 0) for result in results), + failed_count=1, + summary_json={"ok": False, "error": error, "mode": self.mode}, + ) + raise + result = results[0] if len(results) == 1 else self._combined_result(results) + self.progress.emit( + { + "done": result.get("rows", 0), + "total": result.get("rows", 0), + "files": result.get("files", 0), + } + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="done", + done=len(batch_ids), + success_count=result.get("rows", 0), + failed_count=0, + summary_json={"ok": result.get("ok", False), "mode": self.mode, "result": result}, + ) + return result + + def _batch_ids(self): + if isinstance(self.batch_id, (list, tuple, set)): + return list(self.batch_id) + return [self.batch_id] + + def _write_one(self, batch_id): + if self.mode == "results": + return excel.write_back_results( + batch_id, + excel_path=self.excel_path, + path=self.db_path, + ) + return excel.write_back( + batch_id, + excel_path=self.excel_path, + path=self.db_path, + ) + + def _combined_result(self, results): + written_files = [] + for result in results: + for file_path in result.get("written_files", []): + if file_path not in written_files: + written_files.append(file_path) + return { + "ok": all(result.get("ok", False) for result in results), + "batch_id": [result.get("batch_id") for result in results], + "files": sum(result.get("files", 0) for result in results), + "rows": sum(result.get("rows", 0) for result in results), + "written_files": written_files, + } + + def _log_run_event(self, message, level="info"): + safe_message = _safe_add_run_log_event( + self._run_id, + message, + db_path=self.db_path, + level=level, + ) + self.log.emit(str(safe_message)) + + def _write_diagnostic_log(self, message, level="INFO", step=None, payload=None, exc=None): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) + +class AccountLoginCheckWorker(BaseWorker): + def __init__(self, account, db_path=None, config=None, timeout=8, diagnostic_log_dir=None): + super().__init__() + self.account = account + self.db_path = db_path + self.config = config + self.timeout = timeout + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None + + def execute(self): + self._run_id = _safe_create_run_log( + "login_check", + db_path=self.db_path, + total=1, + options={ + "alias": self.account.alias, + "debug_port": self.account.debug_port, + "timeout": self.timeout, + }, + ) + started = time.monotonic() + self._log_run_event( + f"step=detect_login result=start detail=账号 {self.account.alias} debug_port={self.account.debug_port}" + ) + try: + status = accounts.detect_login( + self.account, + timeout=self.timeout, + path=self.db_path, + config=self.config, + ) + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + elapsed_ms = self._elapsed_ms(started) + self._log_run_event( + f"step=detect_login result=failed detail={error} elapsed_ms={elapsed_ms}", + level="error", + ) + self._write_diagnostic_log( + "登录检测失败", + level="ERROR", + step="detect_login", + elapsed_ms=elapsed_ms, + payload={"alias": self.account.alias, "error": error}, + exc=exc, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "alias": self.account.alias, "error": error}, + ) + raise + elapsed_ms = self._elapsed_ms(started) + logged_in = bool(status.get("logged_in")) + result_text = "success" if logged_in else "failed" + level = "info" if logged_in else "warning" + self._log_run_event( + "step=detect_login result={result} detail=账号 {alias} logged_in={logged_in} reason={reason} elapsed_ms={elapsed_ms}".format( + result=result_text, + alias=self.account.alias, + logged_in=logged_in, + reason=status.get("reason") or "", + elapsed_ms=elapsed_ms, + ), + level=level, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="done", + done=1, + success_count=1 if logged_in else 0, + failed_count=0 if logged_in else 1, + summary_json={"ok": logged_in, "alias": self.account.alias, "status": status}, + ) + self.row_updated.emit(self.account.id, status) + return {"alias": self.account.alias, "status": status} + + def _log_run_event(self, message, level="info"): + safe_message = _safe_add_run_log_event( + self._run_id, + message, + db_path=self.db_path, + account=self.account, + level=level, + ) + self.log.emit(str(safe_message)) + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + account=self.account, + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) + +class AIModelTestWorker(BaseWorker): + """Test one AI model connection without blocking the GUI thread.""" + + def __init__(self, model_name, ai_models_path=None, db_path=None, diagnostic_log_dir=None): + super().__init__() + self.model_name = model_name + self.ai_models_path = ai_models_path or appconfig.AI_MODELS_PATH + self.db_path = db_path + self.diagnostic_log_dir = diagnostic_log_dir + self._run_id = None + + def execute(self): + self._run_id = self._create_run_log() + started = time.monotonic() + self._log_run_event( + f"step=test_connection result=start detail=AI模型 {self.model_name}" + ) + try: + result = appconfig.test_ai_model(self.model_name, path=self.ai_models_path) + except Exception as exc: + error = diagnostics.redact_log_text(str(exc) or exc.__class__.__name__) + elapsed_ms = self._elapsed_ms(started) + self._log_run_event( + f"step=test_connection result=failed detail={error} elapsed_ms={elapsed_ms}", + level="error", + ) + self._write_diagnostic_log( + "AI模型测试连接异常", + level="ERROR", + step="test_connection", + elapsed_ms=elapsed_ms, + payload={"model_name": self.model_name, "error": error}, + exc=exc, + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="failed", + done=0, + failed_count=1, + summary_json={"ok": False, "name": self.model_name, "error": error}, + ) + raise + elapsed_ms = self._elapsed_ms(started) + payload = dict(appconfig.sanitize_for_log(result or {})) + payload["name"] = self.model_name + ok = bool(payload.get("ok")) + self._log_run_event( + "step=test_connection result={result} detail=AI模型 {name} status={status} error={error} elapsed_ms={elapsed_ms}".format( + result="success" if ok else "failed", + name=self.model_name, + status=payload.get("status") or "", + error=payload.get("error") or "", + elapsed_ms=elapsed_ms, + ), + level="info" if ok else "warning", + ) + _safe_finish_run_log( + self._run_id, + db_path=self.db_path, + status="done", + done=1, + success_count=1 if ok else 0, + failed_count=0 if ok else 1, + summary_json=payload, + ) + return payload + + def _create_run_log(self): + if not self.db_path: + return None + return _safe_create_run_log( + "ai_model_test", + db_path=self.db_path, + total=1, + options={"model_name": self.model_name}, + ) + + def _log_run_event(self, message, level="info"): + safe_message = _safe_add_run_log_event( + self._run_id, + message, + db_path=self.db_path, + level=level, + ) + self.log.emit(str(safe_message)) + + def _write_diagnostic_log( + self, + message, + level="INFO", + step=None, + elapsed_ms=None, + payload=None, + exc=None, + ): + _safe_write_diagnostic_log( + message, + level=level, + step=step, + elapsed_ms=elapsed_ms, + payload=payload, + exc=exc, + log_dir=self.diagnostic_log_dir, + ) + + def _elapsed_ms(self, started): + return _elapsed_ms(started) + diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 21f2a5c..5472537 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -35,7 +35,7 @@ Shopee 卖家中心页面 / 本地图片目录 真实组件: -- GUI 入口:根目录 `main.py` 调用 `app/gui.py`(待建,PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);也支持 `python -m app`。 +- GUI 入口:根目录 `main.py` 调用 `app/gui/` 包(PySide6 + `QMainWindow` + `QTabWidget`,5 Tab);包入口 `app/gui/__init__.py` 提供 `main()` 并兼容 `from app import gui` / `from app.gui import MainWindow`;也支持 `python -m app`。 - 核心模块统一放在正式代码包 `app/`:`appconfig.py`、`db.py`、`excel.py`、`config.py`、`accounts.py`、`chrome.py`、`editor.py`、`workers.py`、`ai.py`、`prompts.py`;CDP 底座迁入 `app/cdp.py`(当前根目录 `cdp.py` 为已验证来源)。 - 已验证脚本(重构进模块):`prototypes/demo.py`、`prototypes/set_title.py`、`prototypes/set_cover.py`、`prototypes/get_title.py`、`prototypes/cookies.py`、`prototypes/inspect_images.py`、`prototypes/grab.py`。 - 外部依赖:本机 Google Chrome;Shopee;AI 服务(文本+图像,服务商/模型由 `config/ai_models.json` 配置);`openpyxl`。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index f41e240..18e36e5 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -111,7 +111,7 @@ | --- | --- | --- | --- | --- | | T-521 | 依赖清单(锁版本 requirements) | T-006 | 依据 `docs/engineering-review.md` P0。新增锁版本的 `requirements.txt`(或 `pyproject.toml`)声明 PySide6/openpyxl/websocket-client/requests 及版本,与 `docs/03-tech-stack.md` 依赖纪律对齐;README/文档补安装说明;不引入新运行时依赖、不改业务代码 | DONE | | T-522 | CI:自动跑语法 + 单元/ GUI 测试 | T-006, T-521 | 依据 `docs/engineering-review.md` P1。加 GitHub Actions,在 push/PR 上按 `requirements.txt` 安装依赖并跑 `python -m compileall app main.py` + `python -m unittest discover -s tests`(含 PySide6 环境下的 GUI 测试,`QT_QPA_PLATFORM=offscreen`);不连真实 Shopee/AI;失败即红灯,把"改完必跑测试"变强制门禁 | DONE | -| T-523 | 拆分 `app/gui.py` 为 `app/gui/` 包 | T-511, T-514, T-515, T-516, T-517 | 依据 `docs/engineering-review.md` P0。把 6317 行 God-file 拆为包:`models.py`(3 个 TableModel)、`tabs/`(①~⑤各一文件)、`workers.py`(Generate/Apply/Collect/WriteBack/AccountLoginCheck/AIModelTest 从 gui 挪出,与 `app/workers.py` 基类归拢)、`widgets.py`(色板常量、空状态卡、批次总览等 helper)、`main_window.py`。纯结构重构、对外行为与公开符号不变(保留 `from app import gui` 及 `MainWindow`/各 Tab/Worker 的导入路径或提供兼容再导出),保留 PySide6 缺失优雅降级;68 个 GUI 测试全绿、不删减断言 | TODO | +| T-523 | 拆分 `app/gui.py` 为 `app/gui/` 包 | T-511, T-514, T-515, T-516, T-517 | 依据 `docs/engineering-review.md` P0。把 6317 行 God-file 拆为包:`models.py`(3 个 TableModel)、`tabs/`(①~⑤各一文件)、`workers.py`(Generate/Apply/Collect/WriteBack/AccountLoginCheck/AIModelTest 从 gui 挪出,与 `app/workers.py` 基类归拢)、`widgets.py`(色板常量、空状态卡、批次总览等 helper)、`main_window.py`。纯结构重构、对外行为与公开符号不变(保留 `from app import gui` 及 `MainWindow`/各 Tab/Worker 的导入路径或提供兼容再导出),保留 PySide6 缺失优雅降级;68 个 GUI 测试全绿、不删减断言 | DONE | | T-524 | PyInstaller 打包为免安装 exe | T-521 | 依据 `docs/engineering-review.md` P1。新增 PyInstaller spec/脚本,产出 Windows 免安装 `.exe`;打包排除并绝不内置 `config.json`/`config/ai_models.json`/`cmshopee.db*`/`chrome_user_data_dir/`/`images/`/`logs/` 等本地数据与密钥;首次运行按现有默认值在本地生成配置;文档补打包与分发步骤 | TODO | | T-525 | 引入 ruff(lint + format)+ 可选 pre-commit | T-006 | 依据 `docs/engineering-review.md` P1。加 `ruff` 配置(lint + format),先以现状为基线不做大规模风格重排,只开启安全规则(未用 import/变量、明显错误);可选 `.pre-commit-config.yaml`;不改业务逻辑;CI(T-522)可串入 ruff 检查。数据模型渐进上 mypy 作为后续可选 | TODO | diff --git a/docs/api.md b/docs/api.md index c482afe..7bf3289 100644 --- a/docs/api.md +++ b/docs/api.md @@ -321,7 +321,7 @@ render_prompt(template_text, task) -> str - `list_cover_templates()` 不会在启动时创建文件;只有保存/新建/另存为才写 `prompts/cover/*.txt`。 - 模板名不可为空,不允许路径分隔符、`..` 或 Windows 非法文件名字符;重命名时目标重名会报错。 -## gui 模块(`app/gui.py`,已建,PySide6) +## gui 模块(`app/gui/` 包,已建,PySide6) ```python # GUI 入口 @@ -345,6 +345,8 @@ TAB_TITLES: list[str] # 固定 Tab 顺序 TAB_STYLE: str # 顶层 Tab 栏防误点样式:最小宽度/padding/间距/当前态 ``` +T-523 后 GUI 已从旧 `app/gui.py` 拆为 `app/gui/` 包:`__init__.py` 负责旧导入路径兼容与 `main()`;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放色板、空状态卡、批次总览和日志 helper;`workers.py` 放具体 GUI worker;`tabs/` 下按 ①~⑤ 拆分各 Tab。对外仍保留 `from app import gui`、`from app.gui import MainWindow/CollectTab/GenerateWorker/...`。 + `MainWindow` 已实现五 Tab、① 导入采集任务列表、② AI生成布局/提示词/开始生成/停止/封面对照预览、③ 更新shopee筛选列表与检查/确认后分批真实更新、④ 账号管理、⑤ AI 模型管理。缺 PySide6 时 `main()` 返回 1 并输出明确提示。 主 Tab 栏必须在 `MainWindow` 初始化时应用 `TAB_STYLE`:5 个 Tab 不使用 Qt 默认紧凑宽度,需保证点击区域稳定、间距清晰、当前 Tab 高亮明显。该样式属于全局导航基础,不归后续业务 Tab 任务重复实现。 diff --git a/docs/current-state.md b/docs/current-state.md index eece8b2..e66f16d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,9 +6,9 @@ ## 当前快照 - 日期:2026-07-02 -- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试。 +- 阶段:V0 单账号 CDP 流程已验证;V1 已完成 T-000 正式代码包结构、T-001 `app/editor.py` 模块化、T-002 `app/appconfig.py` 应用配置、T-003 SQLite 持久化地基、T-004 本地数据忽略规则、T-005 AI 模型清单后端、T-006 单元测试基座、T-101 账号 user-data-dir 工具、T-102 Chrome 启动器、T-103 登录保活与检测、T-104 PySide6 主窗口骨架、T-104b PySide6 worker 基座、T-105 Tab④ 账号管理、T-106 账号快捷方式、T-201 Excel 导入入库、T-202 Tab① 任务列表与导入按钮、T-202b Tab① 导入汇总栏、T-203 采集旧标题旧封面、T-204 回写旧字段到原 Excel、T-204b 采集完成自动回写、T-205 首次未配账号/Chrome 未启动/未登录引导保护、T-205b 采集后关闭自动新建商品页 tab、T-206 Tab① 删除指定批次软删除、T-301 AI 生成接口、T-302 Tab② AI 生成布局与任务列表、T-302p 提示词管理、T-303 Tab② 开始生成/停止/进度、T-303b ②/③ 商品ID筛选、T-401 Tab③ 更新列表筛选与开始更新确认、T-402 Tab③ 确认后串行更新、T-403 Tab③ 结果回写与结束汇总、T-501 Tab⑤ AI 模型管理 UI、T-501b Tab⑤ 角色与生成参数、T-501c Tab⑤ Shopee 更新安全开关、T-502 换封面删第一张再上传、T-503 敏感信息本地明文保存提示与日志脱敏、T-504 多账号并行/dry-run/运行日志、T-207 ①采集诊断日志、② AI生成图片失败诊断日志补丁、T-404a ②/③ 选中记录重置、T-404 真实 Shopee 单条更新冒烟验收、T-506 正式使用批量更新体验、T-507 正式批量更新移除普通流程测试商品 ID 限制、T-508 ③ 更新shopee生产化操作区、T-509 ② 新标题人工微调、T-510 ③ 检查本轮更新文案统一、T-505 全流程诊断日志扩展、T-511 语义色板与任务状态列上色、T-512 ③高风险按钮上色与①导入校验数字标红、T-513 登录点/③Tab危险标识/破坏性按钮上色、T-514 ①②③ 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关、T-521 依赖清单(锁版本 requirements)、T-522 CI 自动跑语法 + 单元 / GUI 测试、T-523 拆分 `app/gui.py` 为 `app/gui/` 包。 - 技术栈:Python 3.10+,根目录 `requirements.txt` 锁定运行依赖,GitHub Actions 使用 Windows + Python 3.11 自动跑语法和单元/GUI 测试;自研 CDP(websocket-client + requests),SQLite(sqlite3)+ `config.json` + openpyxl + AI(`config/ai_models.json` 通用 HTTP,chat JSON / images_edits),GUI PySide6 5 Tab(已定)。 -- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui.py` 已实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新shopee Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、居中内容区、三列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全与多账号并行设置并持久化 `config.json`,保存成功后弹轻量提示框;⑤ 设置页已将「Shopee 更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 +- 生产代码:已建立 `app/` 包 + 根入口 `main.py`;`app/cdp.py` 为已验证 CDP 底座,已区分 `CDP.close()` 断开 WebSocket 与 `close_tab()` 关闭浏览器 target;`app/editor.py` 已封装登录状态检测、标题/封面/采集/更新按钮能力,并在采集结束后只关闭本轮自动新建的商品编辑页 tab、保留用户已有 tab,更新提交成功且设置开启时可关闭本轮自动新建商品页,确认后跳回商品列表页时关闭前等待 2 秒;`click_update()` 已补 Shopee 站点侧确认框处理,页面主「更新」后若出现 `確定您要更新商品嗎?` modal(`.eds-modal__content` / `.eds-modal__box`),只点击弹窗主按钮「更新」并避开「立即優化」,且记录确认后是否跳回商品列表页;`replace_cover()` 已实现更新封面统一先校验本地旧封面备份,再点第一张删除、可见确认框、等待图片管理器稳定和上传 input 恢复,随后先点击上传块模拟人工入口、短暂等待、重新获取 input、注入文件上传新图并等待 Shopee CDN 后拖到第一位的代码路径;已修正 2026-06-30 稳定等待回归:删除第一张前不要求上传 input 可用,只等图片列表稳定;删除后再等上传入口恢复;`重複/重复/duplicate` 上传 toast 会立即判为新封面重复错误;`app/image_paths.py` 已统一新采集/新生成图片路径为 `images///__old/new.jpg`,历史 DB 路径继续按原路径读取;`app/appconfig.py` 已实现 `config.json` 默认值/读写/更新、AI 参数、端口读取、Shopee 更新安全与执行模式默认值,`config/ai_models.json` 模型清单 CRUD/过滤/打码/测试连接与 OpenAI-compatible base URL 自动补 endpoint,以及 `mask_secret()`、`sanitize_for_log()`、`redact_secrets()` 敏感信息脱敏工具;`app/ai.py` 已实现 `gen_title()`/`gen_cover()`/`generate_batch()`,按默认文本/图片模型发起通用 HTTP 调用,支持重试、错误脱敏、图片 URL/base64 解析、resolution resize、jpg_quality 保存、按 `ai.generate_cover` 选择只生成标题或先并发标题再并发封面、逐条 `set_generated`、失败 `mark_failed`、步骤级事件/错误回调与停止取消未开始项;`app/prompts.py` 已实现标题提示词读写、封面模板 CRUD 与变量替换;`app/db.py` 已实现 SQLite schema、连接 PRAGMA、批次/账号/任务与阶段写库函数、T-206 批次软删除标记与默认业务查询过滤、T-404a 本地生成结果/更新状态重置函数、T-509 `update_generated_title()` 本地新标题微调函数,以及 `run_logs/run_log_events` 运行日志函数;`app/diagnostics.py` 已实现 gitignore 本地诊断日志、滚动写入、结构化 payload 和自由文本脱敏;T-505 已把 Excel 导入/回写、③更新shopee、④Chrome 启动/登录检测、⑤AI模型测试连接接入 `run_logs/run_log_events` 与本地脱敏诊断日志;`app/excel.py` 已实现多 Excel 输入列解析、整文件列校验、脏行统计跳过、导入批次与任务入库、别名匹配统计、旧标题/旧封面路径回写原 Excel、更新结果回写原 Excel 与另存副本;`app/config.py` 已实现账号 slug 与 user-data-dir 创建;`app/accounts.py` 已实现账号 CRUD 服务、端口默认分配、启动登录、检测登录、生成快捷方式;`app/chrome.py` 已实现 Chrome 参数拼装、启动、CDP 端口探测、PowerShell `.lnk` 快捷方式生成;`app/gui/` 已由 T-523 拆分为 PySide6 GUI 包,包入口 `__init__.py` 兼容旧导入,`main_window.py` 放 `MainWindow`,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI worker,`tabs/` 放 ①~⑤ Tab;整体仍实现 PySide6 `MainWindow`、五 Tab、顶部 Tab 栏防误点样式、统一语义色板、①②③任务状态列前景色、③「开始更新」warning 描边/文字色和①导入校验数字标红、④登录状态点上色、③更新shopee Tab warning 小圆点和删除类按钮 danger 样式、①②③首次空状态引导卡片、①②③批次阶段进度总览、① 导入采集的 Excel 导入按钮/导入汇总栏/批次筛选与删除批次软删除入口/QTableView 任务列表/未匹配筛选与略过标记/采集旧标题旧封面 worker/采集前账号就绪预检与④引导/采集完成自动回写/旧数据回写重试按钮与 worker/采集运行日志视图、② AI生成左右布局/标题与封面提示词管理/批次/店铺/商品ID/状态筛选/任务列表/新标题列本地微调/变量预览/生成封面图片成本开关/开始生成/停止/标题与图片双进度条/双击新旧封面预览/用户可读自动滚动AI生成运行日志/重置生成结果与 `GenerateWorker`、③ 更新shopee批次/店铺/商品ID/状态筛选栏/任务列表/开始更新主按钮/重置更新状态右键菜单/更新安全开关拦截与「前往设置」跳转/「检查本轮更新」按钮/开始更新确认弹窗/确认后 `ApplyWorker` 按每批最大更新条数分批执行当前筛选全部可更新记录/账号就绪和端口冲突预检/按账号并行可选/逐条 `set_applied`/运行日志/自动回写结果到 Excel/结束汇总弹窗/手动回写重试按钮、④ 账号管理表格/弹窗/按钮/快捷方式与状态栏、密码明文保存提示、⑤ 设置 AI 模型同列上下布局、居中内容区、三列详情/参数表单、API Key 明文保存提示、`AIModelTestWorker` 后台测试连接、角色/生成参数/路径/端口配置、Shopee 更新安全与多账号并行设置并持久化 `config.json`,保存成功后弹轻量提示框;⑤ 设置页已将「Shopee 更新安全 / 执行模式」前置、将「基础设施(路径与端口)」后置,`test_item_id` 与 `dry_run` 不再有用户可操作控件,保存时保留 `test_item_id` 兼容值并固定 `dry_run=false`;③ 普通正式更新不再用 `test_item_id` 阻断非测试商品,确认弹窗不再显示测试商品 ID;`app/workers.py` 已实现 `BaseWorker`、通用 signals、取消标记和 `QThread` 启动包装。 - 测试:`tests/` 已建立;T-522 已新增 GitHub Actions 在 push / pull_request 自动运行语法检查与全量 unittest;T-006 后纯逻辑改动必须运行 `python -m unittest discover -s tests`,当前覆盖 appconfig/db/config/accounts/chrome 启动与快捷方式/editor 登录检测与商品 tab 生命周期、更新成功后关闭本轮新开 tab、更新封面备份缺失阻断/8张与9张先删第一张/删除确认/上传入口初始不可用/重复图片 toast/物流错误不阻断上传/删图后稳定等待/上传状态诊断/拖首位 mock 路径/Shopee 更新确认框、成功跳回商品列表和残留错误 toast 不覆盖成功 mock 路径/excel 导入/旧字段与更新结果回写/ai 标题与封面 HTTP 解析/`generate_batch` 正常、失败与停止/prompts 读写与渲染/gui ① 导入采集、删除批次软删除与采集诊断日志/gui ② AI生成布局与批次/店铺/商品ID/状态筛选、提示词管理、生成 worker、图片失败诊断日志、双击预览、重置生成结果与新标题本地编辑/gui ③ 更新shopee批次/店铺/商品ID/状态筛选列表、确认弹窗、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与前往设置、`ApplyWorker` 串行/检查/分批/按账号并行/端口冲突预检、运行日志、结果回写与汇总/gui ④ 账号管理、密码打码与明文保存提示/gui ⑤ AI 模型管理、API Key 打码与明文保存提示、角色/生成参数设置、Shopee 更新安全设置、检查按钮/每批最大更新条数/多账号并行设置、测试商品 ID 限制移除/worker signal 与线程包装、T-505 全流程诊断日志(import/write_back/apply/chrome_launch/login_check/ai_model_test)与本地日志脱敏、gui T-511 语义色板和任务状态列前景色、T-512 高风险按钮与导入校验数字样式、T-513 登录状态/Tab 标识/删除按钮样式、T-514 首次空状态引导卡片、T-515 批次阶段进度总览、T-516 ①筛选对齐②③、T-517 ⑤设置分区与兼容字段清理、T-518 ②左栏提示词区组件密度优化、T-519 ②AI生成长任务进度条与用户可读滚动日志、T-520 ②AI生成封面可选生成开关;2026-07-01 已完成 5 个真实商品的 T-404 更新验收,后续 CDP/Shopee 改动仍需测试商品手动验证。 - 数据:`config.json`、`config/ai_models.json`、`cmshopee.db`、`cmshopee.db-*`、`chrome_user_data_dir/`、`images/`、`logs/` 已由 `.gitignore` 排除;密码与 API Key 本地明文保存但保存/变更时提示,UI 打码,日志/导出必须脱敏;运营填写后的 Excel 业务文件默认忽略,标准空模板 `shopee待处理任务模板.xlsx` 可提交;`app/appconfig.py` 首次读取缺失的 `config.json` 时会在本地写默认配置,`app/db.py` 调用 `init_db()` 时会在本地创建 SQLite DB。 @@ -35,7 +35,7 @@ | `app/__init__.py` / `app/__main__.py` / `main.py` | 已有 | 正式包与启动入口;`python main.py` / `python -m app` 可运行占位入口 | | `requirements.txt` | 已有 | T-521 产出:锁定运行时直接依赖 PySide6 6.5.3、openpyxl 3.1.3、requests 2.31.0、websocket-client 1.6.1、Pillow 9.5.0;换机/CI 使用 `python -m pip install -r requirements.txt` | | `.github/workflows/tests.yml` | 已有 | T-522 产出:push / pull_request 自动在 `windows-latest` + Python 3.11 安装 `requirements.txt`,设置 `QT_QPA_PLATFORM=offscreen`,运行 `python -m compileall app main.py` 与 `python -m unittest discover -s tests` | -| `app/gui.py` | 已有 | T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;统一语义色板、①②③状态列前景色、③开始更新 warning 样式、①导入校验数字标红、④登录状态点、③Tab warning 小圆点、删除按钮 danger 样式、①②③首次空状态引导卡片和批次阶段进度总览;① 导入采集导入按钮、导入汇总栏、批次/店铺/商品ID/状态筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/标题与图片双进度条、双击新旧封面预览、用户可读自动滚动AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、三列 Shopee 更新安全与执行模式设置,高频安全/执行模式分区前置、基础设施分区后置,`test_item_id`/`dry_run` 兼容字段无用户入口且普通更新不再阻断正式更新;②封面模板「另存为/重命名/删除」低频操作已收敛进「模板操作」菜单;②AI生成底部已增加标题/图片双进度条,运行日志已改为用户可读、自动滚动、脱敏的长任务日志,且②本轮「生成封面图片(成本较高)」开关默认关闭并持久化到 `ai.generate_cover`,关闭时只生成标题并进入可更新状态 | +| `app/gui/` | 已有 | T-523 产出:由旧 `app/gui.py` 拆分的 PySide6 GUI 包,包入口 `__init__.py` 继续兼容 `from app import gui` / `from app.gui import MainWindow`;`main_window.py` 放 MainWindow,`models.py` 放 3 个 TableModel,`widgets.py` 放色板/空状态/批次总览/helper,`workers.py` 放具体 GUI workers,`tabs/` 放 ①~⑤ Tab; T-104/T-105/T-106/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-404a/T-501/T-501b/T-501c/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520 产出:PySide6 `QMainWindow` + 五 Tab;顶部 Tab 栏防误点样式;统一语义色板、①②③状态列前景色、③开始更新 warning 样式、①导入校验数字标红、④登录状态点、③Tab warning 小圆点、删除按钮 danger 样式、①②③首次空状态引导卡片和批次阶段进度总览;① 导入采集导入按钮、导入汇总栏、批次/店铺/商品ID/状态筛选、删除批次软删除按钮、`QTableView` 任务列表、未匹配筛选与略过标记、采集旧标题旧封面 worker(旧封面路径按批次/店铺/任务细分)、采集前账号就绪预检与④引导、采集完成自动回写与手动重试、采集运行日志视图;② AI生成左右布局、提示词管理、批次/店铺/商品ID/状态筛选栏、任务列表、新标题列本地编辑、开始生成/停止/标题与图片双进度条、双击新旧封面预览、用户可读自动滚动AI生成运行日志、重置生成结果与 `GenerateWorker`;③ 更新shopee批次/店铺/商品ID/状态筛选栏、任务列表、开始更新主按钮、重置更新状态右键菜单、Shopee 更新安全拦截与「前往设置」跳转、「检查本轮更新」按钮、开始更新确认弹窗、`ApplyWorker` 检查/分批串行/按账号并行、账号与端口预检、运行日志、逐条 `set_applied`、自动回写结果到 Excel、结束汇总弹窗与手动回写重试;④ 账号管理表格、账号弹窗、密码本地明文保存提示、启动登录、检测登录、快捷方式;⑤ 设置 AI 模型下拉、新增/删除、详情编辑、密钥打码与本地明文保存提示、测试连接 worker、默认角色下拉、生成参数、路径端口配置、三列 Shopee 更新安全与执行模式设置,高频安全/执行模式分区前置、基础设施分区后置,`test_item_id`/`dry_run` 兼容字段无用户入口且普通更新不再阻断正式更新;②封面模板「另存为/重命名/删除」低频操作已收敛进「模板操作」菜单;②AI生成底部已增加标题/图片双进度条,运行日志已改为用户可读、自动滚动、脱敏的长任务日志,且②本轮「生成封面图片(成本较高)」开关默认关闭并持久化到 `ai.generate_cover`,关闭时只生成标题并进入可更新状态 | | `app/workers.py` | 已有 | T-104b 产出:`BaseWorker` + 通用 signals + 取消标记 + `run_worker()` QThread 包装 | | `app/accounts.py` | 已有 | T-105/T-106 产出:账号 CRUD 服务、目录创建、端口分配、启动登录、检测登录、快捷方式 | | `app/editor.py` | 已有 | T-001/T-103/T-205b/T-207/T-501c/T-502 + T-404 补丁产出:登录状态检测、打开商品页、读/写标题、读/下载封面、采集步骤回调、更新步骤回调、采集后关闭自动新建商品页 tab、上传前等待图片管理器稳定、点击上传块后注入文件并检测 CDN 后拖封面、更新封面统一先校验旧封面备份再删线上第一张、重复图片 toast 明确失败、页面主更新按钮、Shopee 站点侧确认框主按钮、apply_task;更新成功后可按设置关闭本轮自动新建商品页 | @@ -47,7 +47,7 @@ | `app/config.py` | 已有 | T-101 产出:别名→稳定 slug;创建并返回绝对 user-data-dir | | `app/image_paths.py` | 已有 | 本地图片路径 helper:新采集旧封面和新生成封面统一写入 `images///__old/new.jpg`;历史 DB 已存路径继续按原路径读取 | | `app/chrome.py` | 已有 | T-102/T-106 产出:Chrome 启动参数、`subprocess.Popen` 启动、`/json/version` 端口探测、PowerShell `.lnk` 快捷方式 | -| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/image_paths/prompts/gui/workers | +| `tests/` | 已有 | T-006/T-201/T-202/T-202b/T-203/T-204/T-204b/T-205/T-206/T-207/T-301/T-302/T-302p/T-303/T-303b/T-401/T-402/T-403/T-501/T-501b/T-501c/T-502/T-503/T-504/T-506/T-507/T-508/T-509/T-510/T-505/T-511/T-512/T-513/T-514/T-515/T-516/T-517/T-518/T-519/T-520/T-523 产出:stdlib unittest 基座;覆盖 appconfig/db/config/accounts/chrome/editor/excel/ai/image_paths/prompts/gui/workers | | `app/excel.py` | 已有 | T-201/T-204/T-403 产出:多文件 Excel 输入列解析、必需列整文件拒绝、脏行逐行跳过、批次/任务入库、匹配统计;按源文件/工作表/行号回写旧标题与旧封面路径;按源文件/工作表/行号回写新标题、新封面路径、更新状态;支持原文件被占用时另存副本 | | `shopee待处理任务模板.xlsx` | 已有,已提交 | 标准空 Excel 模板;单工作表 `待处理任务`,表头 `账号名 | 别名 | 商品id | 旧标题 | 旧封面图片路径 | 新标题 | 新封面图片路径 | 更新状态`;运营复制后填写,填写副本不提交 | | `config.json` / `config/ai_models.json` / `cmshopee.db` / `cmshopee.db-*` / `chrome_user_data_dir/` / `images/` / `logs/` | 本地存在或按需生成,已忽略 | 含配置、密钥、业务、登录态、图片、本地诊断日志,不提交版本库;密码/API Key 保存或变更时提示,展示/日志/导出脱敏 | @@ -65,8 +65,8 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)。 -- 下一个可领取任务:T-523(拆分 `app/gui.py` 为 `app/gui/` 包)。 +- 已完成:T-000(正式代码包结构)、T-001(`app/editor.py` 模块化)、T-002(`app/appconfig.py` + `config.json`)、T-003(SQLite 建表)、T-004(本地数据 gitignore)、T-005(AI 模型清单后端)、T-006(单元测试基座)、T-101(账号 slug/user-data-dir)、T-102(Chrome 启动器)、T-103(登录保活与检测)、T-104(PySide6 五 Tab 主窗口骨架)、T-104b(PySide6 worker 基座)、T-105(Tab④ 账号管理)、T-106(账号快捷方式)、T-201(Excel 导入:解析多文件输入列入库)、T-202(Tab① 任务列表 + 导入按钮 + 别名匹配标记)、T-202b(Tab① 导入汇总栏)、T-203(采集旧标题+旧封面)、T-204(回写旧字段到原 Excel)、T-204b(采集完成自动回写旧字段)、T-205(首次未配账号 / Chrome 未启动 / 未登录引导保护)、T-205b(采集后关闭自动新建商品页 tab)、T-206(Tab① 删除指定批次软删除)、T-301(AI 生成接口)、T-302(Tab② 左右布局与任务列表)、T-302p(提示词管理)、T-303(Tab② 开始生成 + 停止 + 进度)、T-303b(②/③ 商品ID筛选)、T-401(Tab③ 更新列表筛选 + 开始更新确认弹窗)、T-402(Tab③ 确认后串行更新)、T-403(Tab③ 结果回写与结束汇总)、T-501(Tab⑤ AI 模型管理 UI)、T-501b(Tab⑤ 角色与生成参数)、T-501c(Tab⑤ Shopee 更新安全开关)、T-502(换封面删第一张再上传)、T-503(敏感信息本地明文保存提示与日志脱敏)、T-504(多账号并行 / dry-run / 运行日志)、T-207(①采集诊断日志)、② AI生成图片失败诊断日志补丁、T-404a(②/③ 选中记录重置)、T-404(真实 Shopee 单条更新冒烟验收)、T-506(正式使用批量更新体验)、T-507(正式批量更新:移除普通流程测试商品 ID 限制)、T-508(③ 更新shopee生产化操作区)、T-509(② 新标题人工微调)、T-510(③ 检查本轮更新文案统一)、T-505(全流程诊断日志扩展)、T-511(语义色板 + ①②③任务状态列上色)、T-512(③高风险按钮上色 + ①导入校验数字标红)、T-513(登录点 / ③Tab危险标识 / 破坏性按钮上色)、T-514(①②③ 首次空状态引导卡片)、T-515(批次阶段进度总览)、T-516(①筛选对齐②③)、T-517(⑤设置分区 + 清理兼容字段)、T-518(②左栏提示词区组件密度优化)、T-519(②AI生成长任务进度条 + 用户可读滚动日志)、T-520(②AI生成封面可选生成开关)、T-521(依赖清单:锁版本 requirements)、T-522(CI:自动跑语法 + 单元 / GUI 测试)、T-523(拆分 `app/gui.py` 为 `app/gui/` 包)。 +- 下一个可领取任务:T-524(PyInstaller 打包为免安装 exe)。 ## 当前已知限制 @@ -77,7 +77,7 @@ - T-506 已完成:③ 已将用户可见 `dry-run` 改为「检查本轮更新」按钮;真实更新对当前筛选结果按每批最大更新条数自动分批,确认弹窗显示任务数和预计批次,停止为当前商品安全结束后不再开始新任务;⑤ 设置页已改为居中内容区,左右留白已缩短为 T-506 初始实现约 40%,模型详情/角色与生成参数/路径与端口/Shopee 更新安全使用三列布局,长字段跨列;「多账号并行更新」与「最大并行账号数」已合并为同一个横向组件,最大并行账号数紧跟其后且不换行。 - T-507 已完成:普通正式更新移除 `test_item_id` 商品 ID 限制;③ 确认弹窗不再显示测试商品 ID;⑤ 普通设置页隐藏测试商品 ID,只作为历史/调试兼容字段保留;仍保留允许真实提交、允许更新封面、每批最大条数、二次确认、账号就绪预检、多账号并行上限、运行日志和 Excel 回写。 - T-508/T-509/T-510 已完成:③「开始更新」作为主操作视觉强化,「重置更新状态」移到任务表右键菜单,更新安全拦截弹窗写明具体设置并可跳到⑤;②「新标题」列允许已生成、未提交线上、非运行中任务本地微调,写回 `tasks.new_title`,清空 `last_error` 并回到可更新状态,不触碰 Shopee/CDP/Excel/封面;③ 用户可见「预览本轮更新」已统一改名为「检查本轮更新」,内部 `dry_run` 字段保留。 -- T-511 已完成:`app/gui.py` 顶部定义统一语义色板常量;①②③任务表状态相关列通过 `Qt.ForegroundRole` 按内部 `stage/status` 返回 `QColor`,完成/失败/略过/待处理/进行中分别使用 success/danger/muted/pending/info,不按中文显示文案硬匹配,不给普通行刷底色。 +- T-511 已完成:统一语义色板常量已随 T-523 迁入 `app/gui/widgets.py`;①②③任务表状态相关列通过 `Qt.ForegroundRole` 按内部 `stage/status` 返回 `QColor`,完成/失败/略过/待处理/进行中分别使用 success/danger/muted/pending/info,不按中文显示文案硬匹配,不给普通行刷底色。 - T-512 已完成:③「开始更新」使用 warning `#bc4c00` 文字和描边强调写线上风险;①导入汇总中无效行数 >0、未匹配数 >0 以 danger 标红,未匹配按钮仍保持现有点击筛出能力,无效行不新增筛表入口。 - T-513 已完成:④账号管理登录状态列显示 `● 状态` 并按状态着色(已登录 success、检测中 info、未登录/检测失败 danger、未知/已启动 muted);③更新shopee Tab 通过 `setTabIcon(2, ...)` 显示克制 warning 小圆点,不改 Tab 文字色;①「删除批次」和④「删除账号」使用 danger 文字/描边,原有启用/禁用和二次确认逻辑不变。 - T-514 已完成:①②③增加轻量空状态引导卡片;无账号时显示「前往账号管理」按钮并跳转④,有账号但无任务时分别提示①导入 Excel、②先完成①采集、③先完成②生成;卡片只做 UI 引导,不改执行前账号/Chrome/登录态预检。 @@ -89,6 +89,7 @@ - T-520 已完成:②AI生成页增加「生成封面图片(成本较高)」开关,默认关闭以避免无意产生图片模型成本;关闭时标题生成成功即可写库进入 generated,`new_cover_path=NULL`,③只更新标题且不受「允许更新封面」阻断;开启时保持标题后图片两段生成流程。未改 DB schema、AI HTTP 协议、Excel、Shopee/CDP 更新流程。 - T-521 已完成:根目录新增 `requirements.txt` 并锁定当前运行依赖版本;`docs/03-tech-stack.md` 和 `docs/README.md` 已改为 `python -m pip install -r requirements.txt` 安装;未新增运行时依赖、未改业务代码。 - T-522 已完成:新增 `.github/workflows/tests.yml`,在 push / pull_request 上用 Windows + Python 3.11 安装 `requirements.txt`,设置 `QT_QPA_PLATFORM=offscreen`,自动运行语法检查和全量单元/GUI 测试;CI 不连接真实 Shopee 或真实 AI。 +- T-523 已完成:旧 `app/gui.py` 已拆为 `app/gui/` 包,公开导入路径保持兼容;本轮为纯结构重构,未改 Shopee/CDP、AI、DB、Excel 行为。 - T-301/T-303 已完成通用 HTTP AI 接口、批量生成编排和 GUI 接入 mock 单测;真实 AI 生成还需要在 `config/ai_models.json` 填入可用 url/model/api_key 后做一次成本可控的小样本实测;URL 可填完整 endpoint 或 OpenAI-compatible base URL。 - 本地 `config/ai_models.json` 若由旧版本或手工维护,可能缺少 `category`;启动报 “AI 模型 category 必须是 text 或 image” 时,按 [`troubleshooting.md`](troubleshooting.md) 只补 `category` / `enabled` 等非密钥字段,保留 API Key,且不要提交该文件。 - T-403/T-501c 已完成③更新结果回写、结束汇总与真实更新安全开关;2026-06-29 已用测试商品跑到真实编辑页并完成标题/封面替换、页面主「更新」点击,但被 Shopee 站点侧确认框拦住。当前代码已补确认框处理、确认后列表页跳转观测、上传状态诊断、删除后稳定等待和 mock 单测;2026-06-30 用户复跑发现稳定等待顺序回归:满 9 张时删除前要求上传 input 可用,导致不删除第一张图;当前已修正为删除前只等图片列表稳定、删除后再等上传入口恢复,并补 mock 回归测试;2026-06-30 又发现物流/备货页面级校验 toast 会被误判成封面上传失败,当前已修复为上传阶段只识别图片/文件/上传相关错误,物流错误留到提交阶段处理。2026-06-30 用户复跑确认浏览器已成功更新标题/图片并跳转商品列表,但 GUI 仍报 `POST_UPDATE_ERROR`;已修正提交后观测优先级:列表页跳转优先判成功,残留 error toast 不再覆盖成功跳转,错误 toast 仅在未跳转且持续存在时判失败。当前又发现商品 26887160467 手动上传 220KB 新图成功,但代码直接注入文件后上传组件长期转圈;T-404 当前补丁已让 `replace_cover()` 上传前先点击上传块模拟人工入口、短暂等待、重新获取 input 后再 `DOM.setFileInputFiles`,并补 mock 回归测试。25120403046 只有 8 张商品图时未删除第一张导致 Shopee 提示 `有1張重複的圖片`;当前已落地为更新封面统一先删当前第一张再上传,并把 `重複/重复/duplicate` toast 识别为封面上传失败。新采集/新生成图片路径已改为按批次细分,避免同一账号多批次图片混放;历史 DB 已存路径继续可用。确认后若返回我的商品列表页且设置了成功后关闭本次新开编辑页,关闭前等待 2 秒。2026-07-01 用户已用包含 5 个商品 ID 的 Excel 完成导入、AI 生成标题/图片、Tab③ 更新到 Shopee 的真实链路验收,T-404 判定完成。 diff --git a/progress.md b/progress.md index 7e4ec8c..4516858 100644 --- a/progress.md +++ b/progress.md @@ -1029,4 +1029,9 @@ ## 【2026-07-02】T-522 完成 · CI 自动跑语法 + 单元 / GUI 测试 - 配置:新增 `.github/workflows/tests.yml`,在 push / pull_request 上使用 `windows-latest` + Python 3.11,安装 `requirements.txt`,设置 `QT_QPA_PLATFORM=offscreen`,运行 `python -m compileall app main.py` 与 `python -m unittest discover -s tests`。 - 文档:同步 `docs/03-tech-stack.md`、`docs/README.md`、`docs/current-state.md`;`docs/06-tasks.md` 将 T-522 标为 DONE,下一个可领取任务更新为 T-523。 -- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(159 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示)。本地未执行 GitHub Actions 远端 runner,workflow 已按同等命令配置。 \ No newline at end of file +- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests` 通过(159 tests);`git diff --check` 无空白错误(仅 LF/CRLF 提示)。本地未执行 GitHub Actions 远端 runner,workflow 已按同等命令配置。 +## 【2026-07-02】T-523 完成 · 拆分 app/gui.py 为 app/gui/ 包 +- 代码:删除旧 `app/gui.py`,新增 `app/gui/` 包;`__init__.py` 保持 `from app import gui`、`from app.gui import MainWindow/各 Tab/各 Worker` 等旧导入兼容;`main_window.py` 放 `MainWindow`;`models.py` 放 3 个 TableModel;`widgets.py` 放 Qt 导入、语义色板、空状态卡、批次总览和日志 helper;`workers.py` 放 `GenerateWorker`/`ApplyWorker`/`CollectWorker`/`WriteBackWorker`/`AccountLoginCheckWorker`/`AIModelTestWorker`;`tabs/` 按 ①~⑤ 拆分各 Tab。 +- 边界:纯结构重构;未改 Shopee/CDP、AI、DB、Excel 行为;PySide6 缺失时仍由 `main()` 返回 1 并提示,旧公开导入路径继续可用。 +- 文档:同步 `docs/04-architecture.md`、`docs/api.md`、`docs/current-state.md`;`docs/06-tasks.md` 将 T-523 标为 DONE,下一个可领取任务更新为 T-524。 +- 验证:`python -m compileall app main.py` 通过;`python -m unittest discover -s tests -p "test_gui.py"` 通过(70 tests);`python -m unittest discover -s tests` 通过(159 tests)。 \ No newline at end of file