4212 lines
188 KiB
Python
4212 lines
188 KiB
Python
import os
|
||
import sys
|
||
import json
|
||
import time
|
||
import re
|
||
from pathlib import Path
|
||
from datetime import datetime, timedelta
|
||
from typing import List, Dict, Optional
|
||
|
||
import pandas as pd
|
||
from PyQt5.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QLabel, QFileDialog, QTableWidgetItem, QMessageBox,
|
||
QDateTimeEdit, QGridLayout, QStackedWidget, QButtonGroup,
|
||
QStyle, QComboBox, QFrame, QShortcut, QMenu, QAbstractButton,
|
||
QAbstractItemView, QTableView, QStyledItemDelegate,
|
||
QStyleOptionProgressBar, QStyleOptionButton, QHeaderView,
|
||
QTabWidget, QSplitter, QSizePolicy
|
||
)
|
||
from PyQt5.QtCore import Qt, QThread, pyqtSignal, QDateTime, QSize, QAbstractTableModel, QModelIndex, \
|
||
QSortFilterProxyModel, QRegularExpression, QSettings, QTimer, QEvent
|
||
from PyQt5.QtGui import QFont, QTextDocument, QTextCursor, QKeySequence, QColor, QPainter
|
||
|
||
from qfluentwidgets import (
|
||
setTheme, Theme,
|
||
PushButton, PrimaryPushButton, LineEdit, TextEdit,
|
||
TableWidget, CheckBox, ProgressBar, CardWidget,
|
||
InfoBar, InfoBarPosition
|
||
)
|
||
from main import Pdd
|
||
from loguru import logger
|
||
|
||
|
||
def get_default_folder_path():
|
||
"""获取默认文件夹路径(桌面/多多自动化发文)"""
|
||
desktop = os.path.join(os.path.expanduser("~"), "Desktop")
|
||
default_path = os.path.join(desktop, "多多自动化发文")
|
||
return default_path
|
||
|
||
|
||
class WorkerThread(QThread):
|
||
"""工作线程,用于执行自动化任务"""
|
||
finished = pyqtSignal(bool, str)
|
||
log_message = pyqtSignal(str)
|
||
progress = pyqtSignal(int)
|
||
item_result = pyqtSignal(object) # 单条发布结果(dict)
|
||
|
||
def __init__(self, config_data, is_batch_mode, prepared_files=None, parent=None):
|
||
super().__init__(parent)
|
||
self.config_data = config_data
|
||
self.is_batch_mode = is_batch_mode
|
||
self.prepared_files = prepared_files # 预查找的文件列表
|
||
self.is_running = True
|
||
|
||
def run(self):
|
||
sink_id = None
|
||
start_ts = time.time()
|
||
try:
|
||
# 将日志转发到 GUI(不要 remove 全局 sink,避免影响主线程/其他模块日志)
|
||
sink_id = logger.add(lambda msg: self.log_message.emit(str(msg).strip()))
|
||
|
||
if self.is_batch_mode:
|
||
self.run_batch_mode()
|
||
else:
|
||
self.run_single_mode()
|
||
except Exception as e:
|
||
error_msg = f"执行失败: {str(e)}"
|
||
self.finished.emit(False, error_msg)
|
||
self.log_message.emit(error_msg)
|
||
logger.error(f"执行失败: {e}")
|
||
import traceback
|
||
traceback_str = traceback.format_exc()
|
||
logger.error(traceback_str)
|
||
self.log_message.emit(f"错误详情: {traceback_str}")
|
||
finally:
|
||
try:
|
||
cost = time.time() - start_ts
|
||
self.log_message.emit(f"[线程] 任务结束,耗时 {cost:.2f}s")
|
||
except Exception:
|
||
pass
|
||
if sink_id is not None:
|
||
try:
|
||
logger.remove(sink_id)
|
||
except Exception:
|
||
pass
|
||
|
||
def run_single_mode(self):
|
||
"""执行单个上传模式(action方法)"""
|
||
try:
|
||
config = self.config_data
|
||
self.log_message.emit(f"开始处理单个任务(逐个上传模式)...")
|
||
|
||
pdd = Pdd(
|
||
url=config.get('达人链接', ''),
|
||
user_id=config.get('多多id', ''),
|
||
time_start=config.get('定时发布', '') if config.get('定时发布') else None,
|
||
ht=config.get('话题', ''),
|
||
index=config.get('序号', ''),
|
||
title=config.get('标题', None)
|
||
)
|
||
|
||
# 如果已经预查找了文件,直接使用
|
||
if self.prepared_files:
|
||
self.log_message.emit(f"使用预查找的文件列表,共 {len(self.prepared_files)} 个文件")
|
||
self.progress.emit(30)
|
||
|
||
# 判断是否为视频文件
|
||
video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm']
|
||
video_files = [f for f in self.prepared_files if
|
||
f['path'].is_file() and any(f['path'].suffix.lower() == ext for ext in video_extensions)]
|
||
|
||
# 如果勾选了批量上传且是视频,调用action1方法
|
||
if self.is_batch_mode and video_files:
|
||
# 批量上传视频,调用action1方法
|
||
self.log_message.emit(f"批量上传模式:找到 {len(video_files)} 个视频文件,调用action1方法")
|
||
try:
|
||
result = pdd.action1(folder_path=video_files)
|
||
ok = bool(result.get("ok")) if isinstance(result, dict) else True
|
||
if not ok:
|
||
fails = [r for r in (result.get("results") or []) if not r.get("ok")]
|
||
self.log_message.emit(f"发布校验失败条数: {len(fails)}")
|
||
for r in fails[:20]:
|
||
self.log_message.emit(f" ✗ {r.get('name')} | {r.get('reason')}")
|
||
# 逐条回传结果给GUI更新表格状态
|
||
if isinstance(result, dict):
|
||
for r in (result.get("results") or []):
|
||
try:
|
||
payload = dict(r)
|
||
payload["user_id"] = config.get("多多id", "")
|
||
self.item_result.emit(payload)
|
||
except Exception:
|
||
pass
|
||
self.progress.emit(100)
|
||
self.finished.emit(ok, f"批量上传完成,共处理 {len(video_files)} 个视频(ok={ok})")
|
||
except Exception as e:
|
||
error_msg = f"执行action1方法失败: {str(e)}"
|
||
self.log_message.emit(error_msg)
|
||
logger.error(error_msg)
|
||
import traceback
|
||
traceback_str = traceback.format_exc()
|
||
logger.error(traceback_str)
|
||
self.log_message.emit(f"错误详情: {traceback_str}")
|
||
self.finished.emit(False, error_msg)
|
||
return
|
||
else:
|
||
# 单个上传模式,使用action方法
|
||
# 从预查找的文件中提取文件夹路径(取第一个文件的父目录的父目录,回到最外层文件夹)
|
||
if self.prepared_files:
|
||
first_file = self.prepared_files[0]
|
||
if first_file['path'].is_file():
|
||
# 文件路径:最外层文件夹/多多ID文件夹/文件名
|
||
# 需要回到最外层文件夹
|
||
folder_path = str(first_file['path'].parent.parent)
|
||
else:
|
||
# 文件夹路径:最外层文件夹/多多ID文件夹/文件夹名
|
||
folder_path = str(first_file['path'].parent.parent)
|
||
|
||
self.log_message.emit(f"使用文件夹路径: {folder_path}")
|
||
try:
|
||
result = pdd.action(folder_path=folder_path, collect_all_videos=False)
|
||
ok = bool(result.get("ok")) if isinstance(result, dict) else True
|
||
if not ok:
|
||
self.log_message.emit(f"发布校验失败: {result.get('reason') if isinstance(result, dict) else ''}")
|
||
# 单条回传结果
|
||
if isinstance(result, dict):
|
||
self.item_result.emit({
|
||
"user_id": config.get("多多id", ""),
|
||
"index": config.get("序号", ""),
|
||
"name": config.get("标题", "") or "",
|
||
"ok": ok,
|
||
"reason": result.get("reason", ""),
|
||
})
|
||
self.progress.emit(100)
|
||
self.finished.emit(ok, f"单个任务执行完成(ok={ok})")
|
||
except Exception as e:
|
||
error_msg = f"执行action方法失败: {str(e)}"
|
||
self.log_message.emit(error_msg)
|
||
logger.error(error_msg)
|
||
import traceback
|
||
traceback_str = traceback.format_exc()
|
||
logger.error(traceback_str)
|
||
self.log_message.emit(f"错误详情: {traceback_str}")
|
||
self.finished.emit(False, error_msg)
|
||
return
|
||
else:
|
||
# 未预查找文件,使用原来的逻辑
|
||
folder_path = config.get('文件夹路径', '').strip()
|
||
if not folder_path:
|
||
folder_path = get_default_folder_path()
|
||
|
||
if not os.path.exists(folder_path):
|
||
error_msg = f"文件夹路径不存在: {folder_path}"
|
||
self.finished.emit(False, error_msg)
|
||
self.log_message.emit(error_msg)
|
||
return
|
||
|
||
self.log_message.emit(f"使用文件夹路径: {folder_path}")
|
||
self.progress.emit(30)
|
||
|
||
# 检查是否勾选了批量上传
|
||
is_batch_mode = self.is_batch_mode
|
||
|
||
# 调用action方法
|
||
try:
|
||
result = pdd.action(folder_path=folder_path, collect_all_videos=is_batch_mode)
|
||
ok = bool(result.get("ok")) if isinstance(result, dict) else True
|
||
if not ok:
|
||
self.log_message.emit(f"发布校验失败: {result.get('reason') if isinstance(result, dict) else ''}")
|
||
if isinstance(result, dict):
|
||
self.item_result.emit({
|
||
"user_id": config.get("多多id", ""),
|
||
"index": config.get("序号", ""),
|
||
"name": config.get("标题", "") or "",
|
||
"ok": ok,
|
||
"reason": result.get("reason", ""),
|
||
})
|
||
except Exception as e:
|
||
error_msg = f"执行action方法失败: {str(e)}"
|
||
self.log_message.emit(error_msg)
|
||
logger.error(error_msg)
|
||
import traceback
|
||
traceback_str = traceback.format_exc()
|
||
logger.error(traceback_str)
|
||
self.log_message.emit(f"错误详情: {traceback_str}")
|
||
self.finished.emit(False, error_msg)
|
||
return
|
||
|
||
self.progress.emit(100)
|
||
self.finished.emit(ok, f"单个任务执行完成(ok={ok})")
|
||
except Exception as e:
|
||
error_msg = f"执行失败: {str(e)}"
|
||
self.finished.emit(False, error_msg)
|
||
self.log_message.emit(error_msg)
|
||
logger.error(f"执行失败: {e}")
|
||
import traceback
|
||
traceback_str = traceback.format_exc()
|
||
logger.error(traceback_str)
|
||
self.log_message.emit(f"错误详情: {traceback_str}")
|
||
|
||
def run_batch_mode(self):
|
||
"""执行批量上传模式:先批量上传所有视频,然后逐个上传图片"""
|
||
try:
|
||
config = self.config_data
|
||
self.log_message.emit(f"开始处理批量上传任务...")
|
||
|
||
index = config.get('序号', '')
|
||
|
||
# 创建Pdd实例
|
||
pdd = Pdd(
|
||
url=config.get('达人链接', ''),
|
||
user_id=config.get('多多id', ''),
|
||
time_start=config.get('定时发布', '') if config.get('定时发布') else None,
|
||
ht=config.get('话题', ''),
|
||
index=index,
|
||
title=config.get('标题', None)
|
||
)
|
||
|
||
# 如果已经预查找了文件,直接使用
|
||
if self.prepared_files:
|
||
self.log_message.emit(f"使用预查找的文件列表,共 {len(self.prepared_files)} 个文件")
|
||
self.progress.emit(30)
|
||
|
||
# 分离视频文件和图片文件夹
|
||
video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm']
|
||
video_file_paths = [f for f in self.prepared_files if f['path'].is_file() and any(
|
||
f['path'].suffix.lower() == ext for ext in video_extensions)]
|
||
image_folders = [f for f in self.prepared_files if f['path'].is_dir()]
|
||
|
||
# 如果有视频,批量上传所有视频
|
||
if video_file_paths:
|
||
self.log_message.emit(f"找到 {len(video_file_paths)} 个视频文件,开始批量上传...")
|
||
self.progress.emit(30)
|
||
result = pdd.action1(folder_path=video_file_paths)
|
||
ok = bool(result.get("ok")) if isinstance(result, dict) else True
|
||
if not ok:
|
||
fails = [r for r in (result.get("results") or []) if not r.get("ok")]
|
||
self.log_message.emit(f"发布校验失败条数: {len(fails)}")
|
||
for r in fails[:20]:
|
||
self.log_message.emit(f" ✗ {r.get('name')} | {r.get('reason')}")
|
||
# 逐条回传结果给GUI更新表格状态
|
||
if isinstance(result, dict):
|
||
for r in (result.get("results") or []):
|
||
try:
|
||
payload = dict(r)
|
||
payload["user_id"] = config.get("多多id", "")
|
||
self.item_result.emit(payload)
|
||
except Exception:
|
||
pass
|
||
self.log_message.emit(f"批量上传视频完成,共处理 {len(video_file_paths)} 个视频")
|
||
else:
|
||
self.log_message.emit("未找到视频文件,跳过视频上传")
|
||
ok = True
|
||
|
||
# 如果有图片文件夹,逐个上传图片
|
||
if image_folders:
|
||
self.log_message.emit(f"找到 {len(image_folders)} 个图片文件夹,开始逐个上传...")
|
||
self.progress.emit(70)
|
||
|
||
for idx, image_folder_info in enumerate(image_folders):
|
||
if not self.is_running:
|
||
break
|
||
|
||
image_folder = str(image_folder_info['path'])
|
||
self.log_message.emit(f"处理第 {idx + 1}/{len(image_folders)} 个图片文件夹: {image_folder}")
|
||
# 使用action方法处理图片文件夹
|
||
pdd.action(folder_path=image_folder)
|
||
self.log_message.emit(f"图片文件夹 {idx + 1} 上传完成")
|
||
|
||
self.log_message.emit(f"所有图片文件夹上传完成,共处理 {len(image_folders)} 个文件夹")
|
||
else:
|
||
self.log_message.emit("未找到图片文件夹,跳过图片上传")
|
||
|
||
self.progress.emit(100)
|
||
total_count = len(video_file_paths) + len(image_folders)
|
||
self.finished.emit(ok,
|
||
f"批量任务执行完成,共处理 {len(video_file_paths)} 个视频和 {len(image_folders)} 个图片文件夹(ok={ok})")
|
||
else:
|
||
# 未预查找文件,使用原来的逻辑
|
||
folder_path = config.get('文件夹路径', '').strip()
|
||
if not folder_path:
|
||
folder_path = get_default_folder_path()
|
||
|
||
if not os.path.exists(folder_path):
|
||
self.finished.emit(False, f"文件夹路径不存在: {folder_path}")
|
||
return
|
||
|
||
# 第一步:收集所有视频文件
|
||
self.log_message.emit("第一步:收集所有视频文件...")
|
||
video_file_paths = self.prepare_batch_files(folder_path, config)
|
||
|
||
# 第二步:收集所有图片文件夹
|
||
self.log_message.emit("第二步:收集所有图片文件夹...")
|
||
image_folders = self.prepare_image_folders(folder_path, index)
|
||
|
||
# 第三步:如果有视频,批量上传所有视频
|
||
if video_file_paths:
|
||
self.log_message.emit(f"找到 {len(video_file_paths)} 个视频文件,开始批量上传...")
|
||
self.progress.emit(30)
|
||
pdd.action1(folder_path=video_file_paths)
|
||
self.log_message.emit(f"批量上传视频完成,共处理 {len(video_file_paths)} 个视频")
|
||
else:
|
||
self.log_message.emit("未找到视频文件,跳过视频上传")
|
||
|
||
# 第四步:如果有图片文件夹,逐个上传图片
|
||
if image_folders:
|
||
self.log_message.emit(f"找到 {len(image_folders)} 个图片文件夹,开始逐个上传...")
|
||
self.progress.emit(70)
|
||
|
||
for idx, image_folder in enumerate(image_folders):
|
||
if not self.is_running:
|
||
break
|
||
|
||
self.log_message.emit(f"处理第 {idx + 1}/{len(image_folders)} 个图片文件夹: {image_folder}")
|
||
# 使用action方法处理图片文件夹
|
||
pdd.action(folder_path=image_folder)
|
||
self.log_message.emit(f"图片文件夹 {idx + 1} 上传完成")
|
||
|
||
self.log_message.emit(f"所有图片文件夹上传完成,共处理 {len(image_folders)} 个文件夹")
|
||
else:
|
||
self.log_message.emit("未找到图片文件夹,跳过图片上传")
|
||
|
||
self.progress.emit(100)
|
||
total_count = len(video_file_paths) + len(image_folders)
|
||
self.finished.emit(True,
|
||
f"批量任务执行完成,共处理 {len(video_file_paths)} 个视频和 {len(image_folders)} 个图片文件夹")
|
||
|
||
except Exception as e:
|
||
self.finished.emit(False, f"批量执行失败: {str(e)}")
|
||
logger.error(f"批量执行失败: {e}")
|
||
|
||
def prepare_image_folders(self, folder_path, index):
|
||
"""收集所有匹配序号的图片文件夹(与main.py中action方法的逻辑一致)"""
|
||
image_folders = []
|
||
|
||
try:
|
||
# 参数验证
|
||
if not folder_path or not os.path.exists(folder_path):
|
||
logger.warning(f"文件夹路径无效或不存在: {folder_path}")
|
||
return image_folders
|
||
|
||
if not os.access(folder_path, os.R_OK):
|
||
logger.error(f"没有权限读取文件夹: {folder_path}")
|
||
return image_folders
|
||
|
||
# 遍历最外层文件夹下的所有子文件夹(与main.py逻辑一致)
|
||
try:
|
||
subdirs = os.listdir(folder_path)
|
||
except PermissionError:
|
||
logger.error(f"没有权限访问文件夹: {folder_path}")
|
||
return image_folders
|
||
except Exception as e:
|
||
logger.error(f"读取文件夹失败: {e}")
|
||
return image_folders
|
||
|
||
for file in subdirs:
|
||
try:
|
||
file_path = os.path.join(folder_path, file) # 拼接文件夹
|
||
# 检查是否为目录,跳过文件(如.lnk快捷方式)
|
||
if not os.path.isdir(file_path):
|
||
continue
|
||
|
||
# 获取用户id下的文件
|
||
try:
|
||
files = os.listdir(file_path)
|
||
except PermissionError:
|
||
logger.warning(f"没有权限访问子文件夹: {file_path}")
|
||
continue
|
||
except Exception as e:
|
||
logger.warning(f"读取子文件夹失败: {file_path}, 错误: {e}")
|
||
continue
|
||
|
||
for file_name in files:
|
||
try:
|
||
# 跳过隐藏文件
|
||
if file_name.startswith('.'):
|
||
continue
|
||
|
||
# 用"-"分割文件名,检查第一部分是否等于序号
|
||
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.exists() and path.is_dir():
|
||
# 这是一个图片文件夹
|
||
image_folders.append(str(path))
|
||
except Exception as e:
|
||
logger.warning(f"处理文件 {file_name} 时出错: {e}")
|
||
continue
|
||
except Exception as e:
|
||
logger.warning(f"处理子文件夹 {file} 时出错: {e}")
|
||
continue
|
||
except Exception as e:
|
||
logger.error(f"收集图片文件夹失败: {e}", exc_info=True)
|
||
|
||
return image_folders
|
||
|
||
def count_videos_in_folder(self, 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 Exception as e:
|
||
logger.error(f"统计视频文件失败: {e}")
|
||
|
||
return count
|
||
|
||
def prepare_batch_files(self, folder_path, config):
|
||
"""准备批量上传的文件列表(与main.py中的action1方法逻辑一致)"""
|
||
file_paths = []
|
||
|
||
# 参数验证
|
||
if not config:
|
||
logger.error("配置为空,无法准备批量上传文件")
|
||
return file_paths
|
||
|
||
index = str(config.get('序号', '')).strip()
|
||
if not index:
|
||
logger.warning("序号为空,无法准备批量上传文件")
|
||
return file_paths
|
||
|
||
if not folder_path or not os.path.exists(folder_path):
|
||
logger.error(f"文件夹路径无效或不存在: {folder_path}")
|
||
return file_paths
|
||
|
||
if not os.access(folder_path, os.R_OK):
|
||
logger.error(f"没有权限读取文件夹: {folder_path}")
|
||
return file_paths
|
||
|
||
logger.info("=" * 50)
|
||
logger.info("开始准备批量上传文件列表...")
|
||
logger.info(f"文件夹路径: {folder_path}")
|
||
logger.info(f"查找序号: {index}")
|
||
|
||
try:
|
||
# 遍历最外层文件夹下的所有子文件夹(与main.py逻辑一致)
|
||
try:
|
||
all_items = os.listdir(folder_path)
|
||
subdirs = [f for f in all_items if os.path.isdir(os.path.join(folder_path, f))]
|
||
except PermissionError:
|
||
logger.error(f"没有权限访问文件夹: {folder_path}")
|
||
return file_paths
|
||
except Exception as e:
|
||
logger.error(f"读取文件夹失败: {e}")
|
||
return file_paths
|
||
|
||
logger.info(f"在最外层文件夹下找到 {len(subdirs)} 个子文件夹(多多ID文件夹)")
|
||
|
||
for subdir_name in subdirs:
|
||
file_path = os.path.join(folder_path, subdir_name) # 拼接文件夹
|
||
logger.info(f" 正在扫描子文件夹: {subdir_name}")
|
||
|
||
# 检查是否为目录,跳过文件(如.lnk快捷方式)
|
||
if not os.path.isdir(file_path):
|
||
logger.info(f" 跳过(不是文件夹)")
|
||
continue
|
||
|
||
files = os.listdir(file_path) # 获取用户id下的文件
|
||
logger.info(f" 该文件夹下有 {len(files)} 个项目")
|
||
|
||
for file_name in files:
|
||
logger.info(f" 检查项目: {file_name}")
|
||
|
||
# 检查是否是视频文件(.mp4)
|
||
if ".mp4" in file_name:
|
||
logger.info(f" ✓ 是视频文件(包含.mp4)")
|
||
# 用"-"分割文件名,检查第一部分是否等于序号
|
||
# 文件名格式:4-茶叶蛋大冒险-.mp4 -> ['4', '茶叶蛋大冒险', '', 'mp4']
|
||
file_names = file_name.split("-")
|
||
logger.info(f" 文件名分割结果: {file_names}")
|
||
|
||
if len(file_names) > 0 and file_names[0] == str(index):
|
||
logger.info(f" ✓ 序号匹配!序号: {file_names[0]}, 目标序号: {index}")
|
||
path = Path(os.path.join(file_path, file_name))
|
||
|
||
# 判断是否为文件
|
||
if path.is_file():
|
||
logger.info(f" ✓ 是文件,添加到列表: {path.name}")
|
||
file_paths.append({
|
||
"url": config.get('达人链接', ''),
|
||
"user_id": config.get('多多id', ''),
|
||
"time_start": config.get('定时发布', '') if config.get('定时发布') else None,
|
||
"ht": config.get('话题', ''),
|
||
"index": str(index),
|
||
"path": path
|
||
})
|
||
logger.info(f" 当前视频总数: {len(file_paths)}")
|
||
# 注意:这里不break,因为可能有多个匹配的文件
|
||
else:
|
||
logger.info(f" 是文件夹,扫描其中的文件...")
|
||
# 如果是文件夹,添加其中的所有文件
|
||
try:
|
||
sub_files = os.listdir(path)
|
||
logger.info(f" 文件夹中有 {len(sub_files)} 个文件")
|
||
for sub_file in sub_files:
|
||
sub_path = Path(os.path.join(path, sub_file))
|
||
if sub_path.is_file():
|
||
logger.info(f" ✓ 添加文件: {sub_file}")
|
||
file_paths.append({
|
||
"url": config.get('达人链接', ''),
|
||
"user_id": config.get('多多id', ''),
|
||
"time_start": config.get('定时发布', '') if config.get(
|
||
'定时发布') else None,
|
||
"ht": config.get('话题', ''),
|
||
"index": str(index),
|
||
"path": sub_path
|
||
})
|
||
logger.info(f" 当前视频总数: {len(file_paths)}")
|
||
except Exception as e:
|
||
logger.error(f" 扫描子文件夹失败: {e}")
|
||
else:
|
||
if len(file_names) > 0:
|
||
logger.info(f" ✗ 序号不匹配: {file_names[0]} != {index}")
|
||
else:
|
||
logger.info(f" ✗ 文件名格式不正确,无法提取序号")
|
||
else:
|
||
logger.info(f" ✗ 不是视频文件(不包含.mp4),跳过")
|
||
except Exception as e:
|
||
logger.error(f"准备批量文件失败: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
|
||
logger.info("=" * 50)
|
||
logger.info(f"文件收集完成!共找到 {len(file_paths)} 个视频文件")
|
||
if file_paths:
|
||
logger.info("视频文件列表:")
|
||
for idx, video_info in enumerate(file_paths, 1):
|
||
logger.info(f" {idx}. {video_info['path'].name} ({video_info['path']})")
|
||
|
||
return file_paths
|
||
|
||
def stop(self):
|
||
"""停止执行"""
|
||
self.is_running = False
|
||
|
||
def requestInterruption(self):
|
||
"""请求中断线程(重写QThread方法)"""
|
||
super().requestInterruption()
|
||
self.is_running = False
|
||
|
||
|
||
class ConfigTableModel(QAbstractTableModel):
|
||
"""大数据量表格模型"""
|
||
|
||
def __init__(self, configs, headers, parent=None):
|
||
super().__init__(parent)
|
||
self._configs = configs
|
||
self._headers = headers
|
||
|
||
def rowCount(self, parent=QModelIndex()):
|
||
return len(self._configs)
|
||
|
||
def columnCount(self, parent=QModelIndex()):
|
||
return len(self._headers)
|
||
|
||
def data(self, index, role=Qt.DisplayRole):
|
||
if not index.isValid():
|
||
return None
|
||
row = index.row()
|
||
col = index.column()
|
||
config = self._configs[row]
|
||
mapping = {
|
||
0: "多多id",
|
||
1: "序号",
|
||
2: "话题",
|
||
3: "定时发布",
|
||
4: "间隔时间",
|
||
5: "达人链接",
|
||
6: "执行人",
|
||
7: "情况",
|
||
8: "文件路径",
|
||
9: "",
|
||
10: "",
|
||
}
|
||
key = mapping.get(col, "")
|
||
if role == Qt.DisplayRole:
|
||
return str(config.get(key, "")) if key else ""
|
||
elif role == Qt.TextAlignmentRole:
|
||
# 所有单元格居中对齐
|
||
return Qt.AlignCenter
|
||
elif role == Qt.ToolTipRole:
|
||
# 为达人链接和文件路径列提供 tooltip 显示完整内容
|
||
if col in (5, 8) and key:
|
||
return str(config.get(key, ""))
|
||
return None
|
||
|
||
def headerData(self, section, orientation, role=Qt.DisplayRole):
|
||
if role != Qt.DisplayRole:
|
||
return None
|
||
if orientation == Qt.Horizontal:
|
||
return self._headers[section]
|
||
return section + 1
|
||
|
||
def flags(self, index):
|
||
if not index.isValid():
|
||
return Qt.NoItemFlags
|
||
return Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable
|
||
|
||
def setData(self, index, value, role=Qt.EditRole):
|
||
if role != Qt.EditRole or not index.isValid():
|
||
return False
|
||
row = index.row()
|
||
col = index.column()
|
||
mapping = {
|
||
0: "多多id",
|
||
1: "序号",
|
||
2: "话题",
|
||
3: "定时发布",
|
||
4: "间隔时间",
|
||
5: "达人链接",
|
||
6: "执行人",
|
||
7: "情况",
|
||
8: "文件路径",
|
||
}
|
||
key = mapping.get(col, "")
|
||
if not key:
|
||
return False
|
||
self._configs[row][key] = str(value)
|
||
self.dataChanged.emit(index, index, [Qt.DisplayRole])
|
||
return True
|
||
|
||
def removeRows(self, row, count, parent=QModelIndex()):
|
||
if row < 0 or row + count > len(self._configs):
|
||
return False
|
||
self.beginRemoveRows(parent, row, row + count - 1)
|
||
del self._configs[row:row + count]
|
||
self.endRemoveRows()
|
||
return True
|
||
|
||
|
||
class TableActionDelegate(QStyledItemDelegate):
|
||
"""Model/View 操作列 + 进度列 delegate"""
|
||
|
||
def __init__(self, parent, on_delete):
|
||
super().__init__(parent)
|
||
self.on_delete = on_delete
|
||
|
||
def paint(self, painter, option, index):
|
||
if index.column() == 9:
|
||
status_text = index.sibling(index.row(), 7).data() or ""
|
||
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
|
||
bar = QStyleOptionProgressBar()
|
||
bar.rect = option.rect.adjusted(6, option.rect.height() // 3, -6, -option.rect.height() // 3)
|
||
bar.minimum = 0
|
||
bar.maximum = 100
|
||
bar.progress = value
|
||
bar.textVisible = False
|
||
QApplication.style().drawControl(QStyle.CE_ProgressBar, bar, painter)
|
||
return
|
||
if index.column() == 10:
|
||
rect = option.rect
|
||
delete_rect = rect.adjusted(6, 4, -6, -4)
|
||
delete_btn = QStyleOptionButton()
|
||
delete_btn.rect = delete_rect
|
||
delete_btn.text = "删除"
|
||
QApplication.style().drawControl(QStyle.CE_PushButton, delete_btn, painter)
|
||
return
|
||
super().paint(painter, option, index)
|
||
|
||
def editorEvent(self, event, model, option, index):
|
||
if index.column() != 10:
|
||
return super().editorEvent(event, model, option, index)
|
||
if event.type() == event.MouseButtonRelease:
|
||
rect = option.rect
|
||
delete_rect = rect.adjusted(6, 4, -6, -4)
|
||
if delete_rect.contains(event.pos()):
|
||
self.on_delete(index)
|
||
return True
|
||
return False
|
||
|
||
def update_data(self, configs):
|
||
self.beginResetModel()
|
||
self._configs = configs
|
||
self.endResetModel()
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""主窗口"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.worker_thread = None
|
||
self.configs = [] # 存储从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.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.table_model = None
|
||
self.table_proxy = None
|
||
self.log_match_positions = []
|
||
self.log_match_index = -1
|
||
self.log_match_items = []
|
||
self.is_updating_table = False
|
||
self._is_closing = False # 标记是否正在关闭窗口
|
||
# 任务执行时用于“多多ID+序号 -> 行号”的映射(用于精确回写状态)
|
||
self._row_map_by_user_index = {}
|
||
self.init_ui()
|
||
|
||
def init_ui(self):
|
||
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("拼多多自动化发布工具")
|
||
title_label.setFont(QFont("Microsoft YaHei", 16, QFont.Bold))
|
||
subtitle_label = QLabel("配置导入 • 文件查找 • 批量上传")
|
||
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("开始上传")
|
||
self.execute_btn.clicked.connect(self.execute_task)
|
||
header_actions.addWidget(self.execute_btn)
|
||
header_layout.addLayout(header_actions)
|
||
main_layout.addLayout(header_layout)
|
||
|
||
# 状态卡片区
|
||
status_layout = QHBoxLayout()
|
||
self.status_update_value = QLabel("未更新")
|
||
self.status_pending_value = QLabel("0")
|
||
self.status_running_value = QLabel("0")
|
||
self.status_success_value = QLabel("0")
|
||
self.status_failed_value = QLabel("0")
|
||
|
||
update_card = self._build_status_card(
|
||
"更新状态",
|
||
self.status_update_value,
|
||
self.style().standardIcon(QStyle.SP_BrowserReload),
|
||
"#e6f4ff",
|
||
"文件路径扫描"
|
||
)
|
||
pending_card = self._build_status_card(
|
||
"待执行",
|
||
self.status_pending_value,
|
||
self.style().standardIcon(QStyle.SP_FileDialogInfoView),
|
||
"#fff7ed",
|
||
"等待处理任务"
|
||
)
|
||
running_card = self._build_status_card(
|
||
"执行中",
|
||
self.status_running_value,
|
||
self.style().standardIcon(QStyle.SP_MediaPlay),
|
||
"#ecfdf3",
|
||
"当前执行进度",
|
||
with_progress=True
|
||
)
|
||
success_card = self._build_status_card(
|
||
"成功",
|
||
self.status_success_value,
|
||
self.style().standardIcon(QStyle.SP_DialogApplyButton),
|
||
"#dcfce7",
|
||
"点击筛选成功项",
|
||
clickable=True
|
||
)
|
||
failed_card = self._build_status_card(
|
||
"失败",
|
||
self.status_failed_value,
|
||
self.style().standardIcon(QStyle.SP_MessageBoxCritical),
|
||
"#fee2e2",
|
||
"点击筛选失败项",
|
||
clickable=True
|
||
)
|
||
# 连接点击事件
|
||
success_card.mousePressEvent = lambda e: self._filter_by_status("成功")
|
||
failed_card.mousePressEvent = lambda e: self._filter_by_status("失败")
|
||
|
||
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("导航")
|
||
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("工作台")
|
||
self.nav_log = PushButton("日志")
|
||
|
||
nav_items = [
|
||
(self.nav_main, QStyle.SP_DesktopIcon),
|
||
(self.nav_log, QStyle.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")
|
||
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("收起")
|
||
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("配置信息")
|
||
config_title.setFont(QFont("Microsoft YaHei", 11, QFont.Bold))
|
||
config_layout.addWidget(config_title)
|
||
config_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||
config_group.setMaximumHeight(280)
|
||
|
||
# Excel导入(合并到配置)
|
||
import_row = QHBoxLayout()
|
||
import_row.addWidget(QLabel("Excel文件:"))
|
||
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("导入配置")
|
||
self.import_btn.setToolTip("选择Excel文件并导入配置")
|
||
self.import_btn.clicked.connect(self.import_excel)
|
||
import_row.addWidget(self.import_btn)
|
||
self.download_template_btn = PushButton("下载模板")
|
||
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)
|
||
|
||
grid = QGridLayout()
|
||
grid.setHorizontalSpacing(12)
|
||
grid.setVerticalSpacing(10)
|
||
|
||
# 文件夹路径(最外层文件夹)
|
||
grid.addWidget(QLabel("资料文件夹路径:"), 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("浏览")
|
||
self.folder_browse_btn.clicked.connect(self.browse_folder)
|
||
grid.addWidget(self.folder_browse_btn, 0, 3)
|
||
|
||
tip_label = QLabel("提示:只需填写最外层文件夹路径,程序会自动查找子文件夹中的文件")
|
||
tip_label.setStyleSheet("color: #666; font-size: 10px;")
|
||
grid.addWidget(tip_label, 1, 0, 1, 4)
|
||
|
||
# 更新数据按钮 + 批量上传(同一行)
|
||
update_row = QHBoxLayout()
|
||
self.update_data_btn = PrimaryPushButton("更新数据")
|
||
self.update_data_btn.clicked.connect(self.update_data)
|
||
update_row.addWidget(self.update_data_btn)
|
||
self.update_status_label = QLabel("未更新")
|
||
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("批量上传(如果文件夹中有多个视频,将使用批量上传模式)")
|
||
self.batch_upload_checkbox.setChecked(False)
|
||
update_row.addWidget(self.batch_upload_checkbox)
|
||
update_row_widget = QWidget()
|
||
update_row_widget.setLayout(update_row)
|
||
grid.addWidget(update_row_widget, 2, 0, 1, 4)
|
||
|
||
# 发布间隔控制
|
||
interval_row = QHBoxLayout()
|
||
interval_row.addWidget(QLabel("发布间隔时间:"))
|
||
self.publish_interval_input = LineEdit()
|
||
self.publish_interval_input.setPlaceholderText("如: 5 或 30s 或 2m")
|
||
self.publish_interval_input.setFixedWidth(100)
|
||
self.publish_interval_input.setText("0")
|
||
self.publish_interval_input.setToolTip("设置每条发布任务之间的等待时间\n支持格式: 数字(秒)、30s(秒)、2m(分钟)")
|
||
interval_row.addWidget(self.publish_interval_input)
|
||
interval_row.addWidget(QLabel("秒"))
|
||
interval_row.addSpacing(20)
|
||
interval_tip = QLabel("提示: 设置每条发布任务执行完成后的等待时间,0表示不等待")
|
||
interval_tip.setStyleSheet("color: #666; font-size: 10px;")
|
||
interval_row.addWidget(interval_tip)
|
||
interval_row.addStretch()
|
||
interval_row_widget = QWidget()
|
||
interval_row_widget.setLayout(interval_row)
|
||
grid.addWidget(interval_row_widget, 3, 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导入后显示,可直接在表格中编辑)")
|
||
table_title.setFont(QFont("Microsoft YaHei", 11, QFont.Bold))
|
||
table_layout.addWidget(table_title)
|
||
self.table_edit_hint = QLabel("编辑模式:当前行已高亮,其它行已锁定。修改后点击“确认”保存,点击“退出”还原。")
|
||
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_search_input = LineEdit()
|
||
self.table_search_input.setPlaceholderText("搜索表格(支持空格多关键词)")
|
||
self.table_search_input.setClearButtonEnabled(True)
|
||
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_case_sensitive = CheckBox("区分大小写")
|
||
self.table_case_sensitive.stateChanged.connect(lambda: self.filter_table(self.table_search_input.text()))
|
||
search_row.addWidget(self.table_case_sensitive)
|
||
self.table_regex = CheckBox("正则")
|
||
self.table_regex.stateChanged.connect(lambda: self.filter_table(self.table_search_input.text()))
|
||
search_row.addWidget(self.table_regex)
|
||
self.table_any_term = CheckBox("任意词匹配")
|
||
self.table_any_term.stateChanged.connect(lambda: self.filter_table(self.table_search_input.text()))
|
||
search_row.addWidget(self.table_any_term)
|
||
self.table_highlight = CheckBox("高亮匹配")
|
||
self.table_highlight.setChecked(True)
|
||
self.table_highlight.stateChanged.connect(lambda: self.filter_table(self.table_search_input.text()))
|
||
search_row.addWidget(self.table_highlight)
|
||
self.table_locate_btn = PushButton("定位")
|
||
self.table_locate_btn.clicked.connect(self.locate_table)
|
||
search_row.addWidget(self.table_locate_btn)
|
||
self.table_only_match = CheckBox("仅显示匹配")
|
||
self.table_only_match.setChecked(True)
|
||
self.table_only_match.stateChanged.connect(lambda: self.filter_table(self.table_search_input.text()))
|
||
search_row.addWidget(self.table_only_match)
|
||
self.table_clear_btn = PushButton("清空筛选")
|
||
self.table_clear_btn.clicked.connect(lambda: self.table_search_input.setText(""))
|
||
search_row.addWidget(self.table_clear_btn)
|
||
self.table_filter_status = QLabel("显示: 0/0")
|
||
self.table_filter_status.setStyleSheet("color: #666; font-size: 10px;")
|
||
search_row.addWidget(self.table_filter_status)
|
||
self.table_match_selector = QComboBox()
|
||
self.table_match_selector.setMinimumWidth(180)
|
||
self.table_match_selector.currentIndexChanged.connect(self.jump_to_table_match)
|
||
search_row.addWidget(self.table_match_selector)
|
||
self.table_prev_match_btn = PushButton("上一条")
|
||
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("下一条")
|
||
self.table_next_match_btn.clicked.connect(self.next_table_match)
|
||
search_row.addWidget(self.table_next_match_btn)
|
||
self.table_export_all_btn = PushButton("导出全部")
|
||
self.table_export_all_btn.clicked.connect(self.export_all_rows)
|
||
search_row.addWidget(self.table_export_all_btn)
|
||
self.retry_failed_btn = PushButton("重新发布失败项")
|
||
self.retry_failed_btn.setToolTip("筛选并重新执行所有失败的任务")
|
||
self.retry_failed_btn.clicked.connect(self.retry_failed_items)
|
||
search_row.addWidget(self.retry_failed_btn)
|
||
self.table_select_count = QLabel("已选: 0")
|
||
self.table_select_count.setStyleSheet("color: #666; font-size: 10px;")
|
||
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(11)
|
||
self.config_table.setHorizontalHeaderLabels([
|
||
'多多ID', '序号', '话题', '定时发布', '间隔时间', '达人链接',
|
||
'执行人', '情况', '文件路径', '进度', '操作'
|
||
])
|
||
self.table_column_filter.addItem("全部列")
|
||
for col in range(9):
|
||
header = self.config_table.horizontalHeaderItem(col)
|
||
if header:
|
||
self.table_column_filter.addItem(header.text())
|
||
header = self.config_table.horizontalHeader()
|
||
header.setStretchLastSection(False)
|
||
header.setSectionResizeMode(QHeaderView.Interactive)
|
||
# 设置文本省略模式(超长文本显示省略号)
|
||
self.config_table.setTextElideMode(Qt.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.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)
|
||
# 点击空白区域或按Esc键时退出编辑状态
|
||
self.config_table.viewport().installEventFilter(self)
|
||
self.config_table.installEventFilter(self)
|
||
table_layout.addWidget(self.config_table)
|
||
|
||
# 大数据模式表格(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.ElideRight)
|
||
self.table_view.setContextMenuPolicy(Qt.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)
|
||
self.table_empty_label = QLabel("暂无数据,请先导入Excel配置")
|
||
self.table_empty_label.setStyleSheet("color: #999; font-size: 12px;")
|
||
self.table_empty_label.setAlignment(Qt.AlignCenter)
|
||
self.table_empty_label.setVisible(True)
|
||
table_layout.addWidget(self.table_empty_label)
|
||
|
||
pagination_row = QHBoxLayout()
|
||
self.table_select_all = CheckBox("全选当前页")
|
||
self.table_select_all.stateChanged.connect(self.toggle_select_all_rows)
|
||
pagination_row.addWidget(self.table_select_all)
|
||
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("每页"))
|
||
pagination_row.addWidget(self.page_size_combo)
|
||
self.page_info_label = QLabel("第 1/1 页")
|
||
pagination_row.addWidget(self.page_info_label)
|
||
self.page_first_btn = PushButton("首页")
|
||
self.page_prev_btn = PushButton("上一页")
|
||
self.page_next_btn = PushButton("下一页")
|
||
self.page_last_btn = PushButton("末页")
|
||
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)
|
||
self.table_group.setVisible(True)
|
||
self.table_group.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||
|
||
# 配置 + 列表 分割布局
|
||
self.config_splitter = QSplitter(Qt.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("执行日志")
|
||
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("高亮所有")
|
||
self.log_highlight_check.setChecked(True)
|
||
self.log_highlight_check.stateChanged.connect(lambda: self.filter_log(self.log_search_input.text()))
|
||
log_header.addWidget(self.log_highlight_check)
|
||
self.log_case_sensitive = CheckBox("区分大小写")
|
||
self.log_case_sensitive.stateChanged.connect(lambda: self.filter_log(self.log_search_input.text()))
|
||
log_header.addWidget(self.log_case_sensitive)
|
||
self.log_whole_word = CheckBox("整词匹配")
|
||
self.log_whole_word.stateChanged.connect(lambda: self.filter_log(self.log_search_input.text()))
|
||
log_header.addWidget(self.log_whole_word)
|
||
self.log_regex = CheckBox("正则")
|
||
self.log_regex.stateChanged.connect(lambda: self.filter_log(self.log_search_input.text()))
|
||
log_header.addWidget(self.log_regex)
|
||
self.log_prev_btn = PushButton("上一个")
|
||
self.log_prev_btn.clicked.connect(lambda: self.find_log(backward=True))
|
||
log_header.addWidget(self.log_prev_btn)
|
||
self.log_next_btn = PushButton("下一个")
|
||
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")
|
||
self.log_match_status.setStyleSheet("color: #666; font-size: 10px;")
|
||
log_header.addWidget(self.log_match_status)
|
||
self.log_match_selector = QComboBox()
|
||
self.log_match_selector.setMinimumWidth(160)
|
||
self.log_match_selector.currentIndexChanged.connect(self.jump_to_log_match)
|
||
log_header.addWidget(self.log_match_selector)
|
||
self.log_export_btn = PushButton("导出日志")
|
||
self.log_export_btn.clicked.connect(self.export_log)
|
||
log_header.addWidget(self.log_export_btn)
|
||
self.clear_log_btn = PushButton("清空日志")
|
||
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.shortcut_table_next = QShortcut(QKeySequence("Ctrl+F3"), self)
|
||
self.shortcut_table_next.activated.connect(self.next_table_match)
|
||
self.shortcut_table_prev = QShortcut(QKeySequence("Ctrl+Shift+F3"), self)
|
||
self.shortcut_table_prev.activated.connect(self.prev_table_match)
|
||
|
||
def _build_status_card(self, title, value_label, icon, bg_color, subtitle, with_progress=False, clickable=False):
|
||
"""创建状态卡片"""
|
||
card = CardWidget()
|
||
if clickable:
|
||
card.setCursor(Qt.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.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.KeyPress:
|
||
if event.key() == Qt.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(9):
|
||
item = self.config_table.item(row, col)
|
||
if not item:
|
||
continue
|
||
if enabled:
|
||
item.setBackground(highlight_color)
|
||
else:
|
||
item.setData(Qt.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, 10)
|
||
if action_widget:
|
||
action_widget.setEnabled(not locked)
|
||
# 数据列禁用/启用
|
||
for col in range(9):
|
||
item = self.config_table.item(r, col)
|
||
if not item:
|
||
continue
|
||
if locked:
|
||
item.setFlags(item.flags() & ~Qt.ItemIsEnabled)
|
||
else:
|
||
item.setFlags((item.flags() | Qt.ItemIsEnabled) & ~Qt.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
|
||
|
||
if col == 7:
|
||
# 设置标志,防止递归
|
||
self.is_updating_table = True
|
||
try:
|
||
self._set_status_item(row, text)
|
||
self._set_progress_item(row, text)
|
||
finally:
|
||
self.is_updating_table = False
|
||
|
||
self._sync_config_from_row(row)
|
||
except Exception as e:
|
||
logger.warning(f"表格项改变回调出错: {e}")
|
||
# 确保标志被重置
|
||
self.is_updating_table = False
|
||
|
||
def _create_centered_item(self, text):
|
||
"""创建居中对齐的表格单元格"""
|
||
item = QTableWidgetItem(str(text))
|
||
item.setTextAlignment(Qt.AlignCenter)
|
||
# 默认不可编辑,必须通过编辑按钮进入编辑模式
|
||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||
return item
|
||
|
||
def _set_status_item(self, row, text):
|
||
"""设置状态列图标与文本"""
|
||
try:
|
||
# 临时断开 itemChanged 信号,防止递归
|
||
self.config_table.itemChanged.disconnect(self.on_table_item_changed)
|
||
try:
|
||
item = QTableWidgetItem(text)
|
||
item.setTextAlignment(Qt.AlignCenter) # 居中对齐
|
||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||
if "完成" in text or "成功" in text:
|
||
item.setIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton))
|
||
elif "失败" in text or "错误" in text:
|
||
item.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxCritical))
|
||
elif "执行中" in text or "进行" in text:
|
||
item.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
|
||
elif "待" in text:
|
||
item.setIcon(self.style().standardIcon(QStyle.SP_MessageBoxInformation))
|
||
else:
|
||
item.setIcon(self.style().standardIcon(QStyle.SP_FileDialogInfoView))
|
||
self.config_table.setItem(row, 7, item)
|
||
finally:
|
||
# 重新连接信号
|
||
self.config_table.itemChanged.connect(self.on_table_item_changed)
|
||
except Exception as e:
|
||
logger.warning(f"设置状态项失败: {e}")
|
||
# 确保信号重新连接
|
||
try:
|
||
self.config_table.itemChanged.connect(self.on_table_item_changed)
|
||
except:
|
||
pass
|
||
|
||
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, 9, progress)
|
||
|
||
def _set_action_buttons(self, row, config_index):
|
||
"""设置操作列按钮(编辑和删除)"""
|
||
wrapper = QWidget()
|
||
layout = QHBoxLayout(wrapper)
|
||
layout.setContentsMargins(4, 0, 4, 0)
|
||
layout.setSpacing(4)
|
||
edit_btn = PushButton("编辑")
|
||
edit_btn.setFixedWidth(50)
|
||
delete_btn = PushButton("删除")
|
||
delete_btn.setFixedWidth(50)
|
||
# 使用默认参数捕获当前值,避免闭包问题
|
||
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, 10, wrapper)
|
||
|
||
def _set_edit_mode_buttons(self, row, config_index):
|
||
"""设置编辑模式按钮(确认和退出)"""
|
||
wrapper = QWidget()
|
||
layout = QHBoxLayout(wrapper)
|
||
layout.setContentsMargins(4, 0, 4, 0)
|
||
layout.setSpacing(4)
|
||
confirm_btn = PushButton("确认")
|
||
confirm_btn.setFixedWidth(50)
|
||
cancel_btn = PushButton("退出")
|
||
cancel_btn.setFixedWidth(50)
|
||
# 使用默认参数捕获当前值
|
||
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, 10, 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(9): # 前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(9):
|
||
item = self.config_table.item(row, col)
|
||
if item:
|
||
item.setFlags(item.flags() | Qt.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(9):
|
||
item = self.config_table.item(row, col)
|
||
if item and (item.flags() & Qt.ItemIsEditable):
|
||
first_item = item
|
||
self.config_table.setCurrentCell(row, col)
|
||
break
|
||
if first_item:
|
||
self.config_table.setFocus(Qt.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(9):
|
||
item = self.config_table.item(row, col)
|
||
if item:
|
||
item.setFlags(item.flags() & ~Qt.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]
|
||
# 临时断开信号防止触发同步
|
||
try:
|
||
self.config_table.itemChanged.disconnect(self.on_table_item_changed)
|
||
except:
|
||
pass
|
||
try:
|
||
for col, value in original_data.items():
|
||
item = self.config_table.item(row, col)
|
||
if item:
|
||
item.setText(value)
|
||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||
finally:
|
||
self.config_table.itemChanged.connect(self.on_table_item_changed)
|
||
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
|
||
|
||
def cell(col):
|
||
item = self.config_table.item(row, col)
|
||
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):
|
||
"""从表格同步全部配置"""
|
||
configs = []
|
||
for row in range(self.config_table.rowCount()):
|
||
def cell(col):
|
||
item = self.config_table.item(row, col)
|
||
return item.text().strip() if item else ""
|
||
|
||
configs.append({
|
||
"多多id": cell(0),
|
||
"序号": cell(1),
|
||
"话题": cell(2),
|
||
"定时发布": cell(3),
|
||
"间隔时间": cell(4),
|
||
"达人链接": cell(5),
|
||
"执行人": cell(6),
|
||
"情况": cell(7) or "待执行",
|
||
"文件路径": cell(8),
|
||
})
|
||
self.configs = configs
|
||
|
||
def update_table_selection_count(self):
|
||
"""更新已选行数量"""
|
||
if not hasattr(self, "table_select_count"):
|
||
return
|
||
if self.use_model_view and self.table_view.selectionModel():
|
||
rows = set(idx.row() for idx in self.table_view.selectionModel().selectedRows())
|
||
self.table_select_count.setText(f"已选: {len(rows)}")
|
||
return
|
||
rows = set()
|
||
for r in self.config_table.selectedRanges():
|
||
rows.update(range(r.topRow(), r.bottomRow() + 1))
|
||
self.table_select_count.setText(f"已选: {len(rows)}")
|
||
|
||
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 toggle_select_all_rows(self):
|
||
"""全选/取消全选当前页"""
|
||
if self.table_select_all.isChecked():
|
||
self.config_table.selectAll()
|
||
else:
|
||
self.config_table.clearSelection()
|
||
|
||
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 多选)"""
|
||
if logical_index >= 9:
|
||
return
|
||
modifiers = QApplication.keyboardModifiers()
|
||
is_multi = modifiers & Qt.ControlModifier
|
||
existing = 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[existing]
|
||
new_order = Qt.DescendingOrder if order == Qt.AscendingOrder else Qt.AscendingOrder
|
||
self.table_sort_keys[existing] = (col, new_order)
|
||
else:
|
||
if not is_multi:
|
||
self.table_sort_keys = []
|
||
self.table_sort_keys.append((logical_index, Qt.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 = {
|
||
0: "多多id",
|
||
1: "序号",
|
||
2: "话题",
|
||
3: "定时发布",
|
||
4: "间隔时间",
|
||
5: "达人链接",
|
||
6: "执行人",
|
||
7: "情况",
|
||
8: "文件路径",
|
||
}
|
||
# 数字类型的列(需要按数字排序)
|
||
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):
|
||
field = key_map.get(col, "")
|
||
self.configs.sort(
|
||
key=lambda cfg, f=field: get_sort_key(cfg, f),
|
||
reverse=(order == Qt.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_table_selection_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.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(9)]
|
||
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(9):
|
||
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.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(9)]
|
||
data = []
|
||
for row in range(self.config_table.rowCount()):
|
||
row_data = []
|
||
for col in range(9):
|
||
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()
|
||
if self.use_model_view:
|
||
if not self.table_proxy:
|
||
return
|
||
if not keyword_raw:
|
||
self.table_proxy.setFilterRegularExpression(QRegularExpression())
|
||
if hasattr(self, "table_filter_status"):
|
||
total_rows = self.table_proxy.rowCount()
|
||
self.table_filter_status.setText(f"显示: {total_rows}/{total_rows} | 命中: 0")
|
||
return
|
||
regex_enabled = self.table_regex.isChecked()
|
||
any_term = self.table_any_term.isChecked()
|
||
column_index = self.table_column_filter.currentIndex() - 1
|
||
self.table_proxy.setFilterKeyColumn(column_index if column_index >= 0 else -1)
|
||
if regex_enabled:
|
||
pattern = keyword_raw
|
||
else:
|
||
terms = [re.escape(t) for t in keyword_raw.split() if t]
|
||
if not terms:
|
||
pattern = ""
|
||
elif any_term:
|
||
pattern = "|".join(terms)
|
||
else:
|
||
pattern = "".join([f"(?=.*{t})" for t in terms]) + ".*"
|
||
regex = QRegularExpression(pattern)
|
||
if not self.table_case_sensitive.isChecked():
|
||
regex.setPatternOptions(QRegularExpression.CaseInsensitiveOption)
|
||
self.table_proxy.setFilterRegularExpression(regex)
|
||
if hasattr(self, "table_filter_status"):
|
||
self.table_filter_status.setText(
|
||
f"显示: {self.table_proxy.rowCount()}/{self.table_model.rowCount()} | 命中: 0")
|
||
return
|
||
if not self.config_table or self.config_table.rowCount() == 0:
|
||
if hasattr(self, "table_filter_status"):
|
||
self.table_filter_status.setText("显示: 0/0")
|
||
self._refresh_table_match_selector([])
|
||
return
|
||
if not keyword_raw:
|
||
# 清空筛选
|
||
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)
|
||
if hasattr(self, "table_filter_status"):
|
||
total_rows = self.config_table.rowCount()
|
||
self.table_filter_status.setText(f"显示: {total_rows}/{total_rows} | 命中: 0")
|
||
self._refresh_table_match_selector([])
|
||
return
|
||
terms_raw = [t for t in keyword_raw.split() if t]
|
||
keyword = keyword_raw if self.table_case_sensitive.isChecked() else keyword_raw.lower()
|
||
column_index = self.table_column_filter.currentIndex() - 1
|
||
visible_count = 0
|
||
match_count = 0
|
||
matched_rows = []
|
||
regex_enabled = self.table_regex.isChecked()
|
||
any_term = self.table_any_term.isChecked()
|
||
pattern = None
|
||
if keyword and regex_enabled:
|
||
flags = 0 if self.table_case_sensitive.isChecked() else re.IGNORECASE
|
||
try:
|
||
pattern = re.compile(keyword_raw, flags)
|
||
except re.error:
|
||
self._show_infobar("warning", "提示", "正则表达式无效")
|
||
return
|
||
for row in range(self.config_table.rowCount()):
|
||
match = False
|
||
for col in range(self.config_table.columnCount()):
|
||
if column_index >= 0 and col != column_index:
|
||
item = self.config_table.item(row, col)
|
||
if item:
|
||
item.setBackground(self._default_color())
|
||
continue
|
||
item = self.config_table.item(row, col)
|
||
if item:
|
||
cell_text = item.text()
|
||
cell_compare = cell_text if self.table_case_sensitive.isChecked() else cell_text.lower()
|
||
if keyword:
|
||
if regex_enabled and pattern:
|
||
if pattern.search(cell_text):
|
||
match = True
|
||
if self.table_highlight.isChecked():
|
||
item.setBackground(self._highlight_color())
|
||
match_count += 1
|
||
else:
|
||
item.setBackground(self._default_color())
|
||
else:
|
||
terms = terms_raw if self.table_case_sensitive.isChecked() else [t.lower() for t in
|
||
terms_raw]
|
||
term_hit = any(term in cell_compare for term in terms) if any_term else all(
|
||
term in cell_compare for term in terms
|
||
)
|
||
if term_hit:
|
||
match = True
|
||
if self.table_highlight.isChecked():
|
||
item.setBackground(self._highlight_color())
|
||
match_count += 1
|
||
else:
|
||
item.setBackground(self._default_color())
|
||
else:
|
||
item.setBackground(self._default_color())
|
||
else:
|
||
continue
|
||
only_match = self.table_only_match.isChecked()
|
||
self.config_table.setRowHidden(row, (not match) if (keyword and only_match) else False)
|
||
if keyword:
|
||
if match:
|
||
visible_count += 1
|
||
matched_rows.append(row)
|
||
else:
|
||
visible_count = self.config_table.rowCount()
|
||
if hasattr(self, "table_filter_status"):
|
||
self.table_filter_status.setText(
|
||
f"显示: {visible_count}/{self.config_table.rowCount()} | 命中: {match_count}"
|
||
)
|
||
self._refresh_table_match_selector(matched_rows)
|
||
|
||
def locate_table(self):
|
||
"""快速定位匹配行"""
|
||
keyword_raw = self.table_search_input.text().strip()
|
||
if not keyword_raw:
|
||
return
|
||
terms_raw = [t for t in keyword_raw.split() if t]
|
||
keyword = keyword_raw if self.table_case_sensitive.isChecked() else keyword_raw.lower()
|
||
column_index = self.table_column_filter.currentIndex() - 1
|
||
regex_enabled = self.table_regex.isChecked()
|
||
pattern = None
|
||
if keyword and regex_enabled:
|
||
flags = 0 if self.table_case_sensitive.isChecked() else re.IGNORECASE
|
||
try:
|
||
pattern = re.compile(keyword_raw, flags)
|
||
except re.error:
|
||
self._show_infobar("warning", "提示", "正则表达式无效")
|
||
return
|
||
for row in range(self.config_table.rowCount()):
|
||
for col in range(self.config_table.columnCount()):
|
||
if column_index >= 0 and col != column_index:
|
||
continue
|
||
item = self.config_table.item(row, col)
|
||
if item:
|
||
cell_text = item.text()
|
||
cell_compare = cell_text if self.table_case_sensitive.isChecked() else cell_text.lower()
|
||
if regex_enabled and pattern:
|
||
if pattern.search(cell_text):
|
||
self.config_table.setCurrentItem(item)
|
||
self.config_table.scrollToItem(item)
|
||
return
|
||
else:
|
||
terms = terms_raw if self.table_case_sensitive.isChecked() else [t.lower() for t in terms_raw]
|
||
term_hit = any(
|
||
term in cell_compare for term in terms) if self.table_any_term.isChecked() else all(
|
||
term in cell_compare for term in terms
|
||
)
|
||
if term_hit:
|
||
self.config_table.setCurrentItem(item)
|
||
self.config_table.scrollToItem(item)
|
||
return
|
||
self._show_infobar("warning", "提示", "未找到匹配内容")
|
||
|
||
def _apply_schedule_intervals(self, configs_with_rows):
|
||
"""按多多ID应用定时发布+间隔时间规则
|
||
|
||
规则说明:
|
||
1. 如果某条有定时时间,就按照定时时间发布,并记录为基准时间
|
||
2. 如果某条没有定时时间,也没有间隔时间,就是立即发布(不设置定时)
|
||
3. 如果某条没有定时时间,但有间隔时间,就是在上一条发布时间的基础上加上间隔时间发布
|
||
|
||
注意:按多多ID分组,同一个多多ID的所有数据按行顺序处理
|
||
"""
|
||
from collections import defaultdict
|
||
grouped = defaultdict(list)
|
||
for item in configs_with_rows:
|
||
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["row_idx"])
|
||
base_time = None # 上一条的发布时间(基准时间)
|
||
|
||
for entry in items:
|
||
config = entry["config"]
|
||
row_idx = entry["row_idx"]
|
||
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)
|
||
config["定时发布"] = new_text
|
||
self._update_table_cell(row_idx, 3, new_text, highlight=True)
|
||
updated_count += 1
|
||
# 如果没有基准时间,则保持空(立即发布)
|
||
|
||
# 情况3:当前条目没有定时时间,也没有间隔时间 -> 立即发布(保持空)
|
||
# 不做任何处理,保持原样
|
||
|
||
return updated_count
|
||
|
||
def _parse_schedule_time(self, 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 Exception:
|
||
return None
|
||
|
||
def _parse_interval_seconds(self, interval_value):
|
||
"""解析间隔时间,支持秒/分钟/小时(如: 30, 10m, 2h, 10分钟, 2小时)"""
|
||
if interval_value is None:
|
||
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)
|
||
# 支持 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 Exception:
|
||
return 0
|
||
return 0
|
||
|
||
def _format_schedule_time(self, dt):
|
||
"""格式化定时发布时间字符串"""
|
||
if not dt:
|
||
return ""
|
||
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
def _update_table_cell(self, row, col, value, highlight=False):
|
||
"""更新表格单元格并同步configs"""
|
||
try:
|
||
# 临时断开 itemChanged 信号,防止递归
|
||
self.config_table.itemChanged.disconnect(self.on_table_item_changed)
|
||
try:
|
||
item = QTableWidgetItem(str(value))
|
||
item.setTextAlignment(Qt.AlignCenter) # 居中对齐
|
||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||
if highlight:
|
||
item.setBackground(QColor("#E6F4FF"))
|
||
self.config_table.setItem(row, col, item)
|
||
if row < len(self.configs):
|
||
if col == 3: # 定时发布列
|
||
self.configs[row]["定时发布"] = str(value)
|
||
elif col == 7: # 情况列
|
||
self.configs[row]["情况"] = str(value)
|
||
finally:
|
||
# 重新连接信号
|
||
self.config_table.itemChanged.connect(self.on_table_item_changed)
|
||
except Exception as e:
|
||
logger.warning(f"更新表格单元格失败: {e}")
|
||
# 确保信号重新连接
|
||
try:
|
||
self.config_table.itemChanged.connect(self.on_table_item_changed)
|
||
except:
|
||
pass
|
||
|
||
def _update_table_status(self, row_idx, status):
|
||
"""更新表格中指定行的状态(情况列)"""
|
||
try:
|
||
# 检查表格是否存在
|
||
if not hasattr(self, 'config_table'):
|
||
return
|
||
|
||
if row_idx < 0 or row_idx >= self.config_table.rowCount():
|
||
return
|
||
|
||
# 临时断开 itemChanged 信号,防止递归
|
||
self.config_table.itemChanged.disconnect(self.on_table_item_changed)
|
||
try:
|
||
# 第7列是"情况"列
|
||
status_item = QTableWidgetItem(status)
|
||
status_item.setTextAlignment(Qt.AlignCenter) # 居中对齐
|
||
status_item.setFlags(status_item.flags() & ~Qt.ItemIsEditable)
|
||
|
||
# 根据状态设置不同的颜色
|
||
if status == "已完成":
|
||
status_item.setBackground(QColor("#D4EDDA")) # 浅绿色
|
||
status_item.setForeground(QColor("#155724")) # 深绿色文字
|
||
elif status == "失败":
|
||
status_item.setBackground(QColor("#F8D7DA")) # 浅红色
|
||
status_item.setForeground(QColor("#721C24")) # 深红色文字
|
||
elif status == "执行中":
|
||
status_item.setBackground(QColor("#D1ECF1")) # 浅蓝色
|
||
status_item.setForeground(QColor("#0C5460")) # 深蓝色文字
|
||
|
||
self.config_table.setItem(row_idx, 7, status_item)
|
||
|
||
# 同步更新configs
|
||
if hasattr(self, 'configs') and row_idx < len(self.configs):
|
||
self.configs[row_idx]["情况"] = status
|
||
finally:
|
||
# 重新连接信号
|
||
self.config_table.itemChanged.connect(self.on_table_item_changed)
|
||
except Exception as e:
|
||
logger.error(f"更新表格状态失败(行{row_idx}): {e}")
|
||
# 确保信号重新连接
|
||
try:
|
||
self.config_table.itemChanged.connect(self.on_table_item_changed)
|
||
except:
|
||
pass
|
||
|
||
def _refresh_table_match_selector(self, rows):
|
||
"""更新表格匹配列表"""
|
||
self.table_match_rows = rows
|
||
self.table_match_index = -1
|
||
if not hasattr(self, "table_match_selector"):
|
||
return
|
||
self.table_match_selector.blockSignals(True)
|
||
self.table_match_selector.clear()
|
||
self.table_match_selector.addItem(f"匹配列表({len(rows)})")
|
||
for row in rows[:200]:
|
||
self.table_match_selector.addItem(self._build_table_match_label(row))
|
||
self.table_match_selector.setCurrentIndex(0)
|
||
self.table_match_selector.blockSignals(False)
|
||
|
||
def _build_table_match_label(self, row):
|
||
"""构建表格匹配项显示文本"""
|
||
|
||
def cell_text(col):
|
||
item = self.config_table.item(row, col)
|
||
return item.text().strip() if item else ""
|
||
|
||
user_id = cell_text(0)
|
||
index = cell_text(1)
|
||
topic = cell_text(2)
|
||
label = " | ".join([v for v in [user_id, index, topic] if v])
|
||
if not label:
|
||
for col in range(self.config_table.columnCount()):
|
||
value = cell_text(col)
|
||
if value:
|
||
label = value
|
||
break
|
||
if len(label) > 60:
|
||
label = label[:57] + "..."
|
||
return f"R{row + 1}: {label}"
|
||
|
||
def jump_to_table_match(self, index):
|
||
"""跳转到表格匹配行"""
|
||
if index <= 0:
|
||
return
|
||
actual_index = index - 1
|
||
if actual_index >= len(self.table_match_rows):
|
||
return
|
||
row = self.table_match_rows[actual_index]
|
||
self.table_match_index = actual_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 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)
|
||
self.table_match_selector.setCurrentIndex(self.table_match_index + 1)
|
||
|
||
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)
|
||
self.table_match_selector.setCurrentIndex(self.table_match_index + 1)
|
||
|
||
def _highlight_color(self):
|
||
"""高亮颜色"""
|
||
return self.config_table.palette().color(self.config_table.palette().Highlight).lighter(160)
|
||
|
||
def _default_color(self):
|
||
"""默认背景色"""
|
||
return self.config_table.palette().color(self.config_table.palette().Base)
|
||
|
||
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 hasattr(self, 'config_table') or self.config_table.rowCount() == 0:
|
||
self._show_infobar("warning", "提示", "暂无数据")
|
||
return
|
||
|
||
# 切换筛选状态
|
||
current_filter = getattr(self, '_current_status_filter', None)
|
||
if current_filter == status:
|
||
# 再次点击同一状态,取消筛选,显示全部
|
||
self._current_status_filter = None
|
||
self._show_all_rows()
|
||
self._show_infobar("success", "提示", "已显示全部记录")
|
||
if hasattr(self, "table_filter_status"):
|
||
total = self.config_table.rowCount()
|
||
self.table_filter_status.setText(f"显示: {total}/{total} | 命中: 0")
|
||
return
|
||
|
||
self._current_status_filter = status
|
||
visible_count = 0
|
||
total_count = self.config_table.rowCount()
|
||
|
||
for row in range(total_count):
|
||
item = self.config_table.item(row, 7) # 第7列是"情况"列
|
||
if item:
|
||
cell_text = item.text()
|
||
# 根据状态匹配
|
||
if status == "成功":
|
||
match = "完成" in cell_text or "成功" in cell_text
|
||
elif status == "失败":
|
||
match = "失败" in cell_text or "错误" in cell_text
|
||
else:
|
||
match = status in cell_text
|
||
|
||
self.config_table.setRowHidden(row, not match)
|
||
if match:
|
||
visible_count += 1
|
||
else:
|
||
self.config_table.setRowHidden(row, True)
|
||
|
||
if hasattr(self, "table_filter_status"):
|
||
self.table_filter_status.setText(f"显示: {visible_count}/{total_count} | 筛选: {status}")
|
||
|
||
if visible_count == 0:
|
||
self._show_infobar("warning", "提示", f"没有{status}的记录")
|
||
else:
|
||
self._show_infobar("success", "筛选", f"已筛选出 {visible_count} 条{status}记录,再次点击取消筛选")
|
||
|
||
def _update_status_statistics(self):
|
||
"""更新状态统计(成功/失败/待执行数量)"""
|
||
if not hasattr(self, 'configs') or not self.configs:
|
||
self.set_status_cards(pending=0, success=0, failed=0)
|
||
return
|
||
|
||
pending_count = 0
|
||
success_count = 0
|
||
failed_count = 0
|
||
|
||
for config in self.configs:
|
||
status = config.get('情况', '待执行')
|
||
if "完成" in status or "成功" in status:
|
||
success_count += 1
|
||
elif "失败" in status or "错误" in status:
|
||
failed_count += 1
|
||
elif "待" in status or not status:
|
||
pending_count += 1
|
||
|
||
self.set_status_cards(pending=pending_count, 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:
|
||
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:
|
||
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:
|
||
self._refresh_log_match_selector([])
|
||
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
|
||
self.log_match_items = []
|
||
if not keyword:
|
||
self._update_log_match_status(0)
|
||
self._refresh_log_match_selector([])
|
||
return
|
||
content = self.log_text.toPlainText()
|
||
use_regex = self.log_regex.isChecked()
|
||
case_sensitive = self.log_case_sensitive.isChecked()
|
||
whole_word = self.log_whole_word.isChecked()
|
||
flags = 0 if case_sensitive else re.IGNORECASE
|
||
if use_regex:
|
||
pattern_text = keyword
|
||
if whole_word:
|
||
pattern_text = rf"\b(?:{pattern_text})\b"
|
||
try:
|
||
pattern = re.compile(pattern_text, flags)
|
||
except re.error:
|
||
self._show_infobar("warning", "提示", "日志正则表达式无效")
|
||
self._update_log_match_status(0)
|
||
return
|
||
else:
|
||
pattern_text = re.escape(keyword)
|
||
if whole_word:
|
||
pattern_text = rf"\b{pattern_text}\b"
|
||
pattern = re.compile(pattern_text, flags)
|
||
for match in pattern.finditer(content):
|
||
self.log_match_positions.append((match.start(), match.end()))
|
||
self.log_match_items.append(self._build_log_match_label(content, match.start(), match.end()))
|
||
self._update_log_match_status(len(self.log_match_positions))
|
||
self._refresh_log_match_selector(self.log_match_items)
|
||
|
||
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):
|
||
"""应用配置表列宽(不随内容变化)"""
|
||
widths = {
|
||
0: 120, # 多多ID
|
||
1: 80, # 序号
|
||
2: 160, # 话题
|
||
3: 160, # 定时发布
|
||
4: 110, # 间隔时间
|
||
5: 200, # 达人链接(固定宽度,超长显示省略号)
|
||
6: 120, # 执行人
|
||
7: 100, # 情况
|
||
8: 220, # 文件路径
|
||
9: 90, # 进度
|
||
10: 140 # 操作
|
||
}
|
||
for col, width in widths.items():
|
||
if col < self.config_table.columnCount():
|
||
self.config_table.setColumnWidth(col, width)
|
||
# 所有列都可以手动调整宽度(Interactive模式)
|
||
|
||
def _apply_table_view_column_widths(self):
|
||
"""应用 Model/View 列宽"""
|
||
widths = {
|
||
0: 120,
|
||
1: 80,
|
||
2: 160,
|
||
3: 160,
|
||
4: 110,
|
||
5: 200, # 达人链接(固定宽度)
|
||
6: 120,
|
||
7: 100,
|
||
8: 220,
|
||
9: 90,
|
||
10: 140
|
||
}
|
||
for col, width in widths.items():
|
||
if self.table_view.model() and col < self.table_view.model().columnCount():
|
||
self.table_view.setColumnWidth(col, width)
|
||
# 所有列都可以手动调整宽度(Interactive模式)
|
||
|
||
def _refresh_log_match_selector(self, items):
|
||
"""更新日志匹配下拉"""
|
||
if not hasattr(self, "log_match_selector"):
|
||
return
|
||
self.log_match_selector.blockSignals(True)
|
||
self.log_match_selector.clear()
|
||
self.log_match_selector.addItem(f"匹配列表({len(items)})")
|
||
for label in items[:200]:
|
||
self.log_match_selector.addItem(label)
|
||
self.log_match_selector.setCurrentIndex(0)
|
||
self.log_match_selector.blockSignals(False)
|
||
|
||
def _build_log_match_label(self, content, start, end):
|
||
"""构建匹配项显示文本"""
|
||
line_start = content.rfind("\n", 0, start) + 1
|
||
line_end = content.find("\n", end)
|
||
if line_end == -1:
|
||
line_end = len(content)
|
||
line_text = content[line_start:line_end].strip()
|
||
line_no = content.count("\n", 0, start) + 1
|
||
if len(line_text) > 60:
|
||
line_text = line_text[:57] + "..."
|
||
return f"L{line_no}: {line_text}"
|
||
|
||
def jump_to_log_match(self, index):
|
||
"""跳转到指定匹配"""
|
||
if index <= 0:
|
||
return
|
||
actual_index = index - 1
|
||
if actual_index >= len(self.log_match_positions):
|
||
return
|
||
start, end = self.log_match_positions[actual_index]
|
||
self.log_match_index = actual_index
|
||
cursor = self.log_text.textCursor()
|
||
cursor.setPosition(start)
|
||
cursor.setPosition(end, QTextCursor.KeepAnchor)
|
||
self.log_text.setTextCursor(cursor)
|
||
self.log_text.ensureCursorVisible()
|
||
|
||
def _show_infobar(self, level, title, content):
|
||
"""显示提示条"""
|
||
if level == "success":
|
||
InfoBar.success(title=title, content=content, parent=self, position=InfoBarPosition.TOP_RIGHT)
|
||
elif level == "warning":
|
||
InfoBar.warning(title=title, content=content, parent=self, position=InfoBarPosition.TOP_RIGHT)
|
||
else:
|
||
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
|
||
|
||
# 转换为配置列表,添加异常处理
|
||
self.configs = []
|
||
for idx, row in df.iterrows():
|
||
try:
|
||
config = {
|
||
'多多id': str(row.get('多多id', '')).strip() if pd.notna(row.get('多多id')) else '',
|
||
'序号': str(row.get('序号', '')).strip() if pd.notna(row.get('序号')) else '',
|
||
'话题': str(row.get('话题', '')).strip() if pd.notna(row.get('话题')) else '',
|
||
'定时发布': str(row.get('定时发布', '')).strip() if pd.notna(row.get('定时发布')) else '',
|
||
'间隔时间': int(row.get('间隔时间', 0)) if pd.notna(row.get('间隔时间')) else 0,
|
||
'达人链接': str(row.get('达人链接', '')).strip() if pd.notna(row.get('达人链接')) else '',
|
||
'执行人': str(row.get('执行人', '')).strip() if pd.notna(row.get('执行人')) else '',
|
||
'情况': str(row.get('情况', '待执行')).strip() if pd.notna(row.get('情况')) else '待执行',
|
||
'文件路径': '' # 文件路径字段初始为空,通过更新数据按钮填充
|
||
}
|
||
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.AscendingOrder)
|
||
|
||
# 更新表格显示
|
||
try:
|
||
self.update_table()
|
||
except Exception as e:
|
||
self._show_infobar("error", "错误", f"更新表格失败: {str(e)}")
|
||
logger.error(f"更新表格失败: {e}")
|
||
return
|
||
|
||
# 显示表格
|
||
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 update_table(self):
|
||
"""更新配置表格"""
|
||
self.is_updating_table = True
|
||
total_rows = len(self.configs)
|
||
if total_rows > 1000:
|
||
self._setup_model_view()
|
||
self.is_updating_table = False
|
||
return
|
||
self.use_model_view = False
|
||
if hasattr(self, "table_view"):
|
||
self.table_view.setVisible(False)
|
||
if hasattr(self, "config_table"):
|
||
self.config_table.setVisible(True)
|
||
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(True)
|
||
if hasattr(self, "table_select_all"):
|
||
self.table_select_all.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(range(start, end))
|
||
|
||
# 临时禁用排序,防止填充数据时自动排序打乱顺序
|
||
self.config_table.setSortingEnabled(False)
|
||
|
||
self.config_table.setRowCount(len(self.page_row_indices))
|
||
for table_row, config_index in enumerate(self.page_row_indices):
|
||
config = self.configs[config_index]
|
||
self.config_table.setItem(table_row, 0, self._create_centered_item(str(config.get('多多id', ''))))
|
||
self.config_table.setItem(table_row, 1, self._create_centered_item(str(config.get('序号', ''))))
|
||
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('间隔时间', 0))))
|
||
# 达人链接列:设置 tooltip 显示完整内容
|
||
url_text = str(config.get('达人链接', ''))
|
||
url_item = self._create_centered_item(url_text)
|
||
url_item.setToolTip(url_text) # 悬停显示完整链接
|
||
self.config_table.setItem(table_row, 5, url_item)
|
||
self.config_table.setItem(table_row, 6, self._create_centered_item(str(config.get('执行人', ''))))
|
||
self._set_status_item(table_row, str(config.get('情况', '待执行')))
|
||
# 文件路径列(第8列,索引为8),如果配置中没有则显示空,设置 tooltip
|
||
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, 8, file_path_item)
|
||
self._set_progress_item(table_row, str(config.get('情况', '待执行')))
|
||
self._set_action_buttons(table_row, config_index)
|
||
|
||
# 重新启用排序功能(但不会自动排序已填充的数据)
|
||
self.config_table.setSortingEnabled(True)
|
||
|
||
# 固定列宽(不随内容自适应)
|
||
self._apply_table_column_widths()
|
||
# 未更新前,用配置行数作为待执行提示
|
||
self.set_status_cards(pending=self.config_table.rowCount())
|
||
if hasattr(self, "table_filter_status"):
|
||
self.table_filter_status.setText(
|
||
f"显示: {self.config_table.rowCount()}/{total_rows} | 命中: 0"
|
||
)
|
||
if hasattr(self, "table_empty_label"):
|
||
self.table_empty_label.setVisible(total_rows == 0)
|
||
if hasattr(self, "page_info_label"):
|
||
self.page_info_label.setText(f"第 {self.current_page}/{total_pages} 页")
|
||
self.is_updating_table = False
|
||
self.update_table_selection_count()
|
||
|
||
def _setup_model_view(self):
|
||
"""切换到大数据量 Model/View 模式"""
|
||
headers = ['多多ID', '序号', '话题', '定时发布', '间隔时间', '达人链接', '执行人', '情况', '文件路径', '进度',
|
||
'操作']
|
||
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)
|
||
if self.table_view.selectionModel():
|
||
self.table_view.selectionModel().selectionChanged.connect(self.update_table_selection_count)
|
||
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(False)
|
||
header.setSectionResizeMode(QHeaderView.Interactive)
|
||
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_select_all"):
|
||
self.table_select_all.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._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(row, col, default=0):
|
||
"""安全获取单元格整数"""
|
||
item = self.config_table.item(row, col)
|
||
if item and item.text().strip():
|
||
try:
|
||
return int(item.text().strip())
|
||
except ValueError:
|
||
return default
|
||
return default
|
||
|
||
config = {
|
||
'多多id': get_cell_text(row_index, 0),
|
||
'序号': get_cell_text(row_index, 1),
|
||
'话题': get_cell_text(row_index, 2),
|
||
'定时发布': get_cell_text(row_index, 3),
|
||
'间隔时间': get_cell_int(row_index, 4, 0),
|
||
'达人链接': get_cell_text(row_index, 5),
|
||
'执行人': get_cell_text(row_index, 6),
|
||
'情况': get_cell_text(row_index, 7) or '待执行',
|
||
'文件路径': get_cell_text(row_index, 8) # 第8列是文件路径
|
||
}
|
||
|
||
return config
|
||
|
||
def get_config(self):
|
||
"""获取当前配置数据(已废弃,配置从Excel导入)"""
|
||
# 配置现在从Excel导入,此方法返回None表示需要先导入Excel
|
||
return None
|
||
|
||
def update_data(self):
|
||
"""更新数据:找出文件并保存到表格的文件路径列(更新所有行)"""
|
||
try:
|
||
# 检查是否有Excel导入的配置表格
|
||
if self.configs and self.config_table.rowCount() > 0:
|
||
# 获取文件夹路径
|
||
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"共有 {self.config_table.rowCount()} 行需要更新")
|
||
|
||
# 遍历所有行,更新每行的文件路径
|
||
total_found = 0
|
||
for row_idx in range(self.config_table.rowCount()):
|
||
config = self.get_config_from_table(row_idx)
|
||
if not config:
|
||
self.log_text.append(f"第 {row_idx + 1} 行:无法获取配置数据,跳过")
|
||
continue
|
||
|
||
# 验证必填字段
|
||
if not config.get('多多id') or not config.get('序号'):
|
||
self.log_text.append(f"第 {row_idx + 1} 行:多多ID或序号为空,跳过")
|
||
continue
|
||
|
||
self.log_text.append(f"正在更新第 {row_idx + 1} 行的文件路径...")
|
||
self.log_text.append(f" 多多ID: {config.get('多多id')}, 序号: {config.get('序号')}")
|
||
|
||
# 查找该行对应的文件
|
||
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])
|
||
self.config_table.setItem(row_idx, 8, QTableWidgetItem(file_paths_str))
|
||
|
||
# 同时更新self.configs中对应的配置
|
||
if row_idx < len(self.configs):
|
||
self.configs[row_idx]['文件路径'] = file_paths_str
|
||
|
||
video_count = sum(1 for f in found_files if f['path'].is_file() and any(
|
||
f['path'].suffix.lower() in ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm'] for
|
||
ext in ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm']))
|
||
self.log_text.append(f" ✓ 找到 {len(found_files)} 个文件({video_count} 个视频)")
|
||
total_found += len(found_files)
|
||
else:
|
||
# 清空文件路径列
|
||
self.config_table.setItem(row_idx, 8, QTableWidgetItem(""))
|
||
if row_idx < len(self.configs):
|
||
self.configs[row_idx]['文件路径'] = ""
|
||
self.log_text.append(f" ✗ 未找到匹配的文件")
|
||
|
||
self.log_text.append("=" * 50)
|
||
self.log_text.append(f"批量更新完成!共更新 {self.config_table.rowCount()} 行,找到 {total_found} 个文件")
|
||
self.update_status_label.setText(f"已更新: {self.config_table.rowCount()}行,{total_found}个文件")
|
||
self.update_status_label.setStyleSheet("color: #4CAF50; font-size: 10px;")
|
||
self.set_status_cards(update_text=f"已更新: {self.config_table.rowCount()}行", pending=total_found)
|
||
# 更新映射(多多ID+序号 -> 行号),确保后续逐条回写状态准确
|
||
self._rebuild_row_map()
|
||
# 计算并应用间隔时间规则(按多多ID分组)
|
||
self.log_text.append("=" * 50)
|
||
self.log_text.append("开始计算间隔时间并更新定时发布时间...")
|
||
configs_with_rows = []
|
||
for row_idx in range(self.config_table.rowCount()):
|
||
config = self.get_config_from_table(row_idx)
|
||
if config:
|
||
configs_with_rows.append({"row_idx": row_idx, "config": config})
|
||
|
||
# 应用间隔时间规则,自动计算并更新表格中的定时发布时间
|
||
updated_count = self._apply_schedule_intervals(configs_with_rows)
|
||
if updated_count > 0:
|
||
self.log_text.append(f"✓ 已自动计算并更新 {updated_count} 行的定时发布时间")
|
||
else:
|
||
self.log_text.append("未找到需要自动计算定时时间的数据")
|
||
self.log_text.append("=" * 50)
|
||
self._show_infobar("success", "成功",
|
||
f"已更新文件路径:{self.config_table.rowCount()}行,{total_found}个文件")
|
||
return
|
||
else:
|
||
# 没有Excel导入的配置,提示用户先导入
|
||
self._show_infobar("warning", "提示", "请先导入Excel配置文件")
|
||
return
|
||
self.update_status_label.setText("未找到文件")
|
||
self.update_status_label.setStyleSheet("color: #f44336; font-size: 10px;")
|
||
self.set_status_cards(update_text="未找到文件", pending=0)
|
||
self._show_infobar("warning", "警告", "未找到匹配的文件")
|
||
|
||
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()
|
||
|
||
def _find_files_for_config(self, config, folder_path):
|
||
"""根据配置查找文件(辅助方法)"""
|
||
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
|
||
|
||
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:
|
||
subdirs = [f for f in os.listdir(folder_path)
|
||
if os.path.isdir(os.path.join(folder_path, f))]
|
||
except PermissionError:
|
||
logger.error(f"没有权限访问文件夹: {folder_path}")
|
||
return found_files
|
||
except Exception as e:
|
||
logger.error(f"读取文件夹失败: {e}")
|
||
return found_files
|
||
|
||
# 找到匹配当前多多ID的文件夹
|
||
target_subdir = None
|
||
user_id = str(config.get('多多id', '')).strip()
|
||
if not user_id:
|
||
logger.warning("多多ID为空,无法查找文件")
|
||
return found_files
|
||
|
||
for subdir_name in subdirs:
|
||
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
|
||
|
||
# 扫描该文件夹下的文件
|
||
try:
|
||
items = os.listdir(target_subdir)
|
||
except PermissionError:
|
||
logger.error(f"没有权限访问子文件夹: {target_subdir}")
|
||
return found_files
|
||
except Exception as e:
|
||
logger.error(f"读取子文件夹失败: {e}")
|
||
return found_files
|
||
|
||
for item_name in items:
|
||
try:
|
||
item_path = os.path.join(target_subdir, item_name)
|
||
|
||
# 跳过隐藏文件/文件夹
|
||
if item_name.startswith('.'):
|
||
continue
|
||
|
||
name_parts = item_name.split("-")
|
||
|
||
# 检查序号是否匹配
|
||
if len(name_parts) > 0 and name_parts[0] == index:
|
||
path_obj = Path(item_path)
|
||
|
||
# 确保路径存在
|
||
if not path_obj.exists():
|
||
continue
|
||
|
||
if path_obj.is_file():
|
||
# 检查是否为视频文件
|
||
if any(path_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": path_obj
|
||
})
|
||
elif path_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": path_obj
|
||
})
|
||
except Exception as e:
|
||
logger.warning(f"处理文件 {item_name} 时出错: {e}")
|
||
continue # 继续处理其他文件
|
||
|
||
except Exception as e:
|
||
logger.error(f"查找文件失败: {e}", exc_info=True)
|
||
|
||
return found_files
|
||
|
||
def execute_task(self):
|
||
"""执行任务"""
|
||
try:
|
||
# 检查是否有Excel导入的配置
|
||
if self.configs:
|
||
# 如果有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.execute_btn.setEnabled(True)
|
||
self.progress_bar.setVisible(False)
|
||
|
||
def execute_batch_from_excel(self):
|
||
"""从Excel配置批量执行(自动判断相同多多ID的mp4文件,批量上传)"""
|
||
# 获取文件夹路径,如果为空则使用默认路径
|
||
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
|
||
|
||
# 从表格中获取所有配置(使用用户修改后的值)
|
||
if self.config_table.rowCount() == 0:
|
||
self._show_infobar("warning", "警告", "配置列表为空,请先导入Excel配置")
|
||
return
|
||
|
||
self.log_text.append("=" * 50)
|
||
self.log_text.append("开始分析配置,准备批量上传...")
|
||
|
||
# 收集所有配置及其对应的文件
|
||
all_configs_with_files = []
|
||
configs_with_rows = []
|
||
video_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm']
|
||
|
||
for row_idx in range(self.config_table.rowCount()):
|
||
config = self.get_config_from_table(row_idx)
|
||
if not config:
|
||
continue
|
||
|
||
# 验证必填字段
|
||
if not config.get('多多id') or not config.get('序号'):
|
||
self.log_text.append(f"第 {row_idx + 1} 行:多多ID或序号为空,跳过")
|
||
continue
|
||
|
||
# 添加文件夹路径
|
||
config['文件夹路径'] = folder_path
|
||
configs_with_rows.append({"row_idx": row_idx, "config": config})
|
||
|
||
# 应用定时发布 + 间隔时间逻辑(按多多ID分组)
|
||
self._apply_schedule_intervals(configs_with_rows)
|
||
|
||
for item in configs_with_rows:
|
||
row_idx = item["row_idx"]
|
||
config = item["config"]
|
||
|
||
# 从文件路径列读取文件路径
|
||
file_path_str = config.get('文件路径', '').strip()
|
||
if file_path_str:
|
||
# 文件路径可能用分号分隔(多个文件)
|
||
file_paths = [p.strip() for p in file_path_str.split(';') if p.strip()]
|
||
files = []
|
||
for fp in file_paths:
|
||
if os.path.exists(fp):
|
||
path_obj = Path(fp)
|
||
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_obj
|
||
})
|
||
|
||
if files:
|
||
all_configs_with_files.append({
|
||
'config': config,
|
||
'files': files,
|
||
'row_idx': row_idx
|
||
})
|
||
self.log_text.append(f"第 {row_idx + 1} 行:从文件路径列读取到 {len(files)} 个文件")
|
||
else:
|
||
# 如果文件路径列为空,尝试查找文件
|
||
self.log_text.append(f"第 {row_idx + 1} 行:文件路径列为空,尝试查找文件...")
|
||
found_files = self._find_files_for_config(config, folder_path)
|
||
if found_files:
|
||
all_configs_with_files.append({
|
||
'config': config,
|
||
'files': found_files,
|
||
'row_idx': row_idx
|
||
})
|
||
self.log_text.append(f"第 {row_idx + 1} 行:找到 {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)
|
||
pending_tasks = total_tasks
|
||
self.set_status_cards(pending=pending_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)
|
||
|
||
self.log_text.append("=" * 50)
|
||
self.log_text.append(f"按多多ID分组:共 {len(grouped_by_user_id)} 个不同的多多ID")
|
||
|
||
# 检查是否有正在运行的线程
|
||
try:
|
||
if self.worker_thread and self.worker_thread.isRunning():
|
||
self._show_infobar("warning", "警告", "已有任务正在执行,请等待完成")
|
||
return
|
||
except Exception as e:
|
||
logger.warning(f"检查线程状态时出错: {e}")
|
||
# 如果检查失败,重置 worker_thread
|
||
self.worker_thread = None
|
||
|
||
# 禁用按钮
|
||
self.execute_btn.setEnabled(False)
|
||
self.progress_bar.setVisible(True)
|
||
self.progress_bar.setValue(0)
|
||
self.set_status_cards(running=1)
|
||
|
||
# 构建任务队列(不阻塞GUI线程)
|
||
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
|
||
|
||
# 处理每个多多ID组,构建任务队列
|
||
for user_id, items in grouped_by_user_id.items():
|
||
self.log_text.append(f"\n处理多多ID: {user_id},共 {len(items)} 个配置")
|
||
|
||
# 收集该多多ID下的所有文件
|
||
all_files = []
|
||
try:
|
||
for item in items:
|
||
files = item.get('files', [])
|
||
if files:
|
||
all_files.extend(files)
|
||
except Exception as e:
|
||
logger.warning(f"收集文件时出错: {e}")
|
||
continue
|
||
|
||
# 分离视频文件和图片文件夹
|
||
video_files = [f for f in all_files if
|
||
f['path'].is_file() and any(f['path'].suffix.lower() == ext for ext in video_extensions)]
|
||
image_folders = [f for f in all_files if f['path'].is_dir()]
|
||
|
||
self.log_text.append(f" 视频文件: {len(video_files)} 个")
|
||
self.log_text.append(f" 图片文件夹: {len(image_folders)} 个")
|
||
|
||
# 使用第一个配置创建Pdd实例(因为同一个多多ID,配置应该相同)
|
||
if not items or len(items) == 0:
|
||
continue
|
||
first_config = items[0].get('config', {})
|
||
if not first_config:
|
||
continue
|
||
pdd = Pdd(
|
||
url=first_config.get('达人链接', ''),
|
||
user_id=user_id,
|
||
time_start=first_config.get('定时发布', '') if first_config.get('定时发布') else None,
|
||
ht=first_config.get('话题', ''),
|
||
index=first_config.get('序号', ''),
|
||
title=first_config.get('标题', None)
|
||
)
|
||
|
||
# 第一步:如果有多个视频文件(>1),批量上传所有视频
|
||
if len(video_files) > 1:
|
||
# 检查是否都是mp4文件
|
||
all_mp4 = all(f['path'].suffix.lower() == '.mp4' for f in video_files)
|
||
if all_mp4:
|
||
self.log_text.append(f" ✓ 检测到 {len(video_files)} 个mp4文件,批量上传所有视频(action1方法)")
|
||
else:
|
||
self.log_text.append(f" ✓ 检测到 {len(video_files)} 个视频文件,批量上传所有视频(action1方法)")
|
||
|
||
# 添加到任务队列
|
||
# 收集所有相关的行索引(根据文件中的index和user_id匹配)
|
||
related_row_indices = []
|
||
try:
|
||
for video_file in video_files:
|
||
file_index = video_file.get('index', '')
|
||
for item in items:
|
||
if item.get('config', {}).get('序号', '') == file_index:
|
||
row_idx = item.get('row_idx')
|
||
if row_idx is not None and row_idx not in related_row_indices:
|
||
related_row_indices.append(row_idx)
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"收集行索引时出错: {e}")
|
||
related_row_indices = []
|
||
|
||
self.batch_task_queue.append({
|
||
'type': 'batch_video',
|
||
'config': first_config,
|
||
'files': video_files,
|
||
'user_id': user_id,
|
||
'count': len(video_files),
|
||
'row_indices': related_row_indices # 添加行索引列表
|
||
})
|
||
elif len(video_files) == 1:
|
||
# 只有1个视频,单个上传
|
||
self.log_text.append(f" → 只有1个视频文件,单个上传")
|
||
# 添加到任务队列
|
||
# 收集相关的行索引
|
||
related_row_indices = []
|
||
try:
|
||
if video_files:
|
||
file_index = video_files[0].get('index', '')
|
||
for item in items:
|
||
if item.get('config', {}).get('序号', '') == file_index:
|
||
row_idx = item.get('row_idx')
|
||
if row_idx is not None:
|
||
related_row_indices.append(row_idx)
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"收集行索引时出错: {e}")
|
||
related_row_indices = []
|
||
|
||
self.batch_task_queue.append({
|
||
'type': 'single_video',
|
||
'config': first_config,
|
||
'files': video_files,
|
||
'user_id': user_id,
|
||
'count': 1,
|
||
'row_indices': related_row_indices # 添加行索引列表
|
||
})
|
||
|
||
# 第二步:如果有图片文件夹,逐个上传图片
|
||
if image_folders:
|
||
self.log_text.append(f" → 准备上传 {len(image_folders)} 个图片文件夹")
|
||
for idx, img_folder in enumerate(image_folders, 1):
|
||
# 找到该图片文件夹对应的配置(通过序号匹配)
|
||
folder_index = img_folder.get('index', '')
|
||
matching_config = None
|
||
try:
|
||
for item in items:
|
||
config = item.get('config', {})
|
||
if config.get('序号', '') == folder_index:
|
||
matching_config = config
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"查找匹配配置时出错: {e}")
|
||
|
||
# 如果找不到匹配的配置,使用第一个配置
|
||
if not matching_config:
|
||
matching_config = first_config
|
||
|
||
# 添加到任务队列
|
||
# 找到对应的行索引
|
||
related_row_indices = []
|
||
try:
|
||
for item in items:
|
||
if item.get('config', {}).get('序号', '') == folder_index:
|
||
row_idx = item.get('row_idx')
|
||
if row_idx is not None:
|
||
related_row_indices.append(row_idx)
|
||
break
|
||
except Exception as e:
|
||
logger.warning(f"收集行索引时出错: {e}")
|
||
related_row_indices = []
|
||
|
||
self.batch_task_queue.append({
|
||
'type': 'image_folder',
|
||
'config': matching_config,
|
||
'files': [img_folder],
|
||
'user_id': user_id,
|
||
'folder_index': folder_index,
|
||
'count': 1,
|
||
'index': idx,
|
||
'total': len(image_folders),
|
||
'row_indices': related_row_indices # 添加行索引列表
|
||
})
|
||
|
||
# 开始执行任务队列(异步,不阻塞GUI)
|
||
if self.batch_task_queue:
|
||
self.log_text.append("=" * 50)
|
||
self.log_text.append(f"任务队列构建完成,共 {len(self.batch_task_queue)} 个任务")
|
||
self._process_next_batch_task()
|
||
else:
|
||
self._show_infobar("warning", "警告", "未找到任何任务")
|
||
self.execute_btn.setEnabled(True)
|
||
self.progress_bar.setVisible(False)
|
||
|
||
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.execute_btn.setEnabled(True)
|
||
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']
|
||
user_id = task['user_id']
|
||
|
||
# 根据任务类型处理
|
||
try:
|
||
# 清理旧线程(如果存在)
|
||
try:
|
||
if self.worker_thread:
|
||
try:
|
||
if self.worker_thread.isRunning():
|
||
# 尽量优雅停止,避免 terminate 导致 Qt 对象被强杀后崩溃
|
||
try:
|
||
self.worker_thread.stop()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
self.worker_thread.requestInterruption()
|
||
except Exception:
|
||
pass
|
||
self.worker_thread.wait(1500)
|
||
self.worker_thread.finished.disconnect()
|
||
self.worker_thread.log_message.disconnect()
|
||
self.worker_thread.progress.disconnect()
|
||
try:
|
||
self.worker_thread.item_result.disconnect()
|
||
except Exception:
|
||
pass
|
||
except Exception:
|
||
pass
|
||
self.worker_thread = None
|
||
except Exception as e:
|
||
logger.warning(f"清理旧线程时出错: {e}")
|
||
self.worker_thread = None
|
||
|
||
# 验证 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:
|
||
row_indices = task.get('row_indices', [])
|
||
if row_indices:
|
||
for row_idx in row_indices:
|
||
try:
|
||
self._update_table_status(row_idx, "执行中")
|
||
except Exception as e:
|
||
logger.warning(f"更新状态失败(行{row_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)
|
||
# 即使失败也继续下一个任务
|
||
self.batch_processed += task.get('count', 1)
|
||
self.batch_pending_tasks = max(self.batch_pending_tasks - task.get('count', 1), 0)
|
||
self.set_status_cards(pending=self.batch_pending_tasks)
|
||
self.set_running_progress(self.batch_processed, self.batch_total_tasks)
|
||
self.current_batch_task_index += 1
|
||
# 继续处理下一个任务
|
||
QApplication.processEvents() # 处理GUI事件
|
||
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)
|
||
row_indices = task.get('row_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':
|
||
idx = task.get('index', 0)
|
||
self.log_text.append(f" ✓ 图片文件夹 {idx} 上传完成")
|
||
|
||
# 更新表格状态为"已完成"
|
||
for row_idx in row_indices:
|
||
try:
|
||
self._update_table_status(row_idx, "已完成")
|
||
except Exception as e:
|
||
logger.warning(f"更新状态失败(行{row_idx}): {e}")
|
||
else:
|
||
self.log_text.append(f" ✗ 任务失败: {message}")
|
||
# 更新表格状态为"失败"
|
||
for row_idx in row_indices:
|
||
try:
|
||
self._update_table_status(row_idx, "失败")
|
||
except Exception as e:
|
||
logger.warning(f"更新状态失败(行{row_idx}): {e}")
|
||
|
||
# 更新统计
|
||
self.batch_processed += count
|
||
self.batch_pending_tasks = max(self.batch_pending_tasks - count, 0)
|
||
self.set_status_cards(pending=self.batch_pending_tasks)
|
||
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} 秒后执行下一个任务...")
|
||
|
||
# 处理下一个任务(使用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 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"):
|
||
self._row_map_by_user_index = {}
|
||
return
|
||
for r in range(self.config_table.rowCount()):
|
||
user_id = self.get_cell_text(r, 0)
|
||
idx = self.get_cell_text(r, 1)
|
||
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):
|
||
"""
|
||
接收自动化侧逐条结果:
|
||
payload: {user_id, index, ok, reason, name, ...}
|
||
"""
|
||
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 or not idx:
|
||
return
|
||
|
||
if not self._row_map_by_user_index:
|
||
self._rebuild_row_map()
|
||
|
||
row = self._row_map_by_user_index.get((user_id, idx))
|
||
if row is None:
|
||
# 尝试刷新一次映射
|
||
self._rebuild_row_map()
|
||
row = self._row_map_by_user_index.get((user_id, idx))
|
||
if row is None:
|
||
# 找不到对应行,仍然写日志
|
||
self.log_text.append(f"[结果] user={user_id} 序号={idx} ok={ok} {reason}")
|
||
return
|
||
|
||
self._update_table_status(row, "已完成" if ok else "失败")
|
||
# 记录每条的详细日志
|
||
label = name if name else payload.get("path", "") or ""
|
||
self.log_text.append(f"[结果] R{row+1} user={user_id} 序号={idx} ok={ok} {label} {reason}")
|
||
except Exception as e:
|
||
logger.warning(f"处理单条结果失败: {e}")
|
||
|
||
def count_videos_in_folder(self, 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 Exception 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.execute_btn.setEnabled(True)
|
||
|
||
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
|
||
try:
|
||
if self.worker_thread:
|
||
# 停止线程
|
||
self.worker_thread.stop()
|
||
# 等待线程结束,设置超时避免无限等待
|
||
if not self.worker_thread.wait(3000): # 等待3秒
|
||
logger.warning("线程未能及时停止,强制终止")
|
||
self.worker_thread.terminate()
|
||
self.worker_thread.wait(1000) # 再等待1秒
|
||
|
||
# 断开信号连接
|
||
try:
|
||
self.worker_thread.finished.disconnect()
|
||
self.worker_thread.log_message.disconnect()
|
||
self.worker_thread.progress.disconnect()
|
||
except Exception:
|
||
pass
|
||
|
||
self.worker_thread = None
|
||
except Exception as e:
|
||
logger.error(f"关闭线程时出错: {e}")
|
||
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 as e:
|
||
# 如果异常处理器本身出错,使用默认处理器
|
||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||
|
||
def main():
|
||
# 设置全局异常处理器
|
||
sys.excepthook = exception_handler
|
||
|
||
app = QApplication(sys.argv)
|
||
|
||
# 解决 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()
|
||
# docker run honeygain/honeygain -tou-accept -email ddrwode1@gmail.com -pass 040828cjj -device DEVICE_NAME
|