"""PySide6 GUI entry point.""" from __future__ import annotations import os import sys try: from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QMainWindow, QMessageBox, QPlainTextEdit, QPushButton, 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; } """ if QT_IMPORT_ERROR is None: from . import accounts, appconfig, chrome, db, editor, excel, prompts from . import config as account_config def _database_path(db_path=None, config=None) -> str: return db_path or appconfig.db_path(config) 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.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 _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): super().__init__(parent) self.tasks = [] self.account_by_alias = {} def set_tasks(self, tasks, accounts): self.beginResetModel() self.tasks = list(tasks) self.account_by_alias = { str(account.alias).strip(): account for account in accounts if str(account.alias).strip() } self.endResetModel() def rowCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(self.tasks) def columnCount(self, parent=QModelIndex()): return 0 if parent.isValid() else len(self.HEADERS) def headerData(self, section, orientation, role=Qt.DisplayRole): if role != Qt.DisplayRole: return None if orientation == Qt.Horizontal and 0 <= section < len(self.HEADERS): return self.HEADERS[section] return section + 1 if orientation == Qt.Vertical else None def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return None task = self.tasks[index.row()] if role == Qt.DisplayRole: return self._display_value(task, index.column()) if role == Qt.ToolTipRole and task.last_error: return task.last_error return None def flags(self, index): if not index.isValid(): return Qt.NoItemFlags return Qt.ItemIsEnabled | Qt.ItemIsSelectable def task_at(self, row): if row < 0 or row >= len(self.tasks): return None return self.tasks[row] def account_name_for(self, task): 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 _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 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, status_callback=None, title_prompt_path=None, cover_prompts_dir=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.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.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.save_cover_template_as_button = QPushButton("另存为") self.rename_cover_template_button = QPushButton("重命名") self.delete_cover_template_button = QPushButton("删除") self.insert_title_button = QPushButton("插入标题") self.preview_prompt_button = QPushButton("预览") 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.save_cover_template_as_button) cover_template_layout.addWidget(self.rename_cover_template_button) cover_template_layout.addWidget(self.delete_cover_template_button) 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.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("状态")) filter_layout.addWidget(self.status_filter, 1) filter_layout.addWidget(self.refresh_button) self.summary_label = QLabel("任务 0 条") self.task_table = QTableView() self.model = GenerateTaskTableModel(self.task_table) self.task_table.setModel(self.model) self.task_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.task_table.setSelectionMode(QAbstractItemView.SingleSelection) self.task_table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.task_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.task_table.verticalHeader().setVisible(False) 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.task_table, 1) 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]) layout = QVBoxLayout(self) layout.setContentsMargins(18, 18, 18, 18) layout.addWidget(self.splitter, 1) self.batch_filter.currentIndexChanged.connect(self.refresh_tasks) self.shop_filter.currentIndexChanged.connect(self.refresh_tasks) self.status_filter.currentIndexChanged.connect(self.refresh_tasks) self.refresh_button.clicked.connect(self.refresh_tasks) self.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_button.clicked.connect(self.save_cover_template_as) self.rename_cover_template_button.clicked.connect(self.rename_cover_template) self.delete_cover_template_button.clicked.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.refresh_cover_templates() self.refresh_tasks() def _set_status(self, message): if self.status_callback is not None: self.status_callback(message) 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 _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" 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_status(task, selected_status) ] except Exception as exc: self.model.set_tasks([], []) self.summary_label.setText("任务读取失败") 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)} 条" ) 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_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 CollectTab(QWidget): """Tab 1: import Excel files and list imported tasks.""" def __init__( self, parent=None, db_path=None, config=None, status_callback=None, open_accounts_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.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.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) 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) self.summary_label = QLabel("未导入任务") self.match_detail_label = QLabel("") self.show_all_button = QPushButton("全部") self.show_unmatched_button = QPushButton("未匹配(0)") 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.empty_label = QLabel("") layout = QVBoxLayout(self) layout.setContentsMargins(18, 18, 18, 18) layout.addLayout(toolbar) layout.addLayout(summary_layout) layout.addWidget(self.match_detail_label) layout.addWidget(self.table, 1) layout.addWidget(self.empty_label) self.import_button.clicked.connect(self.import_excel) self.refresh_button.clicked.connect(self.refresh_tasks) 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() def _set_status(self, message): if self.status_callback is not None: self.status_callback(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,并确认已人工登录 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 try: result = excel.import_tasks(file_paths, path=self.db_path) except Exception as exc: self._show_error(exc) return self.has_import_result = True self.last_import_stats = result.get("stats") or {} self.current_batch_id = result.get("batch_id") 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) if self.current_batch_id is None and self.has_import_result: task_rows = [] else: task_rows = db.list_tasks(batch_id=self.current_batch_id, path=self.db_path) account_rows = accounts.list_accounts(path=self.db_path, config=self.config) except Exception as exc: self.model.set_tasks([], []) self.empty_label.setText("任务读取失败") self._set_status(f"任务读取失败:{exc}") return self.model.set_tasks(task_rows, account_rows) self._update_summary(task_rows, account_rows) self._update_empty_label(len(task_rows)) def collect_old_data(self, checked=False): tasks = list(self.model.all_tasks) if not tasks: self._set_status("没有可采集任务") return worker = CollectWorker(tasks, db_path=self.db_path, config=self.config) worker.progress.connect(self._on_collect_progress) worker.row_updated.connect(self._on_collect_row_updated) worker.log.connect(self._set_status) worker.failed.connect(self._on_collect_failed) worker.finished.connect(self._on_collect_finished) worker.cancelled.connect(self._on_collect_cancelled) 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) 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) 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) 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.refresh_tasks() 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): 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", " ")) 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}") 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), ) ) 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) self.summary_label.setText( f"{files} 文件 · {total} 行 · 有效{valid}/无效{invalid} · 匹配{matched} · 未匹配{unmatched}" ) 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) 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_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 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 CollectWorker(BaseWorker): """Collect old title and cover for imported tasks.""" def __init__(self, tasks, db_path=None, config=None, preflight=True): super().__init__() self.tasks = list(tasks) self.db_path = db_path self.config = config self.preflight = preflight 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" ] total = len(eligible) collected = 0 skipped = 0 failed = 0 done = 0 if self.preflight: blocked = self._preflight_block(eligible, account_rows, account_by_alias) if blocked: blocked.update( { "ok": False, "blocked": True, "total": total, "done": 0, "collected": 0, "skipped": 0, "failed": 0, } ) return blocked 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._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._emit_progress(done, total, collected, skipped, failed) continue try: 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), }, ) db.set_collected( task.id, result.get("old_title", ""), result.get("old_cover_path", ""), path=self.db_path, ) collected += 1 self.row_updated.emit( task.id, { "stage": "collected", "status": "success", "old_title": result.get("old_title", ""), "old_cover_path": result.get("old_cover_path", ""), }, ) except Exception as exc: failed += 1 error = str(exc) or exc.__class__.__name__ db.mark_failed(task.id, "collect", error, path=self.db_path) self.failed.emit(task.id, error) self.row_updated.emit(task.id, {"status": "failed", "last_error": error}) finally: done += 1 self._emit_progress(done, total, collected, skipped, failed) return { "ok": failed == 0, "total": total, "done": done, "collected": collected, "skipped": skipped, "failed": failed, } 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: if not chrome.is_running(account.debug_port): not_running.append(self._account_payload(account, "CDP 端口未响应")) continue status = self._login_status(account) if not status.get("logged_in"): logged_out.append( self._account_payload(account, self._login_skip_reason(status)) ) 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 os.path.abspath( os.path.join( image_root, account.slug, f"{task.item_id}_old.jpg", ) ) class WriteBackWorker(BaseWorker): """Write collected old fields back to Excel in a background thread.""" def __init__(self, batch_id, db_path=None, excel_path=None): super().__init__() self.batch_id = batch_id self.db_path = db_path self.excel_path = excel_path def execute(self): result = excel.write_back( self.batch_id, excel_path=self.excel_path, path=self.db_path, ) self.progress.emit( { "done": result.get("rows", 0), "total": result.get("rows", 0), "files": result.get("files", 0), } ) return result class AccountLoginCheckWorker(BaseWorker): def __init__(self, account, db_path=None, config=None, timeout=8): super().__init__() self.account = account self.db_path = db_path self.config = config self.timeout = timeout def execute(self): status = accounts.detect_login( self.account, timeout=self.timeout, path=self.db_path, config=self.config, ) self.row_updated.emit(self.account.id, status) return {"alias": self.account.alias, "status": status} class AccountsTab(QWidget): COLUMNS = ["账号名", "别名", "地区", "端口", "登录状态", "备注"] def __init__(self, parent=None, db_path=None, config=None, status_callback=None): super().__init__(parent) self.db_path = db_path self.config = appconfig.load_config() if config is None else 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.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), status, account.note or "", ] for column, value in enumerate(values): item = QTableWidgetItem(value) 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 try: accounts.create_account( path=self.db_path, config=self.config, **dialog.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 try: updated = accounts.update_account( account.alias, path=self.db_path, config=self.config, **dialog.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 try: accounts.launch_for_login(account, config=self.config) except Exception as exc: self._show_error(exc) return 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, ) 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}") class MainWindow(QMainWindow): """Main application window with the fixed five-tab workflow.""" def __init__(self, db_path=None, config=None): super().__init__() self.config = appconfig.load_config() if config is None else config self.db_path = _database_path(db_path, self.config) 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.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(), ) if title == "② AI生成": return GenerateTab( db_path=self.db_path, config=self.config, status_callback=self.statusBar().showMessage, ) if title == "④ 账号管理": return AccountsTab( db_path=self.db_path, config=self.config, status_callback=self.statusBar().showMessage, ) widget = QWidget() widget.setObjectName(title) layout = QVBoxLayout(widget) layout.setContentsMargins(18, 18, 18, 18) layout.addStretch(1) return widget 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("④ 账号管理")) 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()