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