import os import sys import re from pathlib import Path from datetime import datetime, timedelta from typing import Any, Dict, List, Optional import pandas as pd from PyQt5.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFileDialog, QTableWidgetItem, QMessageBox, QGridLayout, QStackedWidget, QButtonGroup, QStyle, QComboBox, QFrame, QShortcut, QMenu, QAbstractItemView, QTableView, QHeaderView, QTabWidget, QSplitter, QSizePolicy, QCheckBox ) from PyQt5.QtCore import Qt, QSortFilterProxyModel, QRegularExpression, QSettings, QTimer, QEvent from PyQt5.QtGui import QFont, QTextCursor, QKeySequence, QColor from qfluentwidgets import ( setTheme, Theme, PushButton, PrimaryPushButton, LineEdit, TextEdit, TableWidget, CheckBox, ProgressBar, CardWidget, InfoBar, InfoBarPosition ) from loguru import logger from gui_constants import get_default_folder_path, TABLE_HEADERS, MODEL_VIEW_HEADERS from gui_worker import WorkerThread from gui_models import ConfigTableModel, TableActionDelegate # --------------------------------------------------------------------------- # 主窗口 # --------------------------------------------------------------------------- class MainWindow(QMainWindow): """主窗口""" def __init__(self): super().__init__() self.worker_thread = None self.configs: List[Dict[str, Any]] = [] # 存储从Excel导入的配置 self.prepared_files = None # 存储通过"更新数据"找到的文件列表 self.running_total = 0 self.running_done = 0 self.nav_compact = False self.table_match_rows = [] self.table_match_index = -1 self.table_sort_keys = [] self.page_size = 20 self.current_page = 1 self.page_row_indices = [] self.filtered_config_indices = None # 文本/状态筛选后的配置索引,None 表示未筛选(显示全部) self.use_model_view = False # 批量任务队列相关 self.batch_task_queue = [] # 任务队列 self.current_batch_task_index = 0 # 当前任务索引 self.batch_total_tasks = 0 # 总任务数 self.batch_processed = 0 # 已处理任务数 self.batch_pending_tasks = 0 # 待处理任务数 self.batch_success_count = 0 # 成功数量 self.batch_failed_count = 0 # 失败数量 self._is_paused = False # 任务是否暂停 self._is_terminated = False # 任务是否被终止 self.table_model = None self.table_proxy = None self.log_match_positions = [] self.log_match_index = -1 self.is_updating_table = False self._is_closing = False # 标记是否正在关闭窗口 self._column_width_ratios = {} # 存储列宽比例,用于自动调整 self._is_manual_resize = False # 标记是否正在手动调整列宽 # 任务执行时用于“多多ID+序号 -> 行号”的映射(用于精确回写状态) self._row_map_by_user_index = {} # 以下属性在 init_ui() 中初始化,提前声明以满足 PyCharm 代码审查 self._table_view_column_width_ratios = {} self._is_table_view_manual_resize = False self._resize_timer = None self._table_view_resize_timer = None self._auto_resize_timer = None self._auto_resize_table_view_timer = None self.execute_btn = None self.pause_btn = None self.resume_btn = None self.terminate_btn = None self.status_update_value = None self.status_pending_value = None self.status_running_value = None self.status_success_value = None self.status_failed_value = None self.nav_card = None self.nav_title = None self.nav_divider = None self.nav_group = None self.nav_main = None self.nav_log = None self.nav_buttons = [] self.nav_footer = None self.nav_toggle_btn = None self.page_stack = None self.excel_path_input = None self.import_btn = None self.download_template_btn = None self.add_user_id_input = None self.add_index_input = None self.add_topic_input = None self.add_schedule_input = None self.add_interval_input = None self.add_expert_link_input = None self.add_executor_input = None self.add_config_btn = None self.clear_add_inputs_btn = None self.folder_path_input = None self.folder_browse_btn = None self.update_data_btn = None self.update_status_label = None self.batch_upload_checkbox = None self.batch_limit_input = None self.table_group = None self.table_edit_hint = None self.table_select_all_checkbox = None self.table_search_input = None self.table_column_filter = None self.table_highlight = None self.table_only_match = None self.table_prev_match_btn = None self.table_next_match_btn = None self.table_clear_btn = None self.table_export_all_btn = None self.table_select_count = None self.config_table = None self._filter_table_columns = [] self._filter_model_columns = [] self.table_view = None self.table_empty_label = None self.page_size_combo = None self.page_info_label = None self.page_first_btn = None self.page_prev_btn = None self.page_next_btn = None self.page_last_btn = None self.config_splitter = None self.workbench_tabs = None self.progress_bar = None self.log_search_input = None self.log_highlight_check = None self.log_whole_word = None self.log_prev_btn = None self.log_next_btn = None self.log_match_status = None self.log_export_btn = None self.clear_log_btn = None self.log_text = None self.shortcut_log_next = None self.shortcut_log_prev = None self._suppress_filter_clear_once = False self._current_status_filter = None self._update_data_running = False self.init_ui() def init_ui(self): # 初始化列宽调整相关的变量和定时器 if not hasattr(self, '_table_view_column_width_ratios'): self._table_view_column_width_ratios = {} if not hasattr(self, '_is_table_view_manual_resize'): self._is_table_view_manual_resize = False if not hasattr(self, '_resize_timer'): self._resize_timer = QTimer() self._resize_timer.setSingleShot(True) self._resize_timer.timeout.connect(self._delayed_update_ratios) if not hasattr(self, '_table_view_resize_timer'): self._table_view_resize_timer = QTimer() self._table_view_resize_timer.setSingleShot(True) self._table_view_resize_timer.timeout.connect(self._delayed_update_table_view_ratios) if not hasattr(self, '_auto_resize_timer'): self._auto_resize_timer = QTimer() self._auto_resize_timer.setSingleShot(True) self._auto_resize_timer.timeout.connect(self._auto_resize_table_columns) if not hasattr(self, '_auto_resize_table_view_timer'): self._auto_resize_table_view_timer = QTimer() self._auto_resize_table_view_timer.setSingleShot(True) self._auto_resize_table_view_timer.timeout.connect(self._auto_resize_table_view_columns) self.setWindowTitle("拼多多自动化发布工具") self.setGeometry(100, 100, 1000, 800) # 创建中央部件 central_widget = QWidget() self.setCentralWidget(central_widget) # 主布局 main_layout = QVBoxLayout() main_layout.setContentsMargins(20, 20, 20, 20) main_layout.setSpacing(14) central_widget.setLayout(main_layout) # 顶部标题区 header_layout = QHBoxLayout() title_box = QVBoxLayout() title_label = QLabel("拼多多自动化发布工具", None) title_label.setFont(QFont("Microsoft YaHei", 16, QFont.Bold)) subtitle_label = QLabel("配置导入 • 文件查找 • 批量上传", None) subtitle_label.setFont(QFont("Microsoft YaHei", 10)) title_box.addWidget(title_label) title_box.addWidget(subtitle_label) header_layout.addLayout(title_box) header_layout.addStretch() header_actions = QHBoxLayout() self.execute_btn = PrimaryPushButton("开始上传", None) self.execute_btn.clicked.connect(self.execute_task) header_actions.addWidget(self.execute_btn) self.pause_btn = PushButton("暂停", None) self.pause_btn.setVisible(False) self.pause_btn.clicked.connect(self.pause_task) self.pause_btn.setStyleSheet("QPushButton { background-color: #fff3e0; color: #e65100; }") header_actions.addWidget(self.pause_btn) self.resume_btn = PushButton("继续", None) self.resume_btn.setVisible(False) self.resume_btn.clicked.connect(self.resume_task) self.resume_btn.setStyleSheet("QPushButton { background-color: #e8f5e9; color: #2e7d32; }") header_actions.addWidget(self.resume_btn) self.terminate_btn = PushButton("终止", None) self.terminate_btn.setVisible(False) self.terminate_btn.clicked.connect(self.terminate_task) self.terminate_btn.setStyleSheet("QPushButton { background-color: #ffebee; color: #c62828; }") header_actions.addWidget(self.terminate_btn) header_layout.addLayout(header_actions) main_layout.addLayout(header_layout) # 状态卡片区 status_layout = QHBoxLayout() self.status_update_value = QLabel("未更新", None) self.status_pending_value = QLabel("0", None) self.status_running_value = QLabel("0", None) self.status_success_value = QLabel("0", None) self.status_failed_value = QLabel("0", None) update_card = self._build_status_card( "更新状态", self.status_update_value, self.style().standardIcon(QStyle.StandardPixmap.SP_BrowserReload), "#e6f4ff", "文件路径扫描" ) pending_card = self._build_status_card( "待执行", self.status_pending_value, self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogInfoView), "#fff7ed", "等待处理任务" ) running_card = self._build_status_card( "执行中", self.status_running_value, self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay), "#ecfdf3", "当前执行进度", with_progress=True ) success_card = self._build_status_card( "成功", self.status_success_value, self.style().standardIcon(QStyle.StandardPixmap.SP_DialogApplyButton), "#dcfce7", "成功任务数量", clickable=False ) failed_card = self._build_status_card( "失败", self.status_failed_value, self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxCritical), "#fee2e2", "失败任务数量", clickable=False ) status_layout.addWidget(update_card) status_layout.addWidget(pending_card) status_layout.addWidget(running_card) status_layout.addWidget(success_card) status_layout.addWidget(failed_card) main_layout.addLayout(status_layout) # 中间内容区(侧边导航 + 页面) content_layout = QHBoxLayout() content_layout.setSpacing(12) main_layout.addLayout(content_layout) nav_card = CardWidget() self.nav_card = nav_card nav_layout = QVBoxLayout(nav_card) nav_layout.setContentsMargins(10, 10, 10, 10) nav_layout.setSpacing(8) nav_card.setFixedWidth(150) nav_card.setStyleSheet(""" QPushButton { text-align: left; padding: 8px 10px; border-radius: 6px; border-left: 3px solid transparent; } QPushButton:hover { background-color: rgba(0, 120, 212, 0.08); } QPushButton:checked { background-color: rgba(0, 120, 212, 0.15); font-weight: 600; border-left: 3px solid #0078D4; } """) self.nav_title = QLabel("导航", None) self.nav_title.setFont(QFont("Microsoft YaHei", 10, QFont.Bold)) nav_layout.addWidget(self.nav_title) self.nav_divider = QFrame() self.nav_divider.setFrameShape(QFrame.HLine) self.nav_divider.setStyleSheet("color: rgba(0, 0, 0, 0.12);") nav_layout.addWidget(self.nav_divider) self.nav_group = QButtonGroup(self) self.nav_group.setExclusive(True) self.nav_main = PushButton("工作台", None) self.nav_log = PushButton("日志", None) nav_items = [ (self.nav_main, QStyle.StandardPixmap.SP_DesktopIcon), (self.nav_log, QStyle.StandardPixmap.SP_FileDialogContentsView), ] self.nav_buttons = [] for idx, (btn, icon_type) in enumerate(nav_items): btn.setCheckable(True) # 去掉左侧图标,避免在某些环境下图标位置异常遮挡中文文字 # 如果后续需要图标,可以在这里重新启用并配合样式表单独调整 padding # btn.setIcon(self.style().standardIcon(icon_type)) # btn.setIconSize(QSize(16, 16)) self.nav_group.addButton(btn, idx) nav_layout.addWidget(btn) self.nav_buttons.append(btn) nav_layout.addStretch() nav_footer_row = QHBoxLayout() self.nav_footer = QLabel("v1.0", None) self.nav_footer.setStyleSheet("color: #999; font-size: 10px;") nav_footer_row.addWidget(self.nav_footer) nav_footer_row.addStretch() self.nav_toggle_btn = PushButton("收起", None) self.nav_toggle_btn.clicked.connect(self.toggle_nav_compact) nav_footer_row.addWidget(self.nav_toggle_btn) nav_layout.addLayout(nav_footer_row) content_layout.addWidget(nav_card) self.page_stack = QStackedWidget() content_layout.addWidget(self.page_stack) content_layout.setStretch(1, 1) # 配置输入区域 config_group = CardWidget() config_layout = QVBoxLayout(config_group) config_layout.setContentsMargins(12, 12, 12, 12) config_title = QLabel("配置信息", None) config_title.setFont(QFont("Microsoft YaHei", 11, QFont.Bold)) config_layout.addWidget(config_title) config_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) config_group.setMaximumHeight(450) # 增加高度以容纳单条配置输入区域 # Excel导入(合并到配置) import_row = QHBoxLayout() import_row.addWidget(QLabel("Excel文件:", None)) self.excel_path_input = LineEdit() self.excel_path_input.setReadOnly(True) self.excel_path_input.setPlaceholderText("点击导入按钮选择Excel配置文件") import_row.addWidget(self.excel_path_input) self.import_btn = PrimaryPushButton("导入配置", None) self.import_btn.setToolTip("选择Excel文件并导入配置") self.import_btn.clicked.connect(self.import_excel) import_row.addWidget(self.import_btn) self.download_template_btn = PushButton("下载模板", None) self.download_template_btn.setToolTip("下载Excel配置模板文件") self.download_template_btn.clicked.connect(self.download_excel_template) import_row.addWidget(self.download_template_btn) config_layout.addLayout(import_row) # 添加单条配置输入区域 add_config_separator = QLabel("或 手动添加单条配置", None) add_config_separator.setFont(QFont("Microsoft YaHei", 9, QFont.Bold)) add_config_separator.setStyleSheet("color: #666; margin-top: 8px;") config_layout.addWidget(add_config_separator) # 单条配置输入表单 add_config_grid = QGridLayout() add_config_grid.setHorizontalSpacing(12) add_config_grid.setVerticalSpacing(8) # 第一行:多多ID、序号、话题 add_config_grid.addWidget(QLabel("多多ID:", None), 0, 0) self.add_user_id_input = LineEdit() self.add_user_id_input.setPlaceholderText("输入多多ID") add_config_grid.addWidget(self.add_user_id_input, 0, 1) add_config_grid.addWidget(QLabel("序号:", None), 0, 2) self.add_index_input = LineEdit() self.add_index_input.setPlaceholderText("输入序号") self.add_index_input.setFixedWidth(80) add_config_grid.addWidget(self.add_index_input, 0, 3) add_config_grid.addWidget(QLabel("话题:", None), 0, 4) self.add_topic_input = LineEdit() self.add_topic_input.setPlaceholderText("输入话题(如 #话题)") add_config_grid.addWidget(self.add_topic_input, 0, 5) # 第二行:定时发布、间隔时间、达人链接 add_config_grid.addWidget(QLabel("定时发布:", None), 1, 0) self.add_schedule_input = LineEdit() self.add_schedule_input.setPlaceholderText("yyyy-MM-dd HH:mm(可选)") add_config_grid.addWidget(self.add_schedule_input, 1, 1) add_config_grid.addWidget(QLabel("间隔(分):", None), 1, 2) self.add_interval_input = LineEdit() self.add_interval_input.setPlaceholderText("延迟分钟") self.add_interval_input.setFixedWidth(80) add_config_grid.addWidget(self.add_interval_input, 1, 3) add_config_grid.addWidget(QLabel("达人链接:", None), 1, 4) self.add_expert_link_input = LineEdit() self.add_expert_link_input.setPlaceholderText("输入达人链接(可选)") add_config_grid.addWidget(self.add_expert_link_input, 1, 5) # 第三行:执行人和添加按钮 add_config_grid.addWidget(QLabel("执行人:", None), 2, 0) self.add_executor_input = LineEdit() self.add_executor_input.setPlaceholderText("输入执行人(可选)") add_config_grid.addWidget(self.add_executor_input, 2, 1) add_config_btn_layout = QHBoxLayout() add_config_btn_layout.addStretch() self.add_config_btn = PrimaryPushButton("添加到配置列表", None) self.add_config_btn.setToolTip("将当前输入的配置添加到配置列表中") self.add_config_btn.clicked.connect(self.add_single_config) add_config_btn_layout.addWidget(self.add_config_btn) self.clear_add_inputs_btn = PushButton("清空输入", None) self.clear_add_inputs_btn.clicked.connect(self.clear_add_config_inputs) add_config_btn_layout.addWidget(self.clear_add_inputs_btn) add_config_grid.addLayout(add_config_btn_layout, 2, 2, 1, 4) config_layout.addLayout(add_config_grid) # 分隔线 divider = QFrame() divider.setFrameShape(QFrame.HLine) divider.setStyleSheet("color: rgba(0, 0, 0, 0.12); margin: 8px 0;") config_layout.addWidget(divider) grid = QGridLayout() grid.setHorizontalSpacing(12) grid.setVerticalSpacing(10) # 文件夹路径(最外层文件夹) grid.addWidget(QLabel("资料文件夹路径:", None), 0, 0) self.folder_path_input = LineEdit() default_path = get_default_folder_path() self.folder_path_input.setPlaceholderText(f"留空则使用默认路径: {default_path}") self.folder_path_input.setClearButtonEnabled(True) grid.addWidget(self.folder_path_input, 0, 1, 1, 2) self.folder_browse_btn = PushButton("浏览", None) self.folder_browse_btn.clicked.connect(self.browse_folder) grid.addWidget(self.folder_browse_btn, 0, 3) tip_label = QLabel("提示:只需填写最外层文件夹路径,程序会自动查找子文件夹中的文件", None) tip_label.setStyleSheet("color: #666; font-size: 10px;") grid.addWidget(tip_label, 1, 0, 1, 4) # 更新数据按钮 + 批量上传(同一行) update_row = QHBoxLayout() self.update_data_btn = PrimaryPushButton("更新数据", None) self.update_data_btn.clicked.connect(self.update_data) update_row.addWidget(self.update_data_btn) self.update_status_label = QLabel("未更新", None) self.update_status_label.setStyleSheet("color: #666; font-size: 10px;") update_row.addWidget(self.update_status_label) update_row.addStretch() self.batch_upload_checkbox = CheckBox("批量上传(如果文件夹中有多个视频,将使用批量上传模式)", None) self.batch_upload_checkbox.setChecked(False) update_row.addWidget(self.batch_upload_checkbox) # 添加间隔 update_row.addSpacing(20) # 单次上限数输入框 batch_limit_label = QLabel("单次上限数:", None) update_row.addWidget(batch_limit_label) self.batch_limit_input = LineEdit() self.batch_limit_input.setPlaceholderText("5") self.batch_limit_input.setText("5") self.batch_limit_input.setFixedWidth(50) self.batch_limit_input.setAlignment(Qt.AlignmentFlag.AlignCenter) self.batch_limit_input.setToolTip("每次批量上传的最大视频数量,超过则分批上传") update_row.addWidget(self.batch_limit_input) update_row_widget = QWidget() update_row_widget.setLayout(update_row) grid.addWidget(update_row_widget, 2, 0, 1, 4) config_layout.addLayout(grid) config_layout.addStretch() config_page = QWidget() config_page_layout = QVBoxLayout(config_page) config_page_layout.setContentsMargins(0, 0, 0, 0) config_page_layout.setSpacing(12) config_page_layout.addWidget(config_group) # 配置列表表格(如果从Excel导入) self.table_group = CardWidget() table_layout = QVBoxLayout(self.table_group) table_layout.setContentsMargins(12, 12, 12, 12) table_title = QLabel("配置列表(从Excel导入后显示,可直接在表格中编辑)", None) table_title.setFont(QFont("Microsoft YaHei", 11, QFont.Bold)) table_layout.addWidget(table_title) self.table_edit_hint = QLabel("编辑模式:当前行已高亮,其它行已锁定。修改后点击“确认”保存,点击“退出”还原。", None) self.table_edit_hint.setVisible(False) self.table_edit_hint.setStyleSheet( "background-color: #fff7ed; color: #9a3412; " "border: 1px solid #fed7aa; border-radius: 6px; padding: 6px 8px;" ) table_layout.addWidget(self.table_edit_hint) search_row = QHBoxLayout() self.table_select_all_checkbox = CheckBox("全选/取消", None) # noinspection PyUnresolvedReferences self.table_select_all_checkbox.stateChanged.connect(self.toggle_all_checkboxes) search_row.addWidget(self.table_select_all_checkbox) self.table_search_input = LineEdit() self.table_search_input.setPlaceholderText("搜索表格(支持空格多关键词)") self.table_search_input.setClearButtonEnabled(True) self.table_search_input.setFixedWidth(250) self.table_search_input.textChanged.connect(self.filter_table) search_row.addWidget(self.table_search_input) self.table_column_filter = QComboBox() self.table_column_filter.currentIndexChanged.connect(lambda: self.filter_table(self.table_search_input.text())) search_row.addWidget(self.table_column_filter) self.table_highlight = CheckBox("高亮匹配", None) self.table_highlight.setChecked(True) # noinspection PyUnresolvedReferences self.table_highlight.stateChanged.connect(lambda: self.filter_table(self.table_search_input.text())) search_row.addWidget(self.table_highlight) self.table_only_match = CheckBox("仅显示匹配项", None) self.table_only_match.setChecked(True) # noinspection PyUnresolvedReferences self.table_only_match.stateChanged.connect(lambda: self.filter_table(self.table_search_input.text())) search_row.addWidget(self.table_only_match) self.table_prev_match_btn = PushButton("上一条", None) self.table_prev_match_btn.clicked.connect(self.prev_table_match) search_row.addWidget(self.table_prev_match_btn) self.table_next_match_btn = PushButton("下一条", None) self.table_next_match_btn.clicked.connect(self.next_table_match) search_row.addWidget(self.table_next_match_btn) self.table_clear_btn = PushButton("清空筛选", None) self.table_clear_btn.clicked.connect(self._clear_filter_and_selection) search_row.addWidget(self.table_clear_btn) update_row.addSpacing(20) self.table_export_all_btn = PushButton("导出全部", None) self.table_export_all_btn.clicked.connect(self.export_all_rows) search_row.addWidget(self.table_export_all_btn) self.table_select_count = QLabel("已选: 0", None) self.table_select_count.setStyleSheet("color: #666; font-weight: bold;") search_row.addWidget(self.table_select_count) table_layout.addLayout(search_row) self.config_table = TableWidget() self.config_table.setStyleSheet(""" QTableView::item:selected { background-color: rgba(0, 120, 212, 0.18); } QTableView::item:hover { background-color: rgba(0, 120, 212, 0.08); } QHeaderView::section { background-color: #1f2937; color: #ffffff; padding: 8px; border: none; } QHeaderView::section:hover { background-color: #374151; } """) self.config_table.setColumnCount(12) self.config_table.setHorizontalHeaderLabels(TABLE_HEADERS) self.table_column_filter.addItem("全部列") # 第0列为勾选框;记录下拉项对应的表格列索引及 Model 列索引 self._filter_table_columns = [] self._filter_model_columns = [] # Model/View 无勾选列 for col in range(1, 10): header = self.config_table.horizontalHeaderItem(col) if header: self.table_column_filter.addItem(header.text()) self._filter_table_columns.append(col) # Model/View 列映射:1..8→0..7, 9→8 self._filter_model_columns.append(8 if col == 9 else col - 1) # 默认按多多ID筛选(多多ID为下拉第2项,index=1) if self._filter_table_columns and self._filter_table_columns[0] == 1: self.table_column_filter.setCurrentIndex(1) header = self.config_table.horizontalHeader() header.setStretchLastSection(True) # 设置所有列为Interactive模式,允许用户拖拽调整宽度 header.setSectionResizeMode(QHeaderView.Interactive) # 设置最小列宽,确保内容可见 header.setMinimumSectionSize(50) # 设置文本省略模式(超长文本显示省略号) self.config_table.setTextElideMode(Qt.TextElideMode.ElideRight) self.config_table.setAlternatingRowColors(True) self.config_table.setSortingEnabled(True) self.config_table.horizontalHeader().setSortIndicatorShown(True) self.config_table.verticalHeader().setVisible(False) self.config_table.verticalHeader().setDefaultSectionSize(42) # 禁用直接编辑,只能通过编辑按钮进入编辑模式 self.config_table.setEditTriggers(TableWidget.NoEditTriggers) self.config_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.config_table.setSelectionMode(QAbstractItemView.ExtendedSelection) self.config_table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.config_table.customContextMenuRequested.connect(self.show_table_context_menu) self.config_table.itemChanged.connect(self.on_table_item_changed) self.config_table.horizontalHeader().sectionClicked.connect(self.on_header_clicked) # 连接列宽调整信号,用于跟踪手动调整和自动调整 self.config_table.horizontalHeader().sectionResized.connect(self.on_column_resized) # 连接表格resize事件,用于自动按比例调整列宽 self.config_table.horizontalHeader().geometriesChanged.connect(self.on_table_geometry_changed) # 点击空白区域或按Esc键时退出编辑状态 self.config_table.viewport().installEventFilter(self) self.config_table.installEventFilter(self) table_layout.addWidget(self.config_table, 1) # stretch=1,占据剩余空间 # 大数据模式表格(Model/View) self.table_view = QTableView() self.table_view.setAlternatingRowColors(True) self.table_view.setSortingEnabled(True) self.table_view.verticalHeader().setVisible(False) self.table_view.setSelectionBehavior(QAbstractItemView.SelectRows) self.table_view.setSelectionMode(QAbstractItemView.ExtendedSelection) # 设置文本省略模式(超长文本显示省略号) self.table_view.setTextElideMode(Qt.TextElideMode.ElideRight) self.table_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table_view.customContextMenuRequested.connect(self.show_table_context_menu) self.table_view.setStyleSheet(""" QTableView::item:selected { background-color: rgba(0, 120, 212, 0.18); } QTableView::item:hover { background-color: rgba(0, 120, 212, 0.08); } QHeaderView::section { background-color: #1f2937; color: #ffffff; padding: 8px; border: none; } QHeaderView::section:hover { background-color: #374151; } """) self.table_view.setVisible(False) table_layout.addWidget(self.table_view, 1) # stretch=1,占据剩余空间 self.table_empty_label = QLabel("暂无数据,请先导入Excel配置", None) self.table_empty_label.setStyleSheet("color: #999; font-size: 12px;") self.table_empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.table_empty_label.setVisible(True) table_layout.addWidget(self.table_empty_label) # 分页控件行(不设置 stretch,固定在底部) pagination_row = QHBoxLayout() pagination_row.setContentsMargins(0, 8, 0, 0) pagination_row.addStretch() self.page_size_combo = QComboBox() self.page_size_combo.addItems(["10", "20", "50", "100"]) self.page_size_combo.setCurrentText(str(self.page_size)) self.page_size_combo.currentTextChanged.connect(self.change_page_size) pagination_row.addWidget(QLabel("每页", None)) pagination_row.addWidget(self.page_size_combo) self.page_info_label = QLabel("第 1/1 页", None) pagination_row.addWidget(self.page_info_label) self.page_first_btn = PushButton("首页", None) self.page_prev_btn = PushButton("上一页", None) self.page_next_btn = PushButton("下一页", None) self.page_last_btn = PushButton("末页", None) self.page_first_btn.clicked.connect(self.go_first_page) self.page_prev_btn.clicked.connect(self.go_prev_page) self.page_next_btn.clicked.connect(self.go_next_page) self.page_last_btn.clicked.connect(self.go_last_page) pagination_row.addWidget(self.page_first_btn) pagination_row.addWidget(self.page_prev_btn) pagination_row.addWidget(self.page_next_btn) pagination_row.addWidget(self.page_last_btn) table_layout.addLayout(pagination_row, 0) # stretch=0,固定大小 self.table_group.setVisible(True) self.table_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) # 配置 + 列表 分割布局 self.config_splitter = QSplitter(Qt.Orientation.Vertical) self.config_splitter.setChildrenCollapsible(True) self.config_splitter.addWidget(config_group) self.config_splitter.addWidget(self.table_group) self.config_splitter.setStretchFactor(0, 2) self.config_splitter.setStretchFactor(1, 3) self.config_splitter.splitterMoved.connect(self._save_splitter_sizes) config_page_layout.addWidget(self.config_splitter) self.page_stack.addWidget(config_page) # 工作台标签页 self.workbench_tabs = QTabWidget() self.workbench_tabs.addTab(config_page, "配置") workbench_page = QWidget() workbench_layout = QVBoxLayout(workbench_page) workbench_layout.setContentsMargins(0, 0, 0, 0) workbench_layout.addWidget(self.workbench_tabs) self.page_stack.addWidget(workbench_page) # 进度条(全局) self.progress_bar = ProgressBar() self.progress_bar.setVisible(False) main_layout.addWidget(self.progress_bar) # 日志显示区域 log_group = CardWidget() log_layout = QVBoxLayout(log_group) log_layout.setContentsMargins(12, 12, 12, 12) log_header = QHBoxLayout() log_title = QLabel("执行日志", None) log_title.setFont(QFont("Microsoft YaHei", 11, QFont.Bold)) log_header.addWidget(log_title) log_header.addStretch() self.log_search_input = LineEdit() self.log_search_input.setPlaceholderText("搜索日志") self.log_search_input.setClearButtonEnabled(True) self.log_search_input.textChanged.connect(self.filter_log) log_header.addWidget(self.log_search_input) self.log_highlight_check = CheckBox("高亮所有", None) self.log_highlight_check.setChecked(True) # noinspection PyUnresolvedReferences self.log_highlight_check.stateChanged.connect(lambda: self.filter_log(self.log_search_input.text())) log_header.addWidget(self.log_highlight_check) self.log_whole_word = CheckBox("整词匹配", None) # noinspection PyUnresolvedReferences self.log_whole_word.stateChanged.connect(lambda: self.filter_log(self.log_search_input.text())) log_header.addWidget(self.log_whole_word) self.log_prev_btn = PushButton("上一个", None) self.log_prev_btn.clicked.connect(lambda: self.find_log(backward=True)) log_header.addWidget(self.log_prev_btn) self.log_next_btn = PushButton("下一个", None) self.log_next_btn.clicked.connect(lambda: self.find_log(backward=False)) log_header.addWidget(self.log_next_btn) self.log_match_status = QLabel("匹配: 0", None) self.log_match_status.setStyleSheet("color: #666; font-size: 10px;") log_header.addWidget(self.log_match_status) # 初始化表格列宽比例 QTimer.singleShot(100, self._apply_table_column_widths) self.log_export_btn = PushButton("导出日志", None) self.log_export_btn.clicked.connect(self.export_log) log_header.addWidget(self.log_export_btn) self.clear_log_btn = PushButton("清空日志", None) self.clear_log_btn.clicked.connect(self.clear_log) log_header.addWidget(self.clear_log_btn) log_layout.addLayout(log_header) self.log_text = TextEdit() self.log_text.setReadOnly(True) self.log_text.setFont(QFont("Consolas", 10)) log_layout.addWidget(self.log_text) log_page = QWidget() log_page_layout = QVBoxLayout(log_page) log_page_layout.setContentsMargins(0, 0, 0, 0) log_page_layout.addWidget(log_group) self.page_stack.addWidget(log_page) # 配置日志输出(保留控制台输出,GUI通过信号接收) logger.remove() logger.add(lambda msg: None) # 禁用默认输出,通过信号在GUI中显示 # 默认选中工作台 self.nav_main.setChecked(True) self.page_stack.setCurrentIndex(0) self.nav_group.buttonClicked[int].connect(self.switch_page) self._restore_splitter_sizes() # 快捷键 self.shortcut_log_next = QShortcut(QKeySequence("F3"), self) self.shortcut_log_next.activated.connect(lambda: self.find_log(backward=False)) self.shortcut_log_prev = QShortcut(QKeySequence("Shift+F3"), self) self.shortcut_log_prev.activated.connect(lambda: self.find_log(backward=True)) # 程序启动时重置状态(不累计历史数据) self.set_status_cards(update_text="未更新", pending=0, running=0, success=0, failed=0) def _build_status_card(self, title, value_label, icon, bg_color, subtitle, with_progress=False, clickable=False): """创建状态卡片""" card = CardWidget() if clickable: card.setCursor(Qt.CursorShape.PointingHandCursor) card.setToolTip(subtitle) layout = QVBoxLayout(card) layout.setContentsMargins(12, 10, 12, 10) card.setStyleSheet(f"background-color: {bg_color};") icon_label = QLabel() icon_label.setPixmap(icon.pixmap(16, 16)) title_row = QHBoxLayout() title_label = QLabel(title) title_label.setFont(QFont("Microsoft YaHei", 9)) title_row.addWidget(icon_label) title_row.addWidget(title_label) title_row.addStretch() subtitle_label = QLabel(subtitle) subtitle_label.setStyleSheet("color: #666; font-size: 10px;") value_label.setFont(QFont("Microsoft YaHei", 12, QFont.Bold)) layout.addLayout(title_row) layout.addWidget(value_label) layout.addWidget(subtitle_label) if with_progress: self.status_running_progress = ProgressBar() self.status_running_progress.setRange(0, 100) self.status_running_progress.setValue(0) self.status_running_progress.setTextVisible(False) self.status_running_progress.setFixedHeight(6) layout.addWidget(self.status_running_progress) return card def switch_page(self, page_index): """切换侧边导航页面""" self.page_stack.setCurrentIndex(page_index) def eventFilter(self, obj, event): """事件过滤器:处理点击空白区域和按Esc键退出编辑状态""" # 处理表格viewport的鼠标点击事件 if obj == self.config_table.viewport() and event.type() == QEvent.Type.MouseButtonPress: # 获取点击位置对应的单元格 index = self.config_table.indexAt(event.pos()) if not index.isValid(): # 点击了空白区域,退出编辑状态 self._exit_table_edit_mode() return False # 继续传递事件 # 处理表格的键盘事件(Esc键退出编辑) if obj == self.config_table and event.type() == QEvent.Type.KeyPress: if event.key() == Qt.Key.Key_Escape: self._exit_table_edit_mode() return True # 阻止事件继续传递 return super().eventFilter(obj, event) def _exit_table_edit_mode(self): """退出表格编辑状态""" self.config_table.clearSelection() self.config_table.clearFocus() # 关闭当前编辑器 current_item = self.config_table.currentItem() if current_item: self.config_table.closePersistentEditor(current_item) self.config_table.setCurrentItem(None) # 恢复为只读模式 self.config_table.setEditTriggers(TableWidget.NoEditTriggers) self._cleanup_edit_mode_state() def _set_row_highlight(self, row, enabled): """高亮/取消高亮某一行""" highlight_color = QColor(255, 247, 216) for col in range(1, 10): # 数据列 1-9 item = self.config_table.item(row, col) if not item: continue if enabled: item.setBackground(highlight_color) else: item.setData(Qt.ItemDataRole.BackgroundRole, None) def _set_other_rows_locked(self, edit_row, locked): """锁定/解锁编辑行以外的行""" for r in range(self.config_table.rowCount()): if r == edit_row: # 编辑行保持可用 continue # 操作列按钮禁用/启用 action_widget = self.config_table.cellWidget(r, 11) if action_widget: action_widget.setEnabled(not locked) # 数据列禁用/启用 for col in range(1, 10): # 数据列 1-9 item = self.config_table.item(r, col) if not item: continue if locked: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEnabled) else: item.setFlags((item.flags() | Qt.ItemFlag.ItemIsEnabled) & ~Qt.ItemFlag.ItemIsEditable) def _cleanup_edit_mode_state(self): """清理编辑态UI状态""" edit_row = getattr(self, '_editing_row', None) if edit_row is None: return self._set_row_highlight(edit_row, False) self._set_other_rows_locked(edit_row, False) self._editing_row = None if getattr(self, "table_edit_hint", None): self.table_edit_hint.setVisible(False) if hasattr(self, '_edit_selection_mode_backup'): self.config_table.setSelectionMode(self._edit_selection_mode_backup) def on_table_item_changed(self, item): """表格内容变更回调""" try: if not item: return # 防止递归调用:如果正在更新中,跳过 if self.is_updating_table: return # 检查 item 是否仍然有效(避免访问已删除的对象) try: row = item.row() col = item.column() text = item.text() except RuntimeError: # QTableWidgetItem 已被删除 return # 第8列:情况列,联动进度列的显示 if col == 8: # 设置标志,防止递归 self.is_updating_table = True try: self._set_status_item(row, text) self._set_progress_item(row, text) finally: self.is_updating_table = False # 其它可编辑列:同步当前行到 configs self._sync_config_from_row(row) except Exception as e: logger.warning(f"表格项改变回调出错: {e}") # 确保标志被重置 self.is_updating_table = False def _set_checkbox_item(self, row, config_index, row_height=None): """设置勾选框列(第0列)。row_height 传入时使用固定高度+居中对齐,避免第二行起勾选框往下错位。""" checkbox = QCheckBox() checkbox.blockSignals(True) checkbox.setChecked(self.configs[config_index].get('勾选', False)) # 默认不勾选 checkbox.blockSignals(False) # noinspection PyUnresolvedReferences checkbox.stateChanged.connect(lambda state, idx=config_index: self._on_checkbox_changed(idx, state)) checkbox.setStyleSheet( "QCheckBox { margin: 0px; padding: 0px; }" "QCheckBox::indicator { width: 20px; height: 20px; }" ) wrapper = QWidget() h = row_height if (row_height is not None and row_height > 0) else 42 wrapper.setFixedHeight(h) wrapper.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) layout = QVBoxLayout(wrapper) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(checkbox) self.config_table.setCellWidget(row, 0, wrapper) def _sync_checked_from_table(self): """从表格当前页的勾选框同步勾选状态到 configs(仅非 model_view 且当前页)""" if self.use_model_view or not self.page_row_indices: return for table_row, config_index in enumerate(self.page_row_indices): if config_index >= len(self.configs): continue w = self.config_table.cellWidget(table_row, 0) if not w: continue checkbox = w.findChild(QCheckBox) if hasattr(w, 'findChild') else None if checkbox is not None: self.configs[config_index]['勾选'] = checkbox.isChecked() def _on_checkbox_changed(self, config_index, state): """勾选框状态改变回调""" if config_index < len(self.configs): self.configs[config_index]['勾选'] = (state == Qt.CheckState.Checked) # 更新已选数量显示 self._update_checked_count() # 更新状态统计 self._update_status_statistics() def _update_checked_count(self): """更新已勾选的数量显示""" if not hasattr(self, "table_select_count"): return checked_count = sum(1 for config in self.configs if config.get('勾选', False)) self.table_select_count.setText(f"已选: {checked_count}") def _clear_filter_and_selection(self): """清空筛选并取消所有勾选""" for config in self.configs: config['勾选'] = False if hasattr(self, 'table_select_all_checkbox'): self.table_select_all_checkbox.setChecked(False) self._current_status_filter = None self.filtered_config_indices = None # 清空搜索框会触发 filter_table(""),从而刷新为全量显示 self.table_search_input.setText("") self._update_checked_count() self._show_infobar("info", "已清空", "筛选条件和勾选已清空") def toggle_all_checkboxes(self): """全选/取消全选 - 跨页操作所有数据(考虑筛选条件)""" is_checked = self.table_select_all_checkbox.isChecked() visible_count = 0 # 有筛选时只操作筛选结果(filtered_config_indices),否则操作全部 indices = getattr(self, 'filtered_config_indices', None) if indices is not None: for config_index in indices: if 0 <= config_index < len(self.configs): self.configs[config_index]['勾选'] = is_checked visible_count += 1 else: for config_index in range(len(self.configs)): self.configs[config_index]['勾选'] = is_checked visible_count += 1 self.update_table() self._update_checked_count() self._update_status_statistics() if hasattr(self, 'table_search_input') and self.table_search_input.text().strip(): self._suppress_filter_clear_once = True self.filter_table(self.table_search_input.text()) if is_checked: self._show_infobar("success", "提示", f"已勾选 {visible_count} 行(跨页操作)") else: self._show_infobar("success", "提示", f"已取消 {visible_count} 行勾选(跨页操作)") @staticmethod def _create_centered_item(text): """创建居中对齐的表格单元格""" item = QTableWidgetItem(str(text)) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) # 默认不可编辑,必须通过编辑按钮进入编辑模式 item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) return item def _set_status_item(self, row, text): """设置状态列文本""" try: # 使用 blockSignals 临时阻止信号,防止递归 self.config_table.blockSignals(True) try: item = QTableWidgetItem(text) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) # 居中对齐 item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) # 根据状态设置文字颜色 if "完成" in text or "成功" in text: item.setForeground(QColor("#155724")) # 绿色 elif "失败" in text or "错误" in text: item.setForeground(QColor("#721C24")) # 红色 elif "执行中" in text or "进行" in text: item.setForeground(QColor("#0C5460")) # 蓝色 self._apply_current_highlight_to_item(row, 8, item, text) self.config_table.setItem(row, 8, item) finally: # 恢复信号 self.config_table.blockSignals(False) except Exception as e: logger.warning(f"设置状态项失败: {e}") # 确保信号恢复 self.config_table.blockSignals(False) def _set_progress_item(self, row, status_text): """设置进度列""" progress = ProgressBar() progress.setTextVisible(False) progress.setFixedHeight(8) progress.setRange(0, 100) value = 0 if "完成" in status_text or "成功" in status_text: value = 100 elif "执行中" in status_text or "进行" in status_text: value = 60 elif "待" in status_text: value = 10 progress.setValue(value) self.config_table.setCellWidget(row, 10, progress) def _set_action_buttons(self, row, config_index): """设置操作列按钮(编辑和删除)""" wrapper = QWidget() layout = QHBoxLayout(wrapper) layout.setContentsMargins(2, 0, 2, 0) layout.setSpacing(2) edit_btn = PushButton("编辑", None) edit_btn.setFixedWidth(52) edit_btn.setFixedHeight(32) edit_btn.setFont(QFont("Microsoft YaHei", 10)) delete_btn = PushButton("删除", None) delete_btn.setFixedWidth(52) delete_btn.setFixedHeight(32) delete_btn.setFont(QFont("Microsoft YaHei", 10)) # 使用默认参数捕获当前值,避免闭包问题 edit_btn.clicked.connect(lambda checked, r=row, idx=config_index: self._enter_edit_mode(r, idx)) delete_btn.clicked.connect(lambda checked, idx=config_index: self.delete_row_by_index(idx)) layout.addWidget(edit_btn) layout.addWidget(delete_btn) self.config_table.setCellWidget(row, 11, wrapper) def _set_edit_mode_buttons(self, row, config_index): """设置编辑模式按钮(确认和退出)""" wrapper = QWidget() layout = QHBoxLayout(wrapper) layout.setContentsMargins(2, 0, 2, 0) layout.setSpacing(2) confirm_btn = PushButton("确认", None) confirm_btn.setFixedWidth(52) confirm_btn.setFixedHeight(32) confirm_btn.setFont(QFont("Microsoft YaHei", 10)) cancel_btn = PushButton("退出", None) cancel_btn.setFixedWidth(52) cancel_btn.setFixedHeight(32) cancel_btn.setFont(QFont("Microsoft YaHei", 10)) # 使用默认参数捕获当前值 confirm_btn.clicked.connect(lambda checked, r=row, idx=config_index: self._confirm_edit(r, idx)) cancel_btn.clicked.connect(lambda checked, r=row, idx=config_index: self._cancel_edit(r, idx)) layout.addWidget(confirm_btn) layout.addWidget(cancel_btn) self.config_table.setCellWidget(row, 11, wrapper) def _enter_edit_mode(self, row, config_index): """进入编辑模式""" if row < 0 or row >= self.config_table.rowCount(): return # 允许当前行进入编辑时的交互触发 if not hasattr(self, '_edit_triggers_backup'): self._edit_triggers_backup = self.config_table.editTriggers() self.config_table.setEditTriggers(QAbstractItemView.DoubleClicked | QAbstractItemView.SelectedClicked) if not hasattr(self, '_edit_selection_mode_backup'): self._edit_selection_mode_backup = self.config_table.selectionMode() self.config_table.setSelectionMode(QAbstractItemView.SingleSelection) self._editing_row = row # 保存原始数据用于还原 if not hasattr(self, '_edit_backup'): self._edit_backup = {} original_data = {} for col in range(1, 10): # 1-9列是数据列 item = self.config_table.item(row, col) original_data[col] = item.text() if item else "" self._edit_backup[row] = original_data # 启用该行的编辑 for col in range(1, 10): item = self.config_table.item(row, col) if item: item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) # 高亮编辑行并锁定其他行 self._set_row_highlight(row, True) self._set_other_rows_locked(row, True) # 切换到编辑模式按钮 self._set_edit_mode_buttons(row, config_index) # 自动聚焦第一个可编辑单元格 first_item = None for col in range(1, 10): item = self.config_table.item(row, col) if item and (item.flags() & Qt.ItemFlag.ItemIsEditable): first_item = item self.config_table.setCurrentCell(row, col) break if first_item: self.config_table.setFocus(Qt.FocusReason.TabFocusReason) self.config_table.scrollToItem(first_item, QAbstractItemView.PositionAtCenter) self.config_table.editItem(first_item) if self.table_edit_hint: self.table_edit_hint.setVisible(True) self._show_infobar("info", "编辑模式", f"正在编辑第 {row + 1} 行,修改后点击确认保存或退出还原") def _confirm_edit(self, row, config_index): """确认编辑并保存""" if row < 0 or row >= self.config_table.rowCount(): return # 同步表格数据到configs self._sync_config_from_row(row) # 禁用该行的编辑 for col in range(1, 10): item = self.config_table.item(row, col) if item: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) # 清除备份 if hasattr(self, '_edit_backup') and row in self._edit_backup: del self._edit_backup[row] # 恢复操作按钮 self._set_action_buttons(row, config_index) self._show_infobar("success", "保存成功", f"第 {row + 1} 行数据已保存") # 退出编辑模式后恢复为只读 self.config_table.setEditTriggers(TableWidget.NoEditTriggers) self._cleanup_edit_mode_state() def _cancel_edit(self, row, config_index): """取消编辑并还原数据""" if row < 0 or row >= self.config_table.rowCount(): return # 还原原始数据 if hasattr(self, '_edit_backup') and row in self._edit_backup: original_data = self._edit_backup[row] # 临时断开信号防止触发同步 self.is_updating_table = True try: for col, value in original_data.items(): if col == 8: self._set_status_item(row, value) self._set_progress_item(row, value) else: item = self.config_table.item(row, col) if item: item.setText(value) # 确保清除编辑标志 item = self.config_table.item(row, col) if item: item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) finally: self.is_updating_table = False del self._edit_backup[row] # 恢复操作按钮 self._set_action_buttons(row, config_index) self._show_infobar("info", "已退出", f"第 {row + 1} 行数据已还原") # 退出编辑模式后恢复为只读 self.config_table.setEditTriggers(TableWidget.NoEditTriggers) self._cleanup_edit_mode_state() def delete_row_by_index(self, row): """删除指定行""" if row < 0 or row >= len(self.configs): return self.configs.pop(row) self.update_table() def _sync_config_from_row(self, row): """同步表格行到configs""" if row < 0 or row >= self.config_table.rowCount(): return if not self.page_row_indices: return if row >= len(self.page_row_indices): return config_index = self.page_row_indices[row] if config_index >= len(self.configs): return # 第0列是勾选框,数据列从第1列开始 def cell(col): item = self.config_table.item(row, col + 1) return item.text().strip() if item else "" self.configs[config_index].update({ "多多id": cell(0), "序号": cell(1), "话题": cell(2), "定时发布": cell(3), "间隔时间": cell(4), "达人链接": cell(5), "执行人": cell(6), "情况": cell(7) or "待执行", "文件路径": cell(8), }) def _sync_configs_from_table(self): """从表格同步当前页配置(跳过空行)""" if not self.page_row_indices: return for row, config_index in enumerate(self.page_row_indices): if config_index >= len(self.configs): continue # 第0列是勾选框,数据列从第1列开始 def cell(col): item = self.config_table.item(row, col + 1) return item.text().strip() if item else "" # 判断是否为空行(避免把空白占位行写回) values = [cell(i) for i in range(1, 10)] if not any(values): continue self.configs[config_index].update({ "多多id": cell(0), "序号": cell(1), "话题": cell(2), "定时发布": cell(3), "间隔时间": cell(4), "达人链接": cell(5), "执行人": cell(6), "情况": cell(7) or "待执行", "文件路径": cell(8), }) def _save_splitter_sizes(self): """保存分割器尺寸""" if not hasattr(self, "config_splitter"): return settings = QSettings("haha", "gui_app") settings.setValue("config_splitter_sizes", self.config_splitter.sizes()) def _restore_splitter_sizes(self): """恢复分割器尺寸""" if not hasattr(self, "config_splitter"): return settings = QSettings("haha", "gui_app") sizes = settings.value("config_splitter_sizes") if sizes: self.config_splitter.setSizes([int(s) for s in sizes]) else: # 首次默认:配置区偏大 self.config_splitter.setSizes([450, 550]) def change_page_size(self, value): """修改分页大小""" try: self.page_size = int(value) except ValueError: self.page_size = 20 self.current_page = 1 self.update_table() def go_first_page(self): """首页""" self.current_page = 1 self.update_table() def go_prev_page(self): """上一页""" if self.current_page > 1: self.current_page -= 1 self.update_table() def go_next_page(self): """下一页""" total_rows = len(self.configs) total_pages = max(1, (total_rows + self.page_size - 1) // self.page_size) if self.current_page < total_pages: self.current_page += 1 self.update_table() def go_last_page(self): """末页""" total_rows = len(self.configs) total_pages = max(1, (total_rows + self.page_size - 1) // self.page_size) self.current_page = total_pages self.update_table() def on_header_clicked(self, logical_index): """处理多列排序(Ctrl 多选)""" # 0列为勾选框,10/11列为进度/操作,禁止排序 if logical_index == 0 or logical_index >= 10: return modifiers = QApplication.keyboardModifiers() is_multi = modifiers & Qt.KeyboardModifier.ControlModifier existing: Optional[int] = next((i for i, (col, _) in enumerate(self.table_sort_keys) if col == logical_index), None) if existing is not None: col, order = self.table_sort_keys[int(existing)] new_order = Qt.SortOrder.DescendingOrder if order == Qt.SortOrder.AscendingOrder else Qt.SortOrder.AscendingOrder self.table_sort_keys[int(existing)] = (col, new_order) else: if not is_multi: self.table_sort_keys = [] self.table_sort_keys.append((logical_index, Qt.SortOrder.AscendingOrder)) self._sort_table_by_keys() def _sort_table_by_keys(self): """按多列排序表格""" if not self.table_sort_keys: return self._sync_configs_from_table() key_map = { 1: "多多id", 2: "序号", 3: "话题", 4: "定时发布", 5: "间隔时间", 6: "达人链接", 7: "执行人", 8: "情况", 9: "文件路径", } # 数字类型的列(需要按数字排序) numeric_fields = {"序号", "间隔时间"} def get_sort_key(cfg, field): """获取排序键值,数字列按数字排序""" value = cfg.get(field, "") if field in numeric_fields: try: # 尝试转换为数字进行排序 return (0, float(str(value)) if value else 0) except (ValueError, TypeError): # 转换失败则作为字符串排序,排在数字后面 return (1, str(value)) return (0, str(value)) for col, order in reversed(self.table_sort_keys): sort_field = key_map.get(col, "") self.configs.sort( key=lambda cfg, f=sort_field: get_sort_key(cfg, f), reverse=(order == Qt.SortOrder.DescendingOrder) ) self.update_table() def show_table_context_menu(self, pos): """表格右键菜单""" menu = QMenu(self) copy_action = menu.addAction("复制选中行") export_csv_action = menu.addAction("导出选中行CSV") export_excel_action = menu.addAction("导出选中行Excel") delete_action = menu.addAction("删除选中行") view = self.table_view if self.use_model_view else self.config_table action = menu.exec_(view.viewport().mapToGlobal(pos)) if action == copy_action: self.copy_selected_rows() elif action == delete_action: self.delete_selected_rows() elif action == export_csv_action: self.export_selected_rows("csv") elif action == export_excel_action: self.export_selected_rows("excel") def copy_selected_rows(self): """复制选中行到剪贴板""" if self.use_model_view: if not self.table_view.selectionModel(): return rows = [idx.row() for idx in self.table_view.selectionModel().selectedRows()] if not rows: return lines = [] for row in sorted(set(rows)): values = [] for col in range(9): idx = self.table_proxy.index(row, col) values.append(str(self.table_proxy.data(idx))) lines.append("\t".join(values)) QApplication.clipboard().setText("\n".join(lines)) return ranges = self.config_table.selectedRanges() if not ranges: return rows = set() for r in ranges: rows.update(range(r.topRow(), r.bottomRow() + 1)) lines = [] for row in sorted(rows): values = [] for col in range(self.config_table.columnCount()): item = self.config_table.item(row, col) values.append(item.text() if item else "") lines.append("\t".join(values)) QApplication.clipboard().setText("\n".join(lines)) def delete_selected_rows(self): """删除选中行""" if self.use_model_view and self.table_view.selectionModel(): rows = sorted({idx.row() for idx in self.table_view.selectionModel().selectedRows()}, reverse=True) if not rows: return for row in rows: src_index = self.table_proxy.mapToSource(self.table_proxy.index(row, 0)) if src_index.isValid(): self.table_model.removeRows(src_index.row(), 1) self._update_checked_count() return ranges = self.config_table.selectedRanges() if not ranges: return rows = set() for r in ranges: rows.update(range(r.topRow(), r.bottomRow() + 1)) for row in sorted(rows, reverse=True): self.config_table.removeRow(row) if row < len(self.configs): self.configs.pop(row) self.update_table() def export_selected_rows(self, fmt): """导出选中行""" if self.use_model_view: if not self.table_view.selectionModel(): self._show_infobar("warning", "提示", "未选择任何行") return rows = [idx.row() for idx in self.table_view.selectionModel().selectedRows()] if not rows: self._show_infobar("warning", "提示", "未选择任何行") return data = [] headers = [self.table_proxy.headerData(i, Qt.Orientation.Horizontal) for i in range(9)] for row in sorted(set(rows)): row_data = [] for col in range(9): idx = self.table_proxy.index(row, col) row_data.append(str(self.table_proxy.data(idx))) data.append(row_data) df = pd.DataFrame(data, columns=headers) if fmt == "csv": file_path, _ = QFileDialog.getSaveFileName(self, "导出CSV", "selected.csv", "CSV (*.csv)") if not file_path: return df.to_csv(file_path, index=False, encoding="utf-8-sig") self._show_infobar("success", "成功", f"已导出CSV: {file_path}") else: file_path, _ = QFileDialog.getSaveFileName(self, "导出Excel", "selected.xlsx", "Excel (*.xlsx)") if not file_path: return try: df.to_excel(file_path, index=False) self._show_infobar("success", "成功", f"已导出Excel: {file_path}") except Exception as e: self._show_infobar("error", "错误", f"导出失败: {str(e)}") return ranges = self.config_table.selectedRanges() if not ranges: self._show_infobar("warning", "提示", "未选择任何行") return rows = set() for r in ranges: rows.update(range(r.topRow(), r.bottomRow() + 1)) data = [] headers = [self.config_table.horizontalHeaderItem(i).text() for i in range(1, 10)] for row in sorted(rows): if not self.page_row_indices or row >= len(self.page_row_indices): continue config_index = self.page_row_indices[row] row_data = [] for col in range(1, 10): item = self.config_table.item(row, col) row_data.append(item.text() if item else "") data.append(row_data) df = pd.DataFrame(data, columns=headers) if fmt == "csv": file_path, _ = QFileDialog.getSaveFileName(self, "导出CSV", "selected.csv", "CSV (*.csv)") if not file_path: return df.to_csv(file_path, index=False, encoding="utf-8-sig") self._show_infobar("success", "成功", f"已导出CSV: {file_path}") else: file_path, _ = QFileDialog.getSaveFileName(self, "导出Excel", "selected.xlsx", "Excel (*.xlsx)") if not file_path: return try: df.to_excel(file_path, index=False) self._show_infobar("success", "成功", f"已导出Excel: {file_path}") except Exception as e: self._show_infobar("error", "错误", f"导出失败: {str(e)}") def export_all_rows(self): """导出全部数据""" if self.config_table.rowCount() == 0: self._show_infobar("warning", "提示", "没有可导出的数据") return if self.use_model_view and self.table_proxy: total_rows = self.table_proxy.rowCount() headers = [self.table_proxy.headerData(i, Qt.Orientation.Horizontal) for i in range(9)] data = [] for row in range(total_rows): row_data = [] for col in range(9): idx = self.table_proxy.index(row, col) row_data.append(str(self.table_proxy.data(idx))) data.append(row_data) df = pd.DataFrame(data, columns=headers) file_path, selected_filter = QFileDialog.getSaveFileName( self, "导出数据", "all.csv", "CSV (*.csv);;Excel (*.xlsx)" ) if not file_path: return try: if file_path.lower().endswith(".xlsx") or "Excel" in selected_filter: df.to_excel(file_path, index=False) else: df.to_csv(file_path, index=False, encoding="utf-8-sig") self._show_infobar("success", "成功", f"已导出: {file_path}") except Exception as e: self._show_infobar("error", "错误", f"导出失败: {str(e)}") return headers = [self.config_table.horizontalHeaderItem(i).text() for i in range(1, 10)] data = [] for row in range(self.config_table.rowCount()): row_data = [] for col in range(1, 10): item = self.config_table.item(row, col) row_data.append(item.text() if item else "") data.append(row_data) df = pd.DataFrame(data, columns=headers) file_path, selected_filter = QFileDialog.getSaveFileName( self, "导出数据", "all.csv", "CSV (*.csv);;Excel (*.xlsx)" ) if not file_path: return try: if file_path.lower().endswith(".xlsx") or "Excel" in selected_filter: df.to_excel(file_path, index=False) else: df.to_csv(file_path, index=False, encoding="utf-8-sig") self._show_infobar("success", "成功", f"已导出: {file_path}") except Exception as e: self._show_infobar("error", "错误", f"导出失败: {str(e)}") def toggle_nav_compact(self): """切换侧边导航收起/展开""" self.nav_compact = not self.nav_compact if self.nav_compact: self.nav_card.setFixedWidth(60) for btn in self.nav_buttons: btn.setToolTip(btn.text()) btn.setText("") self.nav_toggle_btn.setText("展开") self.nav_title.setVisible(False) self.nav_divider.setVisible(False) self.nav_footer.setVisible(False) else: self.nav_card.setFixedWidth(150) labels = ["工作台", "日志"] for btn, label in zip(self.nav_buttons, labels): btn.setText(label) btn.setToolTip("") self.nav_toggle_btn.setText("收起") self.nav_title.setVisible(True) self.nav_divider.setVisible(True) self.nav_footer.setVisible(True) def filter_table(self, text): """筛选表格行并高亮关键词""" keyword_raw = text.strip() suppress_clear = getattr(self, "_suppress_filter_clear_once", False) if suppress_clear: self._suppress_filter_clear_once = False # 清除状态筛选,确保文本筛选基于全量数据 if hasattr(self, '_current_status_filter') and self._current_status_filter: self._current_status_filter = None self.filtered_config_indices = None if not self.use_model_view and hasattr(self, 'config_table'): for row in range(self.config_table.rowCount()): self.config_table.setRowHidden(row, False) # 如果先全选再进行筛选,先清空勾选,避免隐藏行仍保持勾选 if (not suppress_clear and keyword_raw and hasattr(self, "table_select_all_checkbox") and self.table_select_all_checkbox.isChecked()): for config in self.configs: config['勾选'] = False self.table_select_all_checkbox.blockSignals(True) self.table_select_all_checkbox.setChecked(False) self.table_select_all_checkbox.blockSignals(False) self._update_checked_count() self._update_status_statistics() # 同步当前页勾选框显示 if not self.use_model_view and hasattr(self, "config_table"): for row in range(self.config_table.rowCount()): wrapper = self.config_table.cellWidget(row, 0) if wrapper: checkbox = wrapper.findChild(QCheckBox) if checkbox: checkbox.blockSignals(True) checkbox.setChecked(False) checkbox.blockSignals(False) if self.use_model_view: if not self.table_proxy: return if not keyword_raw: self.table_proxy.setFilterRegularExpression(QRegularExpression()) return terms = [re.escape(t) for t in keyword_raw.split() if t] if not terms: pattern = "" else: pattern = "".join([f"(?=.*{t})" for t in terms]) + ".*" regex = QRegularExpression(pattern, QRegularExpression.CaseInsensitiveOption) column_index = self._filter_model_column_index() self.table_proxy.setFilterKeyColumn(column_index) self.table_proxy.setFilterRegularExpression(regex) return if not self.config_table: return # 表格列与 config 键的对应(列 1~9) _table_config_keys = ('多多id', '序号', '话题', '定时发布', '间隔时间', '达人链接', '执行人', '情况', '文件路径') if not keyword_raw: # 清空筛选:在全量数据上显示,并清除高亮 self.filtered_config_indices = None self.current_page = 1 self.update_table() for row in range(self.config_table.rowCount()): for col in range(self.config_table.columnCount()): item = self.config_table.item(row, col) if item: item.setBackground(self._default_color()) self.config_table.setRowHidden(row, False) self.table_match_rows = [] self.table_match_index = -1 return # 在全量 configs 上计算匹配的配置索引(不限于当前页) terms_raw = [t for t in keyword_raw.split() if t] terms_lower = [t.lower() for t in terms_raw] column_index = self._filter_column_index() matching_indices = [] for i, config in enumerate(self.configs): if column_index >= 1 and column_index <= 9: key = _table_config_keys[column_index - 1] cell_text = str(config.get(key, '')) else: cell_text = ' '.join(str(config.get(k, '')) for k in _table_config_keys) cell_compare = cell_text.lower() if all(term in cell_compare for term in terms_lower): matching_indices.append(i) only_match = self.table_only_match.isChecked() if hasattr(self, 'table_only_match') and self.table_only_match else False if only_match: # 仅显示匹配项:只显示匹配的数据,表格行数=当前页数据行,不出现空行或其它数据 self.filtered_config_indices = matching_indices else: # 显示全部,只做高亮 self.filtered_config_indices = None self.current_page = 1 self.update_table() # 高亮当前页中的匹配单元格(仅显示匹配项时当前页全是匹配行;显示全部时只高亮匹配的格) for row in range(self.config_table.rowCount()): for col in range(self.config_table.columnCount()): if column_index >= 0 and col != column_index: try: item = self.config_table.item(row, col) if item: item.setBackground(self._default_color()) except RuntimeError: pass continue try: item = self.config_table.item(row, col) if item: cell_text = item.text() cell_compare = cell_text.lower() if all(term in cell_compare for term in terms_lower) and self.table_highlight.isChecked(): item.setBackground(self._highlight_color()) else: item.setBackground(self._default_color()) except RuntimeError: continue self.table_match_rows = list(range(self.config_table.rowCount())) self.table_match_index = -1 def next_table_match(self): """跳转到下一条匹配""" if not self.table_match_rows: return if self.table_match_index < 0: self.table_match_index = 0 else: self.table_match_index = (self.table_match_index + 1) % len(self.table_match_rows) row = self.table_match_rows[self.table_match_index] self.config_table.selectRow(row) item = self.config_table.item(row, 0) or self.config_table.item(row, 1) if item: self.config_table.scrollToItem(item) def prev_table_match(self): """跳转到上一条匹配""" if not self.table_match_rows: return if self.table_match_index < 0: self.table_match_index = len(self.table_match_rows) - 1 else: self.table_match_index = (self.table_match_index - 1) % len(self.table_match_rows) row = self.table_match_rows[self.table_match_index] self.config_table.selectRow(row) item = self.config_table.item(row, 0) or self.config_table.item(row, 1) if item: self.config_table.scrollToItem(item) def _apply_schedule_intervals(self, configs_with_indices): """按多多ID应用定时发布+间隔时间规则 configs_with_indices: [{"config_index": config_index, "config": config}, ...] """ from collections import defaultdict grouped = defaultdict(list) for item in configs_with_indices: config = item["config"] user_id = config.get("多多id", "") if not user_id: continue grouped[user_id].append(item) updated_count = 0 for user_id, items in grouped.items(): # 按原始配置列表中的顺序处理 items.sort(key=lambda x: x["config_index"]) base_time = None # 上一条的发布时间(基准时间) for entry in items: config = entry["config"] config_index = entry["config_index"] schedule_text = (config.get("定时发布") or "").strip() interval_value = config.get("间隔时间", 0) # 解析间隔时间(分钟转秒) current_interval = self._parse_interval_seconds(interval_value) # 解析定时时间 parsed_time = self._parse_schedule_time(schedule_text) if schedule_text else None # 情况1:当前条目有定时时间 -> 使用该定时时间,并记录为基准时间 if parsed_time: base_time = parsed_time # 情况2:当前条目没有定时时间,但有间隔时间 elif not parsed_time and current_interval > 0: if base_time: # 有基准时间 -> 新时间 = 基准时间 + 间隔时间 base_time = base_time + timedelta(seconds=current_interval) new_text = self._format_schedule_time(base_time) # 更新配置和表格单元格(如果可见) self._update_table_cell(config_index, 4, new_text, highlight=True, is_config_index=True) updated_count += 1 return updated_count @staticmethod def _parse_schedule_time(text): """解析定时发布时间字符串""" if not text: return None try: dt = pd.to_datetime(text, errors="coerce") if pd.isna(dt): return None return dt.to_pydatetime() except (ValueError, TypeError, AttributeError): return None def _is_schedule_time_expired(self, time_start): """检查定时发布时间是否已过期(早于当前时间) Args: time_start: 定时发布时间字符串,如 "2026-01-15 09:30:00" Returns: bool: 如果时间已过期返回True,否则返回False。如果时间为空或无法解析,返回False(不跳过) """ if not time_start: return False try: # 解析时间字符串 schedule_dt = self._parse_schedule_time(time_start) if not schedule_dt: return False # 与当前时间比较 now = datetime.now() if schedule_dt < now: return True return False except (ValueError, TypeError, AttributeError) as e: logger.warning(f"检查定时发布时间是否过期时出错: {e}") return False @staticmethod def _parse_interval_seconds(interval_value): """解析间隔时间(默认按分钟),支持秒/分钟/小时(如: 30, 10m, 2h, 10分钟, 2小时) 注意:纯数字默认按分钟;空字符串或None返回0,表示没有间隔时间 """ if interval_value is None or interval_value == '': return 0 value_str = str(interval_value).strip().lower() if not value_str: return 0 # 中文单位映射 cn_map = { "秒": "s", "分钟": "m", "分": "m", "小时": "h", "时": "h" } for cn, en in cn_map.items(): if value_str.endswith(cn): value_str = value_str[:-len(cn)] + en break try: # 纯数字,默认分钟 if value_str.isdigit(): return int(value_str) * 60 # 支持 10m / 2h / 30s unit = value_str[-1] num_part = value_str[:-1].strip() if not num_part: return 0 num = float(num_part) if unit == "m": return int(num * 60) if unit == "h": return int(num * 3600) if unit == "s": return int(num) except (ValueError, TypeError): return 0 return 0 @staticmethod def _format_schedule_time(dt): """格式化定时发布时间字符串""" if not dt: return "" return dt.strftime("%Y-%m-%d %H:%M:%S") def _update_table_cell(self, index, col, value, highlight=False, is_config_index=False): """更新配置数据及其对应的表格单元格(如果当前可见) index: 行号 (is_config_index=False) 或 配置列表索引 (is_config_index=True) """ try: # 1. 确定配置索引和表格行号 if is_config_index: config_index = index row_idx = self.page_row_indices.index(config_index) if hasattr(self, 'page_row_indices') and config_index in self.page_row_indices else -1 else: row_idx = index config_index = self.page_row_indices[row_idx] if hasattr(self, 'page_row_indices') and row_idx < len(self.page_row_indices) else row_idx # 2. 更新数据源 self.configs if 0 <= config_index < len(self.configs): col_to_field = { 4: "定时发布", 8: "情况", 9: "文件路径", } field = col_to_field.get(col) if field: self.configs[config_index][field] = str(value) # 3. 如果行可见,更新 UI if 0 <= row_idx < self.config_table.rowCount(): # 使用 blockSignals 临时阻止信号,防止递归 self.config_table.blockSignals(True) try: item = QTableWidgetItem(str(value)) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) if highlight: item.setBackground(QColor("#E6F4FF")) self.config_table.setItem(row_idx, col, item) finally: self.config_table.blockSignals(False) except Exception as e: logger.warning(f"更新单元格失败: {e}") self.config_table.blockSignals(False) def _update_table_status(self, index, status, is_config_index=False): """更新状态列及其对应的表格单元格 index: 行号 (is_config_index=False) 或 配置列表索引 (is_config_index=True) """ try: # 1. 确定配置索引和表格行号 if is_config_index: config_index = index row_idx = self.page_row_indices.index(config_index) if hasattr(self, 'page_row_indices') and config_index in self.page_row_indices else -1 else: row_idx = index config_index = self.page_row_indices[row_idx] if hasattr(self, 'page_row_indices') and row_idx < len(self.page_row_indices) else row_idx # 2. 更新数据源 if 0 <= config_index < len(self.configs): self.configs[config_index]["情况"] = status # 3. 如果可见,更新 UI if 0 <= row_idx < self.config_table.rowCount(): # 使用 blockSignals 临时阻止信号,防止递归 self.config_table.blockSignals(True) try: # 第8列是"情况"列,只设置文字颜色,不设置背景色和图标 status_item = QTableWidgetItem(status) status_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) status_item.setFlags(status_item.flags() & ~Qt.ItemFlag.ItemIsEditable) if status == "已完成": status_item.setForeground(QColor("#155724")) # 绿色 elif status == "失败": status_item.setForeground(QColor("#721C24")) # 红色 elif status == "执行中": status_item.setForeground(QColor("#0C5460")) # 蓝色 self._apply_current_highlight_to_item(row_idx, 8, status_item, status) self.config_table.setItem(row_idx, 8, status_item) finally: self.config_table.blockSignals(False) except Exception as e: logger.error(f"更新状态失败: {e}") self.config_table.blockSignals(False) @staticmethod def _highlight_color(): """高亮颜色 - 淡蓝色背景""" return QColor(173, 216, 230) # 淡蓝色 (Light Blue) def _default_color(self): """默认背景色""" return self.config_table.palette().color(self.config_table.palette().Base) def _apply_current_highlight_to_item(self, _row, col, item, text=None): """根据当前筛选条件对单元格应用高亮""" if not item or not hasattr(self, "table_search_input"): return if not self.table_highlight.isChecked(): return keyword_raw = self.table_search_input.text().strip() if not keyword_raw: return column_index = self._filter_column_index() if column_index >= 0 and col != column_index: return terms = [t.lower() for t in keyword_raw.split() if t] if not terms: return cell_text = text if text is not None else item.text() cell_compare = (cell_text or "").lower() if all(term in cell_compare for term in terms): item.setBackground(self._highlight_color()) else: item.setBackground(self._default_color()) def _filter_column_index(self): """筛选项下拉对应的表格列索引;-1 表示全部列。排除情况列,以多多ID等为筛选项。""" i = self.table_column_filter.currentIndex() if i <= 0 or not getattr(self, "_filter_table_columns", None): return -1 k = i - 1 if k >= len(self._filter_table_columns): return -1 return self._filter_table_columns[k] def _filter_model_column_index(self): """Model/View 模式下筛选项对应的列索引;-1 表示全部列。""" i = self.table_column_filter.currentIndex() if i <= 0 or not getattr(self, "_filter_model_columns", None): return -1 k = i - 1 if k >= len(self._filter_model_columns): return -1 return self._filter_model_columns[k] def _show_all_rows(self): """显示全部行""" for row in range(self.config_table.rowCount()): self.config_table.setRowHidden(row, False) def _filter_by_status(self, status): """按状态筛选表格行(在全量数据上计算,分页显示筛选结果)""" if not getattr(self, 'configs', None) or not self.configs: self._show_infobar("warning", "提示", "暂无数据") return # 清除文本筛选,确保每次筛选都基于全量数据 if hasattr(self, 'table_search_input') and self.table_search_input.text().strip(): self.table_search_input.blockSignals(True) self.table_search_input.clear() self.table_search_input.blockSignals(False) self.filtered_config_indices = None self.table_match_rows = [] self.table_match_index = -1 # 切换筛选状态 current_filter = getattr(self, '_current_status_filter', None) if current_filter == status: self._current_status_filter = None self.filtered_config_indices = None self.current_page = 1 self.update_table() self._show_infobar("success", "提示", "已显示全部记录") return self._current_status_filter = status # 在全量 configs 上计算匹配的配置索引 matching_indices = [] for i, config in enumerate(self.configs): config_status = str(config.get('情况', '')) if status == "成功": match = "完成" in config_status or "成功" in config_status elif status == "失败": match = "失败" in config_status or "错误" in config_status else: match = status in config_status if match: matching_indices.append(i) self.filtered_config_indices = matching_indices self.current_page = 1 self.update_table() if not matching_indices: self._show_infobar("warning", "提示", f"没有{status}的记录") else: self._show_infobar("success", "筛选", f"已筛选出 {len(matching_indices)} 条{status}记录,再次点击取消筛选") def _update_status_statistics(self): """更新状态统计(成功/失败/待执行数量)- 统计所有配置的真实状态 待执行:勾选的且状态不是"已完成"的数据,表示用户选择要处理的任务数 执行中:只有在任务运行时才有值,任务完成后为0 成功:状态为"已完成"的数据数量 失败:状态为"失败/跳过/过期"的数据数量 """ if not hasattr(self, 'configs') or not self.configs: self.set_status_cards(pending=0, running=0, success=0, failed=0) return pending_count = 0 success_count = 0 failed_count = 0 # 统计所有配置的状态(根据数据总量来统计) for config in self.configs: status = config.get('情况', '待执行') is_checked = config.get('勾选', False) if "完成" in status or "成功" in status: success_count += 1 elif "失败" in status or "错误" in status or "过期" in status or "跳过" in status: failed_count += 1 # 只有勾选的失败项才算待执行 if is_checked: pending_count += 1 elif "待" in status or not status: # 只有勾选的待执行项才算待执行 if is_checked: pending_count += 1 # 执行中始终为0(只有任务运行时才会被单独更新) self.set_status_cards(pending=pending_count, running=0, success=success_count, failed=failed_count) def retry_failed_items(self): """重新发布所有失败的项目""" if not hasattr(self, 'configs') or not self.configs: self._show_infobar("warning", "提示", "暂无数据") return # 收集失败的项目索引 failed_indices = [] for idx, config in enumerate(self.configs): status = config.get('情况', '') if "失败" in status or "错误" in status or "过期" in status or "跳过" in status: failed_indices.append(idx) if not failed_indices: self._show_infobar("warning", "提示", "没有失败的项目需要重新发布") return # 确认对话框 reply = QMessageBox.question( self, "确认重新发布", f"发现 {len(failed_indices)} 条失败记录,是否重新发布?\n\n" "重新发布将把这些记录的状态重置为【待执行】,然后执行发布任务。", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply != QMessageBox.Yes: return # 重置失败项的状态为"待执行" for idx in failed_indices: self.configs[idx]['情况'] = '待执行' # 更新表格显示 self.update_table() self._update_status_statistics() # 筛选显示这些待重新发布的项 self._filter_by_status("待执行") self._show_infobar("success", "已重置", f"已将 {len(failed_indices)} 条失败记录重置为待执行状态,请点击【开始上传】按钮开始发布") def get_failed_configs(self): """获取所有失败的配置项""" if not hasattr(self, 'configs') or not self.configs: return [] failed_configs = [] for config in self.configs: status = config.get('情况', '') if "失败" in status or "错误" in status or "过期" in status or "跳过" in status: failed_configs.append(config) return failed_configs def set_status_cards(self, update_text=None, pending=None, running=None, success=None, failed=None): """更新状态卡片显示""" if update_text is not None: self.status_update_value.setText(update_text) if "未找到" in update_text: self.status_update_value.setStyleSheet("color: #b42318;") elif "未更新" in update_text: self.status_update_value.setStyleSheet("color: #6b7280;") else: self.status_update_value.setStyleSheet("color: #1d4ed8;") if pending is not None: self.status_pending_value.setText(str(pending)) try: pending_num = int(str(pending)) except ValueError: pending_num = 0 if pending_num > 0: self.status_pending_value.setStyleSheet("color: #b45309;") else: self.status_pending_value.setStyleSheet("color: #15803d;") if running is not None: self.status_running_value.setText(str(running)) running_text = str(running) if running_text in ["0", "0/0"]: self.status_running_value.setStyleSheet("color: #6b7280;") else: self.status_running_value.setStyleSheet("color: #1d4ed8;") if success is not None: self.status_success_value.setText(str(success)) try: success_num = int(str(success)) except ValueError: success_num = 0 if success_num > 0: self.status_success_value.setStyleSheet("color: #15803d; font-weight: bold;") else: self.status_success_value.setStyleSheet("color: #6b7280;") if failed is not None: self.status_failed_value.setText(str(failed)) try: failed_num = int(str(failed)) except ValueError: failed_num = 0 if failed_num > 0: self.status_failed_value.setStyleSheet("color: #dc2626; font-weight: bold;") else: self.status_failed_value.setStyleSheet("color: #6b7280;") def set_running_progress(self, done, total): """更新执行中统计""" self.running_done = done self.running_total = total if total > 0: self.set_status_cards(running=f"{done}/{total}") if hasattr(self, "status_running_progress"): percent = int((done / total) * 100) self.status_running_progress.setValue(percent) else: self.set_status_cards(running="0") if hasattr(self, "status_running_progress"): self.status_running_progress.setValue(0) def clear_log(self): """清空日志显示""" self.log_text.clear() def filter_log(self, text): """过滤日志内容""" keyword = text.strip() self._clear_log_highlight() self._update_log_match_status(0) if not keyword: return self._update_log_matches(keyword) if self.log_highlight_check.isChecked(): self._highlight_log_matches() self.find_log(backward=False, reset=True) def export_log(self): """导出日志到文件""" log_content = self.log_text.toPlainText() if not log_content.strip(): self._show_infobar("warning", "提示", "日志为空,无法导出") return file_path, _ = QFileDialog.getSaveFileName( self, "保存日志", "log.txt", "文本文件 (*.txt)" ) if not file_path: return try: with open(file_path, "w", encoding="utf-8") as f: f.write(log_content) self._show_infobar("success", "成功", f"日志已导出: {file_path}") except Exception as e: self._show_infobar("error", "错误", f"导出失败: {str(e)}") def find_log(self, backward=False, reset=False): """查找日志""" keyword = self.log_search_input.text().strip() if not keyword: return if not self.log_match_positions: self._update_log_matches(keyword) if not self.log_match_positions: return if reset: self.log_match_index = 0 if not backward else len(self.log_match_positions) - 1 else: step = -1 if backward else 1 self.log_match_index = (self.log_match_index + step) % len(self.log_match_positions) start, end = self.log_match_positions[self.log_match_index] cursor = self.log_text.textCursor() cursor.setPosition(start) cursor.setPosition(end, QTextCursor.KeepAnchor) self.log_text.setTextCursor(cursor) self.log_text.ensureCursorVisible() def _clear_log_highlight(self): """清除日志高亮""" cursor = self.log_text.textCursor() cursor.select(cursor.Document) cursor.setCharFormat(self.log_text.currentCharFormat()) cursor.clearSelection() def _highlight_log_matches(self): """高亮日志所有匹配项""" if not self.log_match_positions: return fmt = self.log_text.currentCharFormat() fmt.setBackground(self.log_text.palette().color(self.log_text.palette().Highlight).lighter(160)) for start, end in self.log_match_positions: cursor = self.log_text.textCursor() cursor.setPosition(start) cursor.setPosition(end, QTextCursor.KeepAnchor) cursor.mergeCharFormat(fmt) def _update_log_matches(self, keyword): """更新日志匹配位置""" self.log_match_positions = [] self.log_match_index = -1 if not keyword: self._update_log_match_status(0) return content = self.log_text.toPlainText() whole_word = self.log_whole_word.isChecked() pattern_text = re.escape(keyword) if whole_word: pattern_text = rf"\b{pattern_text}\b" pattern = re.compile(pattern_text, re.IGNORECASE) for match in pattern.finditer(content): self.log_match_positions.append((match.start(), match.end())) self._update_log_match_status(len(self.log_match_positions)) def _update_log_match_status(self, count): """更新日志匹配统计""" if hasattr(self, "log_match_status"): self.log_match_status.setText(f"匹配: {count}") def _apply_table_column_widths(self): """应用配置表列宽(仅按窗口宽度自适应,不根据内容变化)""" header = self.config_table.horizontalHeader() header.setStretchLastSection(True) # 禁用根据内容自适应,防止导入或更新数据时列宽剧烈跳动 # 设置最小列宽,确保内容可见 min_widths = { 0: 50, # 勾选框 1: 80, # 多多ID 2: 60, # 序号 3: 100, # 话题 4: 100, # 定时发布 5: 80, # 间隔时间 6: 120, # 达人链接 7: 80, # 执行人 8: 80, # 情况 9: 150, # 文件路径 10: 70, # 进度 11: 100 # 操作 } # 切换到Interactive模式,允许手动拖拽调整 header.setSectionResizeMode(0, QHeaderView.Fixed) # 勾选框固定 for col in range(1, self.config_table.columnCount()): header.setSectionResizeMode(col, QHeaderView.Interactive) if col in min_widths: # 仅在第一次或比例未建立时设置初始宽度 if not self._column_width_ratios: self.config_table.setColumnWidth(col, min_widths[col]) # 如果已经有保存的比例,直接按比例缩放 if self._column_width_ratios: self._auto_resize_table_columns() else: # 第一次加载,根据当前设置的初始宽度计算比例 self._update_column_width_ratios() self._auto_resize_table_columns() def _update_column_width_ratios(self): """更新列宽比例""" if self.config_table.columnCount() == 0: return total_width = 0 widths = {} # 计算所有列的总宽度(不包括勾选框) for col in range(1, self.config_table.columnCount()): width = self.config_table.columnWidth(col) widths[col] = width total_width += width # 计算比例 if total_width > 0: self._column_width_ratios = {} for col, width in widths.items(): self._column_width_ratios[col] = width / total_width def _auto_resize_table_columns(self): """根据比例自动调整表格列宽""" if not self._column_width_ratios or self._is_manual_resize: return header = self.config_table.horizontalHeader() if header.sectionResizeMode(1) != QHeaderView.Interactive: return # 获取表格可用宽度 table_width = self.config_table.viewport().width() checkbox_width = self.config_table.columnWidth(0) if self.config_table.columnCount() > 0 else 0 available_width = table_width - checkbox_width if available_width <= 0: return # 按比例分配宽度 self._is_manual_resize = True # 临时标记,避免触发信号 columns = sorted(self._column_width_ratios.keys()) assigned_width = 0 for i, col in enumerate(columns): if col < self.config_table.columnCount(): ratio = self._column_width_ratios[col] # 最后一列取剩余所有宽度,确保填满 if i == len(columns) - 1: new_width = max(60, available_width - assigned_width) else: new_width = int(available_width * ratio) min_width = 60 if new_width < min_width: new_width = min_width self.config_table.setColumnWidth(col, new_width) assigned_width += new_width self._is_manual_resize = False def on_column_resized(self, logical_index, _old_size, _new_size): """列宽调整时的回调(手动或自动)""" if not self._is_manual_resize and logical_index > 0 and self._resize_timer is not None: # 用户手动调整了列宽,延迟更新比例(避免拖拽时频繁更新) self._resize_timer.stop() self._resize_timer.start(300) # 300ms后更新比例 def _delayed_update_ratios(self): """延迟更新列宽比例""" if not self._is_manual_resize: self._update_column_width_ratios() def on_table_geometry_changed(self): """表格几何形状改变时的回调(窗口大小改变)""" if not self._is_manual_resize and self._auto_resize_timer is not None: # 使用独立的定时器进行防抖,避免频繁触发导致卡顿 self._auto_resize_timer.stop() self._auto_resize_timer.start(50) def _apply_table_view_column_widths(self): """应用 Model/View 列宽(仅按窗口宽度自适应,不根据内容变化)""" if not self.table_view.model(): return header = self.table_view.horizontalHeader() header.setStretchLastSection(True) # 禁用根据内容自适应 min_widths = { 0: 80, # 多多ID 1: 60, # 序号 2: 100, # 话题 3: 100, # 定时发布 4: 80, # 间隔时间 5: 120, # 达人链接 6: 80, # 执行人 7: 80, # 情况 8: 150, # 文件路径 9: 70, # 进度 10: 100 # 操作 } # 切换到Interactive模式 for col in range(self.table_view.model().columnCount()): header.setSectionResizeMode(col, QHeaderView.Interactive) if col in min_widths and not self._table_view_column_width_ratios: self.table_view.setColumnWidth(col, min_widths[col]) # 按比例分配或初始化比例 if self._table_view_column_width_ratios: self._auto_resize_table_view_columns() else: self._update_table_view_column_width_ratios() self._auto_resize_table_view_columns() def _update_table_view_column_width_ratios(self): """更新Model/View模式的列宽比例""" if not self.table_view.model() or self.table_view.model().columnCount() == 0: return total_width = 0 widths = {} # 计算所有列的总宽度 for col in range(self.table_view.model().columnCount()): width = self.table_view.columnWidth(col) widths[col] = width total_width += width # 计算比例 if total_width > 0: self._table_view_column_width_ratios = {} for col, width in widths.items(): self._table_view_column_width_ratios[col] = width / total_width def _auto_resize_table_view_columns(self): """根据比例自动调整Model/View表格列宽""" if not self._table_view_column_width_ratios or self._is_table_view_manual_resize: return header = self.table_view.horizontalHeader() if not header or header.sectionResizeMode(0) != QHeaderView.Interactive: return # 获取表格可用宽度 table_width = self.table_view.viewport().width() if table_width <= 0: return # 按比例分配宽度 self._is_table_view_manual_resize = True # 临时标记,避免触发信号 columns = sorted(self._table_view_column_width_ratios.keys()) assigned_width = 0 for i, col in enumerate(columns): if col < self.table_view.model().columnCount(): ratio = self._table_view_column_width_ratios[col] # 最后一列取剩余所有宽度,确保填满 if i == len(columns) - 1: new_width = max(60, table_width - assigned_width) else: new_width = int(table_width * ratio) min_width = 60 if new_width < min_width: new_width = min_width self.table_view.setColumnWidth(col, new_width) assigned_width += new_width self._is_table_view_manual_resize = False def on_table_view_column_resized(self, _logical_index, _old_size, _new_size): """Model/View模式列宽调整时的回调(手动或自动)""" if not self._is_table_view_manual_resize and self._table_view_resize_timer is not None: # 用户手动调整了列宽,延迟更新比例(避免拖拽时频繁更新) self._table_view_resize_timer.stop() self._table_view_resize_timer.start(300) # 300ms后更新比例 def _delayed_update_table_view_ratios(self): """延迟更新Model/View模式的列宽比例""" if not self._is_table_view_manual_resize: self._update_table_view_column_width_ratios() def on_table_view_geometry_changed(self): """Model/View模式表格几何形状改变时的回调(窗口大小改变)""" if not self._is_table_view_manual_resize and self._auto_resize_table_view_timer is not None: # 使用独立的定时器进行防抖,避免频繁触发导致卡顿 self._auto_resize_table_view_timer.stop() self._auto_resize_table_view_timer.start(50) # noinspection SpellCheckingInspection def _show_infobar(self, level, title, content): """显示提示条""" # noinspection PyArgumentList if level == "success": InfoBar.success(title=title, content=content, parent=self, position=InfoBarPosition.TOP_RIGHT) elif level == "warning": # noinspection PyArgumentList InfoBar.warning(title=title, content=content, parent=self, position=InfoBarPosition.TOP_RIGHT) elif level == "info": # noinspection PyArgumentList InfoBar.info(title=title, content=content, parent=self, position=InfoBarPosition.TOP_RIGHT) else: # noinspection PyArgumentList InfoBar.error(title=title, content=content, parent=self, position=InfoBarPosition.TOP_RIGHT) def browse_folder(self): """浏览文件夹""" folder_path = QFileDialog.getExistingDirectory(self, "选择文件夹") if folder_path: self.folder_path_input.setText(folder_path) def download_excel_template(self): """下载Excel配置模板""" try: # 选择保存路径 file_path, _ = QFileDialog.getSaveFileName( self, "保存配置模板", "配置模板.xlsx", "Excel文件 (*.xlsx)" ) if not file_path: return # 创建模板数据 template_data = { '多多id': ['示例ID_001', '示例ID_001', '示例ID_002'], '序号': ['1', '2', '1'], '话题': ['#话题1', '#话题2', '#话题1'], '定时发布': ['2024-01-01 10:00', '', ''], '间隔时间': [5, 5, 0], '达人链接': ['https://example.com/user1', '', 'https://example.com/user2'], '执行人': ['张三', '张三', '李四'], '情况': ['待执行', '待执行', '待执行'] } df = pd.DataFrame(template_data) # 保存Excel文件 df.to_excel(file_path, index=False, engine='openpyxl') self._show_infobar("success", "成功", f"模板已保存: {file_path}") self.log_text.append(f"配置模板已下载: {file_path}") self.log_text.append("模板说明:") self.log_text.append(" - 多多id: 用户ID,相同ID的数据会按序号顺序处理") self.log_text.append(" - 序号: 文件序号,用于匹配文件夹中的文件(如:1-视频名称.mp4)") self.log_text.append(" - 话题: 发布时的话题标签") self.log_text.append(" - 定时发布: 定时发布时间(格式:yyyy-MM-dd HH:mm)") self.log_text.append(" - 间隔时间: 在上一条基础上延迟的分钟数(无定时时间时生效)") self.log_text.append(" - 达人链接: 达人主页链接") self.log_text.append(" - 执行人: 负责人") self.log_text.append(" - 情况: 执行状态(待执行/执行中/已完成/失败)") except Exception as e: self._show_infobar("error", "错误", f"保存模板失败: {str(e)}") logger.error(f"保存模板失败: {e}") def import_excel(self): """导入Excel配置文件(直接弹出文件选择对话框)""" # 弹出文件选择对话框 excel_path, _ = QFileDialog.getOpenFileName( self, "选择Excel配置文件", "", "Excel文件 (*.xlsx *.xls)" ) if not excel_path: return # 用户取消选择 # 显示选择的文件路径 self.excel_path_input.setText(excel_path) try: # 读取Excel文件,添加更多异常处理 try: df = pd.read_excel(excel_path) except FileNotFoundError: self._show_infobar("error", "错误", f"找不到文件: {excel_path}") logger.error(f"文件不存在: {excel_path}") return except PermissionError: self._show_infobar("error", "错误", f"文件被占用,请关闭Excel文件后重试") logger.error(f"文件被占用: {excel_path}") return except Exception as e: self._show_infobar("error", "错误", f"读取Excel文件失败: {str(e)}") logger.error(f"读取Excel失败: {e}") return if df.empty: self._show_infobar("warning", "警告", "Excel文件为空") return # 检查必需的列 required_columns = ['多多id', '序号', '话题', '定时发布', '间隔时间', '达人链接', '执行人', '情况'] missing_columns = [col for col in required_columns if col not in df.columns] if missing_columns: self._show_infobar( "warning", "警告", f"Excel缺少列: {', '.join(missing_columns)}" ) return # 清空所有状态,根据新的Excel配置表格为准(完全重置,不保留任何上一次的数据) # 1. 清空配置列表 self.configs = [] # 2. 重置运行状态变量 self.running_total = 0 self.running_done = 0 # 3. 清空批量任务队列 self.batch_task_queue = [] self.current_batch_task_index = 0 # 4. 清空行映射(用于状态回写) self._row_map_by_user_index = {} # 5. 重置所有计数变量(确保导入新Excel时完全清空上一次的统计) self.batch_success_count = 0 self.batch_failed_count = 0 self.batch_total_tasks = 0 self.batch_processed = 0 self.batch_pending_tasks = 0 # 6. 重置所有状态卡片显示(全部归零) self.set_status_cards(update_text="未更新", pending=0, running=0, success=0, failed=0) # 7. 重置进度条 if hasattr(self, "status_running_progress"): self.status_running_progress.setValue(0) if hasattr(self, "progress_bar"): self.progress_bar.setValue(0) self.progress_bar.setVisible(False) # 8. 重置“更新数据”提示文本 if hasattr(self, "update_status_label"): self.update_status_label.setText("未更新") self.update_status_label.setStyleSheet("color: #666; font-size: 10px;") # 转换为配置列表,添加异常处理 for idx, row in df.iterrows(): try: # 处理间隔时间:保持原始状态,空值保持为空字符串 interval_raw = row.get('间隔时间') if pd.notna(interval_raw) and str(interval_raw).strip() != '': try: interval_value = int(float(interval_raw)) # 先转float再转int,处理小数情况 except (ValueError, TypeError): interval_value = '' # 无效值保持为空 else: interval_value = '' # 空值保持为空 # 辅助函数:将可能带.0的数字字符串转为整数型字符串 def clean_str(val): if pd.isna(val): return '' s = str(val).strip() if s.endswith('.0'): return s[:-2] return s # 导入新Excel时,强制将所有状态重置为"待执行",根据新的Excel配置表格为准 # 忽略Excel中"情况"列的旧状态值,统一重置为"待执行" status_value = '待执行' config = { '勾选': False, # 导入后默认不勾选,交由用户选择 '多多id': clean_str(row.get('多多id', '')), '序号': clean_str(row.get('序号', '')), '话题': str(row.get('话题', '')).strip() if pd.notna(row.get('话题')) else '', '定时发布': str(row.get('定时发布', '')).strip() if pd.notna(row.get('定时发布')) else '', '间隔时间': interval_value, '达人链接': str(row.get('达人链接', '')).strip() if pd.notna(row.get('达人链接')) else '', '执行人': str(row.get('执行人', '')).strip() if pd.notna(row.get('执行人')) else '', '情况': status_value, # 强制重置为"待执行",根据新的Excel配置表格为准 '文件路径': '' # 文件路径字段初始为空,通过更新数据按钮填充 } self.configs.append(config) except Exception as e: logger.warning(f"处理第 {idx + 1} 行数据时出错: {e}") continue # 跳过有问题的行 if not self.configs: self._show_infobar("warning", "警告", "未能解析出任何有效配置") return # 清除排序状态,保持Excel原始顺序 self.table_sort_keys = [] self.config_table.horizontalHeader().setSortIndicator(-1, Qt.SortOrder.AscendingOrder) # 更新表格显示(跳过表格->configs同步,避免旧数据回写) try: self.update_table(skip_sync=True) except Exception as e: self._show_infobar("error", "错误", f"更新表格失败: {str(e)}") logger.error(f"更新表格失败: {e}") return # 导入后默认不勾选,确保全选状态为未选中 if hasattr(self, "table_select_all_checkbox"): self.table_select_all_checkbox.blockSignals(True) self.table_select_all_checkbox.setChecked(False) self.table_select_all_checkbox.blockSignals(False) # 显示表格 self.table_group.setVisible(True) # 更新状态统计(基于新导入的配置重新计算) self._update_status_statistics() # 确保更新状态显示为"未更新"(文件路径还未更新) self.set_status_cards(update_text="未更新") self._show_infobar("success", "成功", f"成功导入 {len(self.configs)} 条配置,所有状态已清空重置") except Exception as e: error_msg = f"导入Excel文件失败: {str(e)}" self._show_infobar("error", "错误", error_msg) logger.error(f"导入Excel失败: {e}", exc_info=True) def add_single_config(self): """添加单条配置到配置列表""" try: # 获取输入框的值 user_id = self.add_user_id_input.text().strip() index = self.add_index_input.text().strip() topic = self.add_topic_input.text().strip() schedule = self.add_schedule_input.text().strip() interval_text = self.add_interval_input.text().strip() expert_link = self.add_expert_link_input.text().strip() executor = self.add_executor_input.text().strip() # 验证必填字段 if not user_id: self._show_infobar("warning", "警告", "请输入多多ID") self.add_user_id_input.setFocus() return if not index: self._show_infobar("warning", "警告", "请输入序号") self.add_index_input.setFocus() return # 处理间隔时间 interval_value = '' if interval_text: try: interval_value = int(float(interval_text)) except ValueError: self._show_infobar("warning", "警告", "间隔时间必须是数字") self.add_interval_input.setFocus() return # 创建配置对象 config = { '勾选': False, '多多id': user_id, '序号': index, '话题': topic, '定时发布': schedule, '间隔时间': interval_value, '达人链接': expert_link, '执行人': executor, '情况': '待执行', '文件路径': '' } # 添加到配置列表 self.configs.append(config) # 更新表格显示 self.update_table() # 显示表格 self.table_group.setVisible(True) # 更新状态统计 self._update_status_statistics() self.set_status_cards(update_text="未更新") # 清空输入框 self.clear_add_config_inputs() self._show_infobar("success", "成功", f"已添加配置: {user_id} - {index}") except Exception as e: error_msg = f"添加配置失败: {str(e)}" self._show_infobar("error", "错误", error_msg) logger.error(f"添加配置失败: {e}", exc_info=True) def clear_add_config_inputs(self): """清空单条配置输入框""" self.add_user_id_input.clear() self.add_index_input.clear() self.add_topic_input.clear() self.add_schedule_input.clear() self.add_interval_input.clear() self.add_expert_link_input.clear() self.add_executor_input.clear() # 设置焦点到第一个输入框 self.add_user_id_input.setFocus() def update_table(self, skip_sync=False): """更新配置表格。skip_sync=True 时跳过 表格→configs 同步(如刚由更新数据写入 configs 后刷新表格)。""" if getattr(self, "config_table", None) is None: return if not self.use_model_view and not skip_sync: self._sync_configs_from_table() self.is_updating_table = True # 使用筛选后的索引或全部索引进行分页(筛选在全量数据上计算,分页只切当前页) effective_indices = self.filtered_config_indices if getattr(self, 'filtered_config_indices', None) is not None else list(range(len(self.configs))) total_rows = len(effective_indices) # 设置最小显示行数,即使没有数据也显示空行 min_display_rows = 15 # 最少显示15行 if len(self.configs) > 1000: self._setup_model_view() self.is_updating_table = False return self.use_model_view = False if getattr(self, "table_view", None) is not None: self.table_view.setVisible(False) self.config_table.setVisible(True) for btn in [getattr(self, "page_first_btn", None), getattr(self, "page_prev_btn", None), getattr(self, "page_next_btn", None), getattr(self, "page_last_btn", None), getattr(self, "page_size_combo", None)]: if btn is not None: btn.setEnabled(True) total_pages = max(1, (total_rows + self.page_size - 1) // self.page_size) if self.current_page > total_pages: self.current_page = total_pages start = (self.current_page - 1) * self.page_size end = min(start + self.page_size, total_rows) self.page_row_indices = list(effective_indices[start:end]) # 临时禁用排序,防止填充数据时自动排序打乱顺序 self.config_table.setSortingEnabled(False) # 筛选时只显示当前页数据行,不预留空行,避免“仅显示匹配项”时仍看到多余行 is_filtered = getattr(self, 'filtered_config_indices', None) is not None display_rows = len(self.page_row_indices) if is_filtered else max(len(self.page_row_indices), min_display_rows) self.config_table.setRowCount(display_rows) # 先统一设置行高,再填数据,避免后设行高导致勾选框列布局重算、出现“除第一行外往下移” default_row_height = self.config_table.verticalHeader().defaultSectionSize() for row in range(display_rows): self.config_table.setRowHeight(row, default_row_height) # 先清除第 0 列所有单元格控件,避免连续刷新时旧勾选框残留导致错位 for row in range(display_rows): self.config_table.removeCellWidget(row, 0) # 填充期间禁止表格重绘,避免中间布局导致勾选框错位 self.config_table.setUpdatesEnabled(False) try: # 填充数据行 for table_row, config_index in enumerate(self.page_row_indices): config = self.configs[config_index] # 第0列:勾选框(传入行高,固定高度+居中对齐,避免第一页第二行等错位) self._set_checkbox_item(table_row, config_index, default_row_height) # 其他列的索引均+1 self.config_table.setItem(table_row, 1, self._create_centered_item(str(config.get('多多id', '')))) self.config_table.setItem(table_row, 2, self._create_centered_item(str(config.get('序号', '')))) self.config_table.setItem(table_row, 3, self._create_centered_item(str(config.get('话题', '')))) self.config_table.setItem(table_row, 4, self._create_centered_item(str(config.get('定时发布', '')))) # 间隔时间:保持原始状态,空值显示为空 interval_val = config.get('间隔时间', '') if interval_val == '' or interval_val is None: interval_display = '' else: interval_display = str(interval_val) self.config_table.setItem(table_row, 5, self._create_centered_item(interval_display)) url_text = str(config.get('达人链接', '')) url_item = self._create_centered_item(url_text) url_item.setToolTip(url_text) self.config_table.setItem(table_row, 6, url_item) self.config_table.setItem(table_row, 7, self._create_centered_item(str(config.get('执行人', '')))) self._set_status_item(table_row, str(config.get('情况', '待执行'))) file_path = str(config.get('文件路径', '')) file_path_item = self._create_centered_item(file_path) file_path_item.setToolTip(file_path) self.config_table.setItem(table_row, 9, file_path_item) self._set_progress_item(table_row, str(config.get('情况', '待执行'))) self._set_action_buttons(table_row, config_index) finally: self.config_table.setUpdatesEnabled(True) # 强制表格立即完成布局,避免第一页第二行等勾选框错位 self.config_table.doItemsLayout() # 重新启用排序功能(但不会自动排序已填充的数据) self.config_table.setSortingEnabled(True) # 固定列宽(不随内容自适应) self._apply_table_column_widths() # 更新状态卡片统计(只统计真实数据) self._update_status_statistics() if getattr(self, "table_empty_label", None) is not None: self.table_empty_label.setVisible(False) if getattr(self, "page_info_label", None) is not None: self.page_info_label.setText(f"第 {self.current_page}/{total_pages} 页") self.is_updating_table = False self._update_checked_count() def _setup_model_view(self): """切换到大数据量 Model/View 模式""" headers = MODEL_VIEW_HEADERS if self.table_model is None: self.table_model = ConfigTableModel(self.configs, headers, self) self.table_proxy = QSortFilterProxyModel(self) self.table_proxy.setSourceModel(self.table_model) self.table_proxy.setFilterKeyColumn(-1) self.table_view.setModel(self.table_proxy) self.table_delegate = TableActionDelegate(self.table_view, self._edit_row_from_view, self._delete_row_from_view) self.table_view.setItemDelegate(self.table_delegate) header = self.table_view.horizontalHeader() header.setStretchLastSection(True) header.setSectionResizeMode(QHeaderView.Interactive) # 连接列宽调整信号,用于跟踪手动调整和自动调整 header.sectionResized.connect(self.on_table_view_column_resized) header.geometriesChanged.connect(self.on_table_view_geometry_changed) self._apply_table_view_column_widths() else: self.table_model.update_data(self.configs) self.use_model_view = True self.table_view.setVisible(True) self.config_table.setVisible(False) if hasattr(self, "page_first_btn"): for btn in [self.page_first_btn, self.page_prev_btn, self.page_next_btn, self.page_last_btn, self.page_size_combo]: btn.setEnabled(False) if hasattr(self, "table_empty_label"): self.table_empty_label.setVisible(len(self.configs) == 0) if hasattr(self, "page_info_label"): total_rows = len(self.configs) self.page_info_label.setText(f"大数据模式:{total_rows} 行") self._update_checked_count() self._show_infobar("warning", "提示", "数据量较大,已切换到Model/View模式(部分功能受限)") def _edit_row_from_view(self, index): """Model/View 编辑行""" if not index.isValid(): return edit_index = index.sibling(index.row(), 0) self.table_view.setCurrentIndex(edit_index) self.table_view.edit(edit_index) def _delete_row_from_view(self, index): """Model/View 删除行""" if not index.isValid(): return src_index = self.table_proxy.mapToSource(index) if src_index.isValid(): self.table_model.removeRows(src_index.row(), 1) def get_config_from_table(self, row_index=0): """从表格中获取指定行的配置数据(使用表格中修改后的值)""" if row_index >= self.config_table.rowCount(): return None def get_cell_text(row, col): """安全获取单元格文本""" item = self.config_table.item(row, col) return item.text().strip() if item else '' def get_cell_int_or_empty(row, col): """安全获取单元格整数或空字符串""" item = self.config_table.item(row, col) if item and item.text().strip(): try: return int(item.text().strip()) except ValueError: return '' # 无效值返回空字符串 return '' # 空值返回空字符串 config = { '多多id': get_cell_text(row_index, 1), # 第1列 '序号': get_cell_text(row_index, 2), # 第2列 '话题': get_cell_text(row_index, 3), # 第3列 '定时发布': get_cell_text(row_index, 4), # 第4列 '间隔时间': get_cell_int_or_empty(row_index, 5), # 第5列,空值保持为空 '达人链接': get_cell_text(row_index, 6), # 第6列 '执行人': get_cell_text(row_index, 7), # 第7列 '情况': get_cell_text(row_index, 8) or '待执行', # 第8列 '文件路径': get_cell_text(row_index, 9) # 第9列是文件路径 } return config @staticmethod def get_config(): """获取当前配置数据(已废弃,配置从Excel导入)""" # 配置现在从Excel导入,此方法返回None表示需要先导入Excel return None def update_data(self): """更新数据:找出文件并保存到各行的文件路径列""" # 防止连续点击导致重入,避免勾选框错位 if getattr(self, '_update_data_running', False): return try: self._update_data_running = True if hasattr(self, 'update_data_btn'): self.update_data_btn.setEnabled(False) if not self.configs: self._show_infobar("warning", "提示", "请先导入Excel配置文件") return # 获取文件夹路径 folder_path = self.folder_path_input.text().strip() if not folder_path: folder_path = get_default_folder_path() if not os.path.exists(folder_path): self._show_infobar("warning", "警告", f"文件夹路径不存在: {folder_path}") return self.log_text.append("=" * 50) self.log_text.append("开始批量更新文件路径...") self.log_text.append(f"共有 {len(self.configs)} 行配置需要处理") self.log_text.append(f"搜索根目录: {folder_path} (结构: 根目录/多多ID/序号(-xxx).mp4 或 序号(-xxx)/)") total_found_files = 0 rows_with_files = 0 skip_no_id = 0 no_match_log = [] # 标记正在更新,防止触发信号 self.is_updating_table = True # 遍历所有配置项进行更新 for config_idx, config in enumerate(self.configs): user_id = str(config.get('多多id', '')).strip() index = str(config.get('序号', '')).strip() if not user_id or not index: skip_no_id += 1 continue found_files = self._find_files_for_config(config, folder_path) if found_files: file_paths_str = "; ".join([str(f['path']) for f in found_files]) config['文件路径'] = file_paths_str rows_with_files += 1 total_found_files += len(found_files) else: config['文件路径'] = "" no_match_log.append(f"多多ID={user_id} 序号={index}") # 更新 UI 表格显示(跳过 sync:configs 已由更新数据写入,勿用表格回写覆盖) if self.use_model_view: if self.table_model: self.table_model.layoutChanged.emit() else: self.update_table(skip_sync=True) # 结束更新 self.is_updating_table = False if skip_no_id: self.log_text.append(f"跳过 {skip_no_id} 行(多多ID或序号为空)") if no_match_log: self.log_text.append(f"未匹配到文件 {len(no_match_log)} 行:") for s in no_match_log[:20]: self.log_text.append(f" · {s}") if len(no_match_log) > 20: self.log_text.append(f" ... 还有 {len(no_match_log) - 20} 行") total_rows = len(self.configs) self.log_text.append("=" * 50) self.log_text.append(f"更新完成!数据 {total_rows} 条,文件 {total_found_files} 个,匹配 {rows_with_files} 条") status_msg = f"数据 {total_rows} 条,文件 {total_found_files} 个,匹配 {rows_with_files} 条" self.update_status_label.setText(status_msg) self.update_status_label.setStyleSheet("color: #4CAF50; font-size: 10px;") self.set_status_cards(update_text=status_msg) self._update_status_statistics() # 更新映射 self._rebuild_row_map() # 计算并应用间隔时间规则 self.log_text.append("=" * 50) self.log_text.append("开始计算间隔时间并更新定时发布时间...") configs_with_rows = [] for i, cfg in enumerate(self.configs): configs_with_rows.append({"config_index": i, "config": cfg}) updated_count = self._apply_schedule_intervals(configs_with_rows) if updated_count > 0: self.log_text.append(f"✓ 已自动计算并更新 {updated_count} 行的定时发布时间") if not self.use_model_view: self.update_table(skip_sync=True) elif self.table_model: self.table_model.layoutChanged.emit() self.log_text.append("=" * 50) self._show_infobar("success", "成功", f"数据 {total_rows} 条,文件 {total_found_files} 个,匹配 {rows_with_files} 条") return except Exception as e: error_msg = f"更新数据失败: {str(e)}" self.log_text.append(error_msg) self._show_infobar("error", "错误", error_msg) logger.error(f"更新数据失败: {e}") import traceback traceback.print_exc() finally: self._update_data_running = False if hasattr(self, 'update_data_btn'): self.update_data_btn.setEnabled(True) @staticmethod def _index_matches_name(index, name_or_stem): """序号与文件名/文件夹名是否匹配。支持:序号、序号.mp4、序号-xxx、01 与 1 数字等价。""" if not name_or_stem or not index: return False s = str(name_or_stem).strip() if s == index: return True if s.startswith(index + "-"): return True parts = s.split("-") part0 = parts[0].strip() if parts else "" if part0 == index: return True try: if int(s) == int(index): return True if part0 and int(part0) == int(index): return True except (ValueError, TypeError): pass return False def _find_files_for_config(self, config, folder_path): """根据配置查找文件(辅助方法)。目录结构:文件夹路径/多多ID/[(子目录)/] 序号(-xxx).mp4 或 序号(-xxx)/""" found_files = [] if not config: logger.warning("配置为空,无法查找文件") return found_files if not folder_path or not os.path.exists(folder_path): logger.warning(f"文件夹路径无效或不存在: {folder_path}") return found_files index = str(config.get('序号', '')).strip() if not index: logger.warning("序号为空,无法查找文件") return found_files user_id = str(config.get('多多id', '')).strip() if not user_id: logger.warning("多多ID为空,无法查找文件") return found_files video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'] try: if not os.access(folder_path, os.R_OK): logger.error(f"没有权限读取文件夹: {folder_path}") return found_files try: sub_dirs = [entry for entry in os.listdir(folder_path) if os.path.isdir(os.path.join(folder_path, entry))] except (PermissionError, OSError) as e: logger.error(f"读取文件夹失败: {e}") return found_files target_subdir = None for subdir_name in sub_dirs: if subdir_name == user_id: target_subdir = os.path.join(folder_path, subdir_name) break if not target_subdir or not os.path.exists(target_subdir): return found_files def append_file(fpath): fpath_obj = Path(fpath) if not fpath_obj.exists(): return if fpath_obj.is_file(): if any(fpath_obj.suffix.lower() == ext for ext in video_extensions): found_files.append({ "url": config.get('达人链接', ''), "user_id": user_id, "time_start": config.get('定时发布', '') if config.get('定时发布') else None, "ht": config.get('话题', ''), "index": index, "path": fpath_obj }) elif fpath_obj.is_dir(): found_files.append({ "url": config.get('达人链接', ''), "user_id": user_id, "time_start": config.get('定时发布', '') if config.get('定时发布') else None, "ht": config.get('话题', ''), "index": index, "path": fpath_obj }) search_dirs = [target_subdir] try: for sub in os.listdir(target_subdir): p = os.path.join(target_subdir, sub) if os.path.isdir(p): search_dirs.append(p) except (PermissionError, OSError): pass for search_dir in search_dirs: try: dir_items = os.listdir(search_dir) except (PermissionError, OSError): continue for item_name in dir_items: if item_name.startswith('.'): continue item_path = os.path.join(search_dir, item_name) try: path_obj = Path(item_path) if path_obj.is_file(): base = path_obj.stem else: base = item_name if not self._index_matches_name(index, base): continue append_file(item_path) except (OSError, ValueError) as e: logger.warning(f"处理 {item_name} 时出错: {e}") continue except (OSError, ValueError) as e: logger.error(f"查找文件失败: {e}", exc_info=True) return found_files def execute_task(self): """执行任务""" try: # 检查是否有Excel导入的配置 if self.configs: # 检查用户是否已经勾选了任何项 has_user_selection = any(cfg.get('勾选', False) for cfg in self.configs) # 只有当用户没有勾选任何项时,才自动勾选失败项 if not has_user_selection: failed_indices = [] for idx, cfg in enumerate(self.configs): status = cfg.get('情况', '') if "失败" in status or "错误" in status or "过期" in status or "跳过" in status: failed_indices.append(idx) if failed_indices: for idx, cfg in enumerate(self.configs): cfg['勾选'] = idx in failed_indices self.update_table() self._show_infobar("info", "提示", f"检测到 {len(failed_indices)} 条失败记录,已自动仅选择失败项") # 如果有Excel配置,批量处理 self.execute_batch_from_excel() return # 批量处理完成后直接返回 # 没有Excel导入的配置,提示用户先导入 self._show_infobar("warning", "提示", "请先导入Excel配置文件") return except Exception as e: import traceback error_detail = traceback.format_exc() logger.error(f"执行任务失败: {e}\n{error_detail}") try: self._show_infobar("error", "错误", f"执行任务失败: {str(e)}") self.log_text.append(f"执行任务失败: {str(e)}") self.log_text.append(f"错误详情: {error_detail}") except: pass self._set_task_buttons_state('idle') self.progress_bar.setVisible(False) def execute_batch_from_excel(self): """从 Excel配置批量执行(自动判断相同多多ID的mp4文件,批量上传)""" # 执行前强制同步当前页数据,确保手动修改的内容被包含 if not self.use_model_view: self._sync_configs_from_table() # 从表格当前页勾选框同步勾选状态到 configs,确保执行的是用户当前勾选的行 self._sync_checked_from_table() # 开始新任务时,从当前数据中统计已有的成功/失败/待执行数量(累计历史数据) existing_success = 0 existing_failed = 0 existing_pending = 0 for config in self.configs: status = config.get('情况', '待执行') if "完成" in status or "成功" in status: existing_success += 1 elif "失败" in status or "错误" in status or "过期" in status or "跳过" in status: existing_failed += 1 existing_pending += 1 # 失败的也算待执行 elif "待" in status or not status: existing_pending += 1 self.batch_success_count = existing_success self.batch_failed_count = existing_failed self.set_status_cards(pending=existing_pending, running=0, success=existing_success, failed=existing_failed) # 获取文件夹路径 folder_path = self.folder_path_input.text().strip() if not folder_path: folder_path = get_default_folder_path() if not os.path.exists(folder_path): self._show_infobar("warning", "警告", f"文件夹路径不存在: {folder_path}") return # 筛选勾选的待处理配置(直接从 self.configs 获取最新数据) checked_indices = [i for i, cfg in enumerate(self.configs) if cfg.get('勾选', False)] if not checked_indices: self._show_infobar("warning", "提示", "请先勾选需要上传的数据行") return # 分析勾选项 all_configs_with_files = [] configs_to_process = [] # 用于计算间隔时间 for idx in checked_indices: config = self.configs[idx] # 跳过状态为「已完成」的任务(仅上传勾选且未完成的项) status = (config.get('情况') or '').strip() if '已完成' in status: continue # 验证必填 if not config.get('多多id') or not config.get('序号'): continue configs_to_process.append({"config_index": idx, "config": config}) if not configs_to_process: self._show_infobar("warning", "提示", "所选项已完成或数据不完整") return # 重置所有要处理的配置状态为"待执行"(确保第二次运行时清除旧状态) for item in configs_to_process: idx = item["config_index"] self.configs[idx]['情况'] = '待执行' self._update_table_status(idx, "待执行", is_config_index=True) # 重置任务状态后,重新统计成功/失败/待执行数量以保持一致性 existing_success = 0 existing_failed = 0 existing_pending = 0 for config in self.configs: status = config.get('情况', '待执行') if "完成" in status or "成功" in status: existing_success += 1 elif "失败" in status or "错误" in status or "过期" in status or "跳过" in status: existing_failed += 1 existing_pending += 1 # 失败的也算待执行 elif "待" in status or not status: existing_pending += 1 self.batch_success_count = existing_success self.batch_failed_count = existing_failed self.set_status_cards(pending=existing_pending, success=existing_success, failed=existing_failed) # 注意:不再在此处调用 _apply_schedule_intervals # 间隔时间计算已在"更新数据"按钮中完成,用户可能在之后手动修改了时间,应保留用户修改 self.log_text.append("=" * 50) self.log_text.append(f"开始分析配置,准备批量上传...") for item in configs_to_process: idx = item["config_index"] config = item["config"] # 从已保存的文件路径中获取文件(由"更新数据"按钮写入) file_path_str = config.get('文件路径', '').strip() found_files = [] if file_path_str: file_paths = [p.strip() for p in file_path_str.split(';') if p.strip()] for fp in file_paths: if os.path.exists(fp): found_files.append({ "url": config.get('达人链接', ''), "user_id": config.get('多多id', ''), "time_start": config.get('定时发布', '') if config.get('定时发布') else None, "ht": config.get('话题', ''), "index": config.get('序号', ''), "path": Path(fp) }) else: self.log_text.append(f" ⚠ 文件路径不存在,已跳过: {fp}") if not found_files and not file_path_str: self.log_text.append(f" 索引 {idx+1} ({config.get('多多id')}): 未找到文件路径,请先点击'更新数据'按钮") if found_files: all_configs_with_files.append({ 'config': config, 'files': found_files, 'config_index': idx }) self.log_text.append(f"索引 {idx+1} ({config.get('多多id')}): 准备上传 {len(found_files)} 个项目") if not all_configs_with_files: self._show_infobar("warning", "警告", "未找到任何匹配文件,请先点击‘更新数据’按钮") return total_tasks = sum(len(item['files']) for item in all_configs_with_files) self.set_status_cards(pending=total_tasks) self.set_running_progress(0, total_tasks) # 按多多ID分组任务 from collections import defaultdict grouped_by_user_id = defaultdict(list) for item in all_configs_with_files: user_id = item['config'].get('多多id', '') grouped_by_user_id[user_id].append(item) # 检查线程 if self.worker_thread and self.worker_thread.isRunning(): self._show_infobar("warning", "警告", "已有任务正在执行,请等待完成") return # 重置暂停/终止状态 self._is_paused = False self._is_terminated = False self._set_task_buttons_state('running') self.progress_bar.setVisible(True) self.progress_bar.setValue(0) # 构建任务队列 self.batch_task_queue = [] self.current_batch_task_index = 0 self.batch_total_tasks = total_tasks self.batch_processed = 0 self.batch_pending_tasks = total_tasks video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'] # 获取用户是否勾选了"批量上传"复选框 use_batch_upload = self.batch_upload_checkbox.isChecked() self.log_text.append(f"批量上传模式: {'已勾选' if use_batch_upload else '未勾选'}") # 获取最大批量数(单次上限数) batch_limit = 5 # 默认值 try: batch_limit_text = self.batch_limit_input.text().strip() if batch_limit_text: batch_limit = int(batch_limit_text) if batch_limit < 1: batch_limit = 1 except (ValueError, AttributeError): batch_limit = 5 self.log_text.append(f"单次上限数: {batch_limit}") def _skip_expired_and_append(file_info, file_index, group_items, _cfg_indices, into_videos, into_non_videos, file_is_video): """若定时过期则更新状态并返回 True(失败(定时过期)),否则加入列表并返回 False""" f_time_start = file_info.get('time_start', '') if self._is_schedule_time_expired(f_time_start): self.log_text.append(f" 跳过序号 {file_index}:定时发布时间已过期 ({f_time_start})") expire_idx = next((it['config_index'] for it in group_items if it['config'].get('序号') == file_index), None) if expire_idx is not None: self._update_table_status(expire_idx, "失败(定时过期)", is_config_index=True) self.configs[expire_idx]['情况'] = '失败(定时过期)' self.batch_failed_count = getattr(self, "batch_failed_count", 0) + 1 self.set_status_cards(failed=self.batch_failed_count) return True if file_is_video: into_videos.append(file_info) else: into_non_videos.append(file_info) return False # 按 config 顺序确定多多ID 的迭代顺序(先出现的多多ID 先处理) def _first_config_index(pair): return min(it['config_index'] for it in pair[1]) sorted_groups = sorted(grouped_by_user_id.items(), key=_first_config_index) for user_id, items in sorted_groups: all_files = [] related_config_indices = [] for item in items: all_files.extend(item['files']) if item['config_index'] not in related_config_indices: related_config_indices.append(item['config_index']) def get_sort_key(file_entry): try: return int(file_entry.get('index', '0')) except (ValueError, TypeError): return 0 all_files_sorted = sorted(all_files, key=get_sort_key) first_config = items[0].get('config', {}) if not use_batch_upload: # 未勾选批量上传:按序号一条一条上传,先完成当前多多ID的 1,2,3,4 再处理下一个多多ID i = 0 while i < len(all_files_sorted): current_file = all_files_sorted[i] is_video = current_file['path'].is_file() and any( current_file['path'].suffix.lower() == ext for ext in video_extensions) f_index = current_file.get('index', '') if _skip_expired_and_append(current_file, f_index, items, related_config_indices, [], [], is_video): i += 1 continue matching_idx = next( (it['config_index'] for it in items if it['config'].get('序号') == f_index), related_config_indices[0] if related_config_indices else 0) matching_config = next( (it['config'] for it in items if it['config'].get('序号') == f_index), first_config) if is_video: self.batch_task_queue.append({ 'type': 'single_video', 'config': matching_config, 'files': [current_file], 'user_id': user_id, 'count': 1, 'config_indices': [matching_idx] }) else: self.batch_task_queue.append({ 'type': 'image_folder', 'config': matching_config, 'files': [current_file], 'user_id': user_id, 'count': 1, 'config_indices': [matching_idx] }) i += 1 else: # 勾选批量上传:严格按序号顺序处理。遇到图文立即上传;第一次遇到视频时,收集该多多ID下全部视频按上限分批上传,再继续后续序号(图文照常上传) ordered_list = [] # (f, is_video) 按序号顺序,已排除定时过期 for f in all_files_sorted: is_video = f['path'].is_file() and any( f['path'].suffix.lower() == ext for ext in video_extensions) f_index = f.get('index', '') if _skip_expired_and_append(f, f_index, items, related_config_indices, [], [], is_video): continue ordered_list.append((f, is_video)) videos_in_order = [entry for (entry, iv) in ordered_list if iv] videos_emitted = False for (f, is_video) in ordered_list: f_index = f.get('index', '') if not is_video: matching_idx = next( (it['config_index'] for it in items if it['config'].get('序号') == f_index), related_config_indices[0]) matching_config = next( (it['config'] for it in items if it['config'].get('序号') == f_index), first_config) self.batch_task_queue.append({ 'type': 'image_folder', 'config': matching_config, 'files': [f], 'user_id': user_id, 'count': 1, 'config_indices': [matching_idx] }) else: if not videos_emitted: for start in range(0, len(videos_in_order), batch_limit): chunk = videos_in_order[start:start + batch_limit] batch_config_indices = [] for fc in chunk: fi = fc.get('index', '') match_idx = next((it['config_index'] for it in items if it['config'].get('序号') == fi), None) if match_idx is not None and match_idx not in batch_config_indices: batch_config_indices.append(match_idx) if len(chunk) > 1: self.batch_task_queue.append({ 'type': 'batch_video', 'config': first_config, 'files': chunk, 'user_id': user_id, 'count': len(chunk), 'config_indices': batch_config_indices }) else: vid_index = chunk[0].get('index', '') matching_config = next( (it['config'] for it in items if it['config'].get('序号') == vid_index), first_config) self.batch_task_queue.append({ 'type': 'single_video', 'config': matching_config, 'files': chunk, 'user_id': user_id, 'count': 1, 'config_indices': batch_config_indices }) videos_emitted = True # 已在该序号前统一发过视频批次,此处跳过 if self.batch_task_queue: self._process_next_batch_task() else: self._show_infobar("warning", "警告", "任务分析失败,未构建有效队列") self._set_task_buttons_state('idle') self.progress_bar.setVisible(False) def _cleanup_worker_thread(self): """安全清理工作线程""" if not self.worker_thread: return try: # 如果线程正在运行,请求停止 if self.worker_thread.isRunning(): # 请求停止(优雅方式) self.worker_thread.stop() self.worker_thread.requestInterruption() # 等待线程结束,设置合理超时 if not self.worker_thread.wait(2000): logger.warning("工作线程未能在2秒内停止") # 不使用 terminate(),继续处理 # 安全断开所有信号连接 try: self.worker_thread.finished.disconnect() except (TypeError, RuntimeError): pass # 信号未连接或已断开 try: self.worker_thread.log_message.disconnect() except (TypeError, RuntimeError): pass try: self.worker_thread.progress.disconnect() except (TypeError, RuntimeError): pass try: self.worker_thread.item_result.disconnect() except (TypeError, RuntimeError): pass except Exception as e: logger.warning(f"清理工作线程时出错: {e}") finally: self.worker_thread = None def _set_task_buttons_state(self, state): """设置任务控制按钮的显示状态 state: 'idle' | 'running' | 'paused' """ if state == 'idle': self.execute_btn.setEnabled(True) self.pause_btn.setVisible(False) self.resume_btn.setVisible(False) self.terminate_btn.setVisible(False) elif state == 'running': self.execute_btn.setEnabled(False) self.pause_btn.setVisible(True) self.resume_btn.setVisible(False) self.terminate_btn.setVisible(True) elif state == 'paused': self.execute_btn.setEnabled(False) self.pause_btn.setVisible(False) self.resume_btn.setVisible(True) self.terminate_btn.setVisible(True) def pause_task(self): """暂停任务:当前正在执行的任务会完成,但不再启动下一个""" self._is_paused = True self._set_task_buttons_state('paused') self.log_text.append("=" * 50) self.log_text.append("⏸ 任务已暂停,当前正在执行的任务完成后将停止。点击「继续」恢复执行。") self._show_infobar("warning", "已暂停", "当前任务完成后将暂停,点击「继续」恢复") def resume_task(self): """继续执行暂停的任务""" self._is_paused = False self._set_task_buttons_state('running') self.log_text.append("=" * 50) self.log_text.append("▶ 任务已恢复,继续执行剩余任务...") self._show_infobar("success", "已恢复", "继续执行剩余任务") # 继续处理下一个任务 self._process_next_batch_task() def terminate_task(self): """终止所有任务""" reply = QMessageBox.question( self, "确认终止", "确定要终止所有任务吗?当前正在执行的任务将被中断。", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: return self._is_terminated = True self._is_paused = False self.log_text.append("=" * 50) self.log_text.append("⏹ 正在终止任务...") # 停止当前工作线程 self._cleanup_worker_thread() # 将队列中所有未执行的任务标记为"已终止" remaining = 0 for i in range(self.current_batch_task_index, len(self.batch_task_queue)): task = self.batch_task_queue[i] config_indices = task.get('config_indices', []) for cfg_idx in config_indices: try: if self.configs[cfg_idx].get('情况', '') in ('待执行', '执行中'): self.configs[cfg_idx]['情况'] = '已终止' self._update_table_status(cfg_idx, "已终止", is_config_index=True) remaining += 1 except (IndexError, KeyError): pass # 清空任务队列 self.batch_task_queue = [] self.current_batch_task_index = 0 # 重置 UI 状态 self._set_task_buttons_state('idle') self.progress_bar.setVisible(False) self._is_terminated = False self.log_text.append(f"⏹ 任务已终止。{remaining} 个待执行任务被取消。") self.log_text.append(f" 已完成: {self.batch_success_count} 成功, {self.batch_failed_count} 失败") self._update_status_statistics() self._show_infobar("warning", "已终止", f"任务已终止,{remaining} 个任务被取消") def _process_next_batch_task(self): """处理任务队列中的下一个任务(异步,不阻塞GUI)""" # 检查是否还有任务 if self.current_batch_task_index >= len(self.batch_task_queue): # 所有任务完成 self.progress_bar.setValue(100) self.log_text.append("=" * 50) self.log_text.append(f"所有任务执行完成!共处理 {self.batch_processed} 个文件/文件夹") self._set_task_buttons_state('idle') self.progress_bar.setVisible(False) self.set_running_progress(0, 0) # 更新状态统计(成功/失败/待执行数量) self._update_status_statistics() self._show_infobar("success", "任务完成", f"共处理 {self.batch_processed} 个文件/文件夹") # 重置任务队列 self.batch_task_queue = [] self.current_batch_task_index = 0 return # 获取当前任务 task = self.batch_task_queue[self.current_batch_task_index] task_type = task['type'] config = task['config'] files = task['files'] _ = task['user_id'] # 仅提取以验证存在性 # 根据任务类型处理 try: # 清理旧线程(如果存在) self._cleanup_worker_thread() # 验证 files 参数 if not files or not isinstance(files, list): raise ValueError(f"无效的 files 参数: {files}") # 确定是否为批量模式 is_batch = (task_type == 'batch_video' and len(files) > 1) # 创建并启动工作线程 self.worker_thread = WorkerThread(config, is_batch, files, self) # 连接信号 self.worker_thread.finished.connect(self._on_batch_task_finished) self.worker_thread.log_message.connect(self.log_text.append) self.worker_thread.progress.connect(self.progress_bar.setValue) self.worker_thread.item_result.connect(self._on_worker_item_result) # 记录任务信息 if task_type == 'batch_video': self.log_text.append(f"\n开始批量上传 {len(files)} 个视频文件...") elif task_type == 'single_video': self.log_text.append(f"\n开始上传单个视频文件...") elif task_type == 'image_folder': idx = task.get('index', 0) total = task.get('total', 0) folder_index = task.get('folder_index', '') self.log_text.append(f"\n开始上传第 {idx}/{total} 个图片文件夹(序号: {folder_index})...") # 更新表格状态为"执行中" try: config_indices = task.get('config_indices', []) if config_indices: for cfg_idx in config_indices: try: self._update_table_status(cfg_idx, "执行中", is_config_index=True) except Exception as e: logger.warning(f"更新状态失败(索引{cfg_idx}): {e}") except Exception as e: logger.warning(f"更新执行中状态失败: {e}") # 启动线程(不阻塞) try: self.worker_thread.start() except Exception as e: error_msg = f"启动线程失败: {str(e)}" logger.error(error_msg) self.log_text.append(f" ✗ {error_msg}") raise except Exception as e: error_msg = f"启动任务失败: {str(e)}" self.log_text.append(f" ✗ {error_msg}") logger.error(error_msg, exc_info=True) # 即使失败也继续下一个任务,立即更新表格为失败并增加失败条数 count = task.get('count', 1) config_indices = task.get('config_indices', []) for cfg_idx in config_indices: try: self._update_table_status(cfg_idx, "失败", is_config_index=True) except Exception: pass self.batch_failed_count += len(config_indices) if config_indices else 1 self.batch_processed += count self.batch_pending_tasks = max(self.batch_pending_tasks - count, 0) self.set_status_cards( pending=self.batch_pending_tasks, success=getattr(self, 'batch_success_count', 0), failed=getattr(self, 'batch_failed_count', 0) ) self.set_running_progress(self.batch_processed, self.batch_total_tasks) self.current_batch_task_index += 1 if not self._is_terminated and not self._is_paused: QApplication.processEvents() self._process_next_batch_task() def _on_batch_task_finished(self, success, message): """批量任务完成回调(不阻塞GUI)""" try: # 获取当前任务(注意:此时任务已完成,但索引还未更新) if self.current_batch_task_index < len(self.batch_task_queue): task = self.batch_task_queue[self.current_batch_task_index] task_type = task['type'] count = task.get('count', 1) config_indices = task.get('config_indices', []) # 更新进度日志 if success: if task_type == 'batch_video': self.log_text.append(f" ✓ 批量上传 {count} 个视频完成") elif task_type == 'single_video': self.log_text.append(f" ✓ 单个视频上传完成") elif task_type == 'image_folder': self.log_text.append(f" ✓ 图片文件夹上传完成") else: reason_text = f"失败原因: {message}" if message else "失败原因: 未知" self.log_text.append(f" ✗ 任务失败: {reason_text}") # 对于 batch_video 类型,状态和计数已经在 _on_worker_item_result 中实时更新了 # 所以这里不再重复更新,只需要处理 single_video 和 image_folder 类型 if task_type != 'batch_video': if success: # 更新表格为"已完成",并增加成功条数 for cfg_idx in config_indices: self._update_table_status(cfg_idx, "已完成", is_config_index=True) self.batch_success_count += len(config_indices) else: # 更新表格为"失败",并增加失败条数 for cfg_idx in config_indices: self._update_table_status(cfg_idx, "失败", is_config_index=True) self.batch_failed_count += len(config_indices) # 更新统计(single_video 和 image_folder 需要在这里更新) self.batch_processed += count self.batch_pending_tasks = max(self.batch_pending_tasks - count, 0) self.set_status_cards( pending=self.batch_pending_tasks, success=self.batch_success_count, failed=self.batch_failed_count ) self.set_running_progress(self.batch_processed, self.batch_total_tasks) # 移动到下一个任务 self.current_batch_task_index += 1 # 获取发布间隔时间(毫秒) interval_ms = self._get_publish_interval_ms() if interval_ms > 0 and self.current_batch_task_index < len(self.batch_task_queue): self.log_text.append(f" 等待 {interval_ms // 1000} 秒后执行下一个任务...") # 检查是否被终止 if self._is_terminated: return # 检查是否被暂停 if self._is_paused: self.log_text.append("⏸ 当前任务已完成,等待用户点击「继续」...") self._set_task_buttons_state('paused') return # 处理下一个任务(使用QTimer延迟,包含发布间隔时间) delay_ms = max(100, interval_ms) # 至少100ms确保GUI更新 QTimer.singleShot(delay_ms, self._process_next_batch_task) except Exception as e: logger.error(f"批量任务完成回调失败: {e}") import traceback traceback.print_exc() # 即使出错也继续处理下一个任务(除非已暂停或终止) if not self._is_terminated and not self._is_paused and self.current_batch_task_index < len(self.batch_task_queue): self.current_batch_task_index += 1 QTimer.singleShot(100, self._process_next_batch_task) def _get_publish_interval_ms(self): """获取发布间隔时间(毫秒)""" try: if not hasattr(self, 'publish_interval_input'): return 0 value_str = self.publish_interval_input.text().strip().lower() if not value_str or value_str == '0': return 0 # 解析时间格式:数字(秒)、30s(秒)、2m(分钟) if value_str.endswith('m') or value_str.endswith('分钟'): # 分钟 num_str = value_str.replace('m', '').replace('分钟', '').strip() return int(float(num_str) * 60 * 1000) elif value_str.endswith('s') or value_str.endswith('秒'): # 秒 num_str = value_str.replace('s', '').replace('秒', '').strip() return int(float(num_str) * 1000) else: # 默认为秒 return int(float(value_str) * 1000) except (ValueError, TypeError): return 0 def _rebuild_row_map(self): """重建 多多ID+序号 -> 表格行号 映射(仅针对当前页可见行)""" m = {} try: if not hasattr(self, "config_table") or self.use_model_view: self._row_map_by_user_index = {} return for r in range(self.config_table.rowCount()): # 多多ID在第1列,序号在第2列(第0列是勾选框) user_id = self.get_cell_text(r, 1) idx = self.get_cell_text(r, 2) if user_id and idx: m[(user_id, idx)] = r except Exception: pass self._row_map_by_user_index = m def _on_worker_item_result(self, payload): """接收自动化侧逐条结果 - 仅用于 batch_video 类型任务的实时状态更新""" try: if not isinstance(payload, dict): return user_id = str(payload.get("user_id", "")).strip() idx = str(payload.get("index", "")).strip() ok = bool(payload.get("ok")) reason = str(payload.get("reason", "")).strip() name = str(payload.get("name", "")).strip() if not user_id: return # 检查当前任务类型,只有 batch_video 类型才在这里更新计数 # single_video 和 image_folder 类型在 _on_batch_task_finished 中更新 is_batch_video_task = False if hasattr(self, 'current_batch_task_index') and self.current_batch_task_index < len(self.batch_task_queue): current_task = self.batch_task_queue[self.current_batch_task_index] is_batch_video_task = current_task.get('type') == 'batch_video' # 全局搜索匹配的多多ID和序号 found_config_idx = -1 updated_count = 0 # 记录更新了多少条配置 if idx: # 如果有序号,优先通过多多ID和序号匹配 for i, cfg in enumerate(self.configs): if str(cfg.get("多多id")) == user_id and str(cfg.get("序号")) == idx: found_config_idx = i break # 如果通过序号没找到,尝试通过多多ID匹配 if found_config_idx == -1: # 查找所有匹配多多ID的配置 matching_indices = [] for i, cfg in enumerate(self.configs): if str(cfg.get("多多id")) == user_id: matching_indices.append(i) # 如果只找到一个匹配的配置,直接使用它 if len(matching_indices) == 1: found_config_idx = matching_indices[0] # 如果有多个匹配的配置,且是批量视频任务,更新相关配置的状态 elif len(matching_indices) > 1 and is_batch_video_task: current_task = self.batch_task_queue[self.current_batch_task_index] task_config_indices = current_task.get('config_indices', []) # 只更新当前任务相关的配置状态 for cfg_idx in task_config_indices: if cfg_idx in matching_indices: self._update_table_status(cfg_idx, "已完成" if ok else "失败", is_config_index=True) updated_count += 1 found_config_idx = -2 # 标记为已处理(多个配置) if found_config_idx >= 0: # 单个配置匹配成功,更新表格状态 self._update_table_status(found_config_idx, "已完成" if ok else "失败", is_config_index=True) updated_count = 1 label = name if name else payload.get("path", "") or "" reason_text = f"失败原因: {reason}" if (not ok and reason) else reason self.log_text.append(f"[结果] {user_id}-{idx}: {'✓' if ok else '✗'} {label} {reason_text}") elif found_config_idx == -2: # 多个配置已处理,只记录日志 label = name if name else payload.get("path", "") or "" reason_text = f"失败原因: {reason}" if (not ok and reason) else reason self.log_text.append(f"[结果] {user_id}-{idx}: {'✓' if ok else '✗'} {label} {reason_text}") else: reason_text = f"失败原因: {reason}" if (not ok and reason) else reason self.log_text.append(f"[结果] {user_id}-{idx}: ok={ok} {reason_text} (未在列表中找到匹配项)") # 只有 batch_video 类型任务才在这里更新计数(实时反馈) # single_video 和 image_folder 在 _on_batch_task_finished 中更新,避免重复计数 if is_batch_video_task and updated_count > 0: if ok: self.batch_success_count += updated_count else: self.batch_failed_count += updated_count # 更新待处理数量 self.batch_pending_tasks = max(self.batch_pending_tasks - updated_count, 0) # 立即刷新状态卡片显示 self.set_status_cards( pending=self.batch_pending_tasks, success=self.batch_success_count, failed=self.batch_failed_count ) # 更新进度 self.batch_processed += updated_count self.set_running_progress(self.batch_processed, self.batch_total_tasks) except Exception as e: logger.warning(f"处理单条结果失败: {e}") @staticmethod def count_videos_in_folder(folder_path, index): """统计文件夹中匹配序号的视频文件数量(与main.py逻辑一致)""" count = 0 try: # 遍历最外层文件夹下的所有子文件夹(与main.py逻辑一致) for file in os.listdir(folder_path): # 获取文件夹下所有的文件夹 file_path = os.path.join(folder_path, file) # 拼接文件夹 # 检查是否为目录,跳过文件(如.lnk快捷方式) if not os.path.isdir(file_path): continue files = os.listdir(file_path) # 获取用户id下的文件 for file_name in files: # 检查是否是视频文件(.mp4) if ".mp4" in file_name: # 用"-"分割文件名,检查第一部分是否等于序号 # 文件名格式:4-茶叶蛋大冒险-.mp4 -> ['4', '茶叶蛋大冒险', '', 'mp4'] file_names = file_name.split("-") if len(file_names) > 0 and file_names[0] == str(index): path = Path(os.path.join(file_path, file_name)) # 判断是否为文件 if path.is_file(): count += 1 else: # 如果是文件夹,统计其中的文件 for sub_file in os.listdir(path): sub_path = Path(os.path.join(path, sub_file)) if sub_path.is_file(): count += 1 except (OSError, PermissionError) as e: logger.error(f"统计视频文件失败: {e}") return count def on_task_finished(self, success, message): """任务完成回调(单个任务)""" self.progress_bar.setValue(100) self.set_running_progress(0, 0) if success: self._show_infobar("success", "成功", message) # 更新表格状态为"已完成" # 查找对应的行(通过配置信息) self._update_single_task_status(success) else: self._show_infobar("error", "失败", message) # 更新表格状态为"失败" self._update_single_task_status(success) # 更新状态统计(成功/失败/待执行数量) self._update_status_statistics() # 恢复按钮 self._set_task_buttons_state('idle') self.log_text.append(f"任务完成: {message}") self.log_text.append("=" * 50) def _update_single_task_status_start(self, config): """更新单个任务的状态为"执行中"(根据配置查找对应的表格行)""" try: # 检查是否有表格 if not hasattr(self, 'config_table') or self.config_table.rowCount() == 0: return user_id = config.get('多多id', '') index = config.get('序号', '') if not user_id or not index: return # 在表格中查找匹配的行 for row_idx in range(self.config_table.rowCount()): try: row_user_id = self.get_cell_text(row_idx, 0) row_index = self.get_cell_text(row_idx, 1) if row_user_id == user_id and row_index == index: self._update_table_status(row_idx, "执行中") break except Exception as e: logger.warning(f"更新状态时出错(行{row_idx}): {e}") continue except Exception as e: logger.error(f"更新任务状态失败: {e}") import traceback traceback.print_exc() def _update_single_task_status(self, success): """更新单个任务的状态(根据当前配置查找对应的表格行)""" try: # 检查是否有表格 if not hasattr(self, 'config_table') or self.config_table.rowCount() == 0: return # 获取当前配置(从worker_thread中获取) if not self.worker_thread: return config = self.worker_thread.config_data user_id = config.get('多多id', '') index = config.get('序号', '') if not user_id or not index: return # 在表格中查找匹配的行 for row_idx in range(self.config_table.rowCount()): try: row_user_id = self.get_cell_text(row_idx, 0) row_index = self.get_cell_text(row_idx, 1) if row_user_id == user_id and row_index == index: status = "已完成" if success else "失败" self._update_table_status(row_idx, status) break except Exception as e: logger.warning(f"更新状态时出错(行{row_idx}): {e}") continue except Exception as e: logger.error(f"更新任务状态失败: {e}") import traceback traceback.print_exc() def get_cell_text(self, row, col): """获取表格单元格文本""" try: if not hasattr(self, 'config_table'): return "" if row < 0 or row >= self.config_table.rowCount(): return "" item = self.config_table.item(row, col) return item.text().strip() if item else "" except Exception as e: logger.warning(f"获取单元格文本失败(行{row},列{col}): {e}") return "" def closeEvent(self, event): """关闭事件""" if self._is_closing: event.accept() return if self.worker_thread and self.worker_thread.isRunning(): reply = QMessageBox.question( self, "确认", "任务正在执行中,确定要退出吗?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: self._is_closing = True # 使用统一的线程清理方法 self._cleanup_worker_thread() event.accept() else: event.ignore() else: event.accept() def exception_handler(exc_type, exc_value, exc_traceback): """全局异常处理器""" if issubclass(exc_type, KeyboardInterrupt): sys.__excepthook__(exc_type, exc_value, exc_traceback) return # 防止递归:如果异常处理器本身出错,使用默认处理器 if exc_type is RecursionError: # 对于递归错误,直接打印到控制台,避免再次触发递归 print(f"递归错误: {exc_value}") print("这通常是由于无限递归调用导致的") return try: import traceback # 限制堆栈深度,避免递归错误 error_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback, limit=50)) logger.critical(f"未捕获的异常:\n{error_msg}") # 尝试显示错误对话框 try: from PyQt5.QtWidgets import QMessageBox msg = QMessageBox() msg.setIcon(QMessageBox.Critical) msg.setWindowTitle("程序错误") msg.setText("程序发生未处理的错误") # 限制详细文本长度 detailed_text = error_msg[:2000] if len(error_msg) > 2000 else error_msg msg.setDetailedText(detailed_text) msg.exec_() except: # 如果无法显示对话框,至少打印到控制台 print(f"未捕获的异常:\n{error_msg[:1000]}") # 限制输出长度 except Exception: # 如果异常处理器本身出错,使用默认处理器 sys.__excepthook__(exc_type, exc_value, exc_traceback) def main(): # 设置全局异常处理器 sys.excepthook = exception_handler app = QApplication(sys.argv) # noinspection SpellCheckingInspection # 解决 macOS 上缺失 Segoe UI 字体导致的警告 if sys.platform == "darwin": font = QFont(".AppleSystemUIFont", 10) font.setFamilies([".AppleSystemUIFont", "PingFang SC", "Helvetica Neue", "Arial"]) app.setFont(font) setTheme(Theme.LIGHT) try: window = MainWindow() window.show() sys.exit(app.exec_()) except Exception as e: import traceback error_detail = traceback.format_exc() logger.critical(f"程序启动失败: {e}\n{error_detail}") print(f"程序启动失败: {e}\n{error_detail}") sys.exit(1) if __name__ == '__main__': main()