Files
feiqiu-ETL/etl_billiards/gui/widgets/task_manager.py
2026-01-27 22:14:01 +08:00

1949 lines
77 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""任务管理器面板"""
import uuid
from datetime import datetime, timedelta
from typing import Dict, List, Optional
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QGridLayout,
QGroupBox, QLabel, QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QMessageBox, QMenu, QAbstractItemView, QDialog,
QComboBox, QSpinBox, QLineEdit, QCheckBox, QTimeEdit, QDateEdit,
QListWidget, QListWidgetItem, QDialogButtonBox, QTabWidget, QFrame,
QTextEdit, QPlainTextEdit
)
from PySide6.QtCore import Qt, Signal, QTimer, QTime, QDate
from PySide6.QtGui import QColor, QAction, QFont
from ..models.task_model import QueuedTask, TaskConfig, TaskStatus
from ..models.schedule_model import (
ScheduledTask, ScheduleConfig, ScheduleType, IntervalUnit, ScheduleStore,
ScheduleExecutionRecord
)
from ..utils.cli_builder import CLIBuilder
from ..utils.app_settings import app_settings
from ..workers.task_worker import TaskWorker
# 可调度的任务列表(包含所有 ODS 任务 + DWD/质量检查任务)
SCHEDULABLE_TASKS = [
# ODS 数据抓取任务(与 task_panel.AUTO_UPDATE_TASKS 保持一致)
("ODS_PAYMENT", "支付流水"),
("ODS_MEMBER", "会员档案"),
("ODS_MEMBER_CARD", "会员储值卡"),
("ODS_MEMBER_BALANCE", "会员余额变动"),
("ODS_SETTLEMENT_RECORDS", "结账记录"),
("ODS_TABLE_USE", "台费计费流水"),
("ODS_ASSISTANT_ACCOUNT", "助教账号"),
("ODS_ASSISTANT_LEDGER", "助教流水"),
("ODS_ASSISTANT_ABOLISH", "助教作废"),
("ODS_REFUND", "退款流水"),
("ODS_PLATFORM_COUPON", "平台券核销"),
("ODS_RECHARGE_SETTLE", "充值结算"),
("ODS_SETTLEMENT_TICKET", "结账小票"),
# DWD 和质量检查任务
("DWD_LOAD_FROM_ODS", "ODS→DWD 装载"),
("DWD_QUALITY_CHECK", "DWD 质量检查"),
("DATA_INTEGRITY_CHECK", "数据完整性检查"),
("CHECK_CUTOFF", "检查 Cutoff"),
]
class TaskLogDialog(QDialog):
"""任务日志查看对话框"""
def __init__(self, task: QueuedTask, parent=None):
super().__init__(parent)
self.task = task
self.setWindowTitle(f"任务日志 - {', '.join(task.config.tasks[:2])}")
self.setMinimumSize(800, 600)
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
# 任务信息
info_group = QGroupBox("任务信息")
info_layout = QGridLayout(info_group)
info_layout.addWidget(QLabel("任务 ID:"), 0, 0)
info_layout.addWidget(QLabel(self.task.id), 0, 1)
info_layout.addWidget(QLabel("任务列表:"), 0, 2)
info_layout.addWidget(QLabel(", ".join(self.task.config.tasks)), 0, 3)
info_layout.addWidget(QLabel("状态:"), 1, 0)
status_label = QLabel(self._get_status_text(self.task.status))
status_label.setStyleSheet(f"color: {self._get_status_color(self.task.status)};")
info_layout.addWidget(status_label, 1, 1)
info_layout.addWidget(QLabel("退出码:"), 1, 2)
info_layout.addWidget(QLabel(str(self.task.exit_code) if self.task.exit_code is not None else "-"), 1, 3)
if self.task.started_at:
info_layout.addWidget(QLabel("开始时间:"), 2, 0)
info_layout.addWidget(QLabel(self.task.started_at.strftime("%Y-%m-%d %H:%M:%S")), 2, 1)
if self.task.finished_at:
info_layout.addWidget(QLabel("结束时间:"), 2, 2)
info_layout.addWidget(QLabel(self.task.finished_at.strftime("%Y-%m-%d %H:%M:%S")), 2, 3)
if self.task.started_at:
duration = (self.task.finished_at - self.task.started_at).total_seconds()
info_layout.addWidget(QLabel("耗时:"), 3, 0)
info_layout.addWidget(QLabel(f"{duration:.1f}"), 3, 1)
layout.addWidget(info_group)
# 命令行
cmd_group = QGroupBox("执行命令")
cmd_layout = QVBoxLayout(cmd_group)
cmd_text = QLineEdit()
cmd_text.setReadOnly(True)
from ..utils.cli_builder import CLIBuilder
cli = CLIBuilder()
cmd_text.setText(cli.build_command_string(self.task.config))
cmd_layout.addWidget(cmd_text)
# 显示环境变量
if hasattr(self.task.config, 'env_vars') and self.task.config.env_vars:
env_label = QLabel("环境变量: " + ", ".join(f"{k}={v}" for k, v in self.task.config.env_vars.items()))
env_label.setWordWrap(True)
cmd_layout.addWidget(env_label)
layout.addWidget(cmd_group)
# 输出日志
log_group = QGroupBox("执行输出")
log_layout = QVBoxLayout(log_group)
self.log_text = QPlainTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setFont(QFont("Consolas", 9))
self.log_text.setPlainText(self.task.output if self.task.output else "(无输出)")
log_layout.addWidget(self.log_text)
layout.addWidget(log_group)
# 错误信息
if self.task.error:
error_group = QGroupBox("错误信息")
error_layout = QVBoxLayout(error_group)
error_text = QPlainTextEdit()
error_text.setReadOnly(True)
error_text.setPlainText(self.task.error)
error_text.setMaximumHeight(100)
error_layout.addWidget(error_text)
layout.addWidget(error_group)
# 按钮
btn_layout = QHBoxLayout()
copy_btn = QPushButton("复制日志")
copy_btn.clicked.connect(self._copy_log)
btn_layout.addWidget(copy_btn)
btn_layout.addStretch()
close_btn = QPushButton("关闭")
close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn)
layout.addLayout(btn_layout)
def _copy_log(self):
"""复制日志到剪贴板"""
from PySide6.QtWidgets import QApplication
QApplication.clipboard().setText(self.task.output or "")
QMessageBox.information(self, "提示", "日志已复制到剪贴板")
@staticmethod
def _get_status_text(status: TaskStatus) -> str:
return {
TaskStatus.PENDING: "待执行",
TaskStatus.RUNNING: "执行中",
TaskStatus.SUCCESS: "成功",
TaskStatus.FAILED: "失败",
TaskStatus.CANCELLED: "已取消",
}.get(status, "未知")
@staticmethod
def _get_status_color(status: TaskStatus) -> str:
return {
TaskStatus.PENDING: "#5f6368",
TaskStatus.RUNNING: "#1a73e8",
TaskStatus.SUCCESS: "#1e8e3e",
TaskStatus.FAILED: "#d93025",
TaskStatus.CANCELLED: "#9aa0a6",
}.get(status, "#333333")
class ScheduleEditDialog(QDialog):
"""调度任务编辑对话框"""
def __init__(self, task: Optional[ScheduledTask] = None, parent=None):
super().__init__(parent)
self.task = task
self.cli_builder = CLIBuilder()
self.setWindowTitle("编辑调度任务" if task else "新建调度任务")
self.setMinimumWidth(600)
self.setMinimumHeight(700)
self._init_ui()
self._connect_preview_signals()
if task:
self._load_task(task)
self._update_cli_preview() # 初始化预览
def _init_ui(self):
layout = QVBoxLayout(self)
# 基本信息
basic_group = QGroupBox("基本信息")
basic_layout = QGridLayout(basic_group)
basic_layout.addWidget(QLabel("任务名称:"), 0, 0)
self.name_edit = QLineEdit()
self.name_edit.setPlaceholderText("例: 每日数据更新")
basic_layout.addWidget(self.name_edit, 0, 1)
basic_layout.addWidget(QLabel("启用:"), 1, 0)
self.enabled_check = QCheckBox("启用此调度任务")
self.enabled_check.setChecked(True)
basic_layout.addWidget(self.enabled_check, 1, 1)
layout.addWidget(basic_group)
# 任务选择
task_group = QGroupBox("执行任务")
task_layout = QVBoxLayout(task_group)
self.task_list = QListWidget()
self.task_list.setSelectionMode(QListWidget.MultiSelection)
self.task_list.setMaximumHeight(150)
for code, name in SCHEDULABLE_TASKS:
item = QListWidgetItem(f"{name} ({code})")
item.setData(Qt.UserRole, code)
self.task_list.addItem(item)
task_layout.addWidget(self.task_list)
layout.addWidget(task_group)
# 调度设置
schedule_group = QGroupBox("调度设置")
schedule_layout = QVBoxLayout(schedule_group)
# 调度类型
type_layout = QHBoxLayout()
type_layout.addWidget(QLabel("调度类型:"))
self.schedule_type_combo = QComboBox()
self.schedule_type_combo.addItem("固定间隔", ScheduleType.INTERVAL)
self.schedule_type_combo.addItem("每天定时", ScheduleType.DAILY)
self.schedule_type_combo.addItem("每周定时", ScheduleType.WEEKLY)
self.schedule_type_combo.addItem("Cron 表达式", ScheduleType.CRON)
self.schedule_type_combo.currentIndexChanged.connect(self._on_type_changed)
type_layout.addWidget(self.schedule_type_combo, 1)
schedule_layout.addLayout(type_layout)
# 间隔设置
self.interval_widget = QWidget()
interval_layout = QHBoxLayout(self.interval_widget)
interval_layout.setContentsMargins(0, 0, 0, 0)
interval_layout.addWidget(QLabel("执行间隔:"))
self.interval_value = QSpinBox()
self.interval_value.setRange(1, 999)
self.interval_value.setValue(1)
interval_layout.addWidget(self.interval_value)
self.interval_unit = QComboBox()
self.interval_unit.addItem("分钟", IntervalUnit.MINUTES)
self.interval_unit.addItem("小时", IntervalUnit.HOURS)
self.interval_unit.addItem("", IntervalUnit.DAYS)
self.interval_unit.setCurrentIndex(1) # 默认小时
interval_layout.addWidget(self.interval_unit)
interval_layout.addStretch()
schedule_layout.addWidget(self.interval_widget)
# 每日设置
self.daily_widget = QWidget()
daily_layout = QHBoxLayout(self.daily_widget)
daily_layout.setContentsMargins(0, 0, 0, 0)
daily_layout.addWidget(QLabel("执行时间:"))
self.daily_time = QTimeEdit()
self.daily_time.setTime(QTime(4, 0))
self.daily_time.setDisplayFormat("HH:mm")
daily_layout.addWidget(self.daily_time)
daily_layout.addStretch()
self.daily_widget.setVisible(False)
schedule_layout.addWidget(self.daily_widget)
# 每周设置
self.weekly_widget = QWidget()
weekly_layout = QVBoxLayout(self.weekly_widget)
weekly_layout.setContentsMargins(0, 0, 0, 0)
days_layout = QHBoxLayout()
days_layout.addWidget(QLabel("执行日:"))
self.day_checks = {}
for i, day in enumerate(["", "", "", "", "", "", ""], 1):
check = QCheckBox(f"{day}")
check.setChecked(i == 1) # 默认周一
self.day_checks[i] = check
days_layout.addWidget(check)
weekly_layout.addLayout(days_layout)
weekly_time_layout = QHBoxLayout()
weekly_time_layout.addWidget(QLabel("执行时间:"))
self.weekly_time = QTimeEdit()
self.weekly_time.setTime(QTime(4, 0))
self.weekly_time.setDisplayFormat("HH:mm")
weekly_time_layout.addWidget(self.weekly_time)
weekly_time_layout.addStretch()
weekly_layout.addLayout(weekly_time_layout)
self.weekly_widget.setVisible(False)
schedule_layout.addWidget(self.weekly_widget)
# Cron 设置
self.cron_widget = QWidget()
cron_layout = QHBoxLayout(self.cron_widget)
cron_layout.setContentsMargins(0, 0, 0, 0)
cron_layout.addWidget(QLabel("Cron:"))
self.cron_edit = QLineEdit()
self.cron_edit.setPlaceholderText("分 时 日 月 周 (例: 0 4 * * *)")
self.cron_edit.setText("0 4 * * *")
cron_layout.addWidget(self.cron_edit, 1)
self.cron_widget.setVisible(False)
schedule_layout.addWidget(self.cron_widget)
layout.addWidget(schedule_group)
# 任务配置
config_group = QGroupBox("任务配置")
config_layout = QGridLayout(config_group)
config_layout.addWidget(QLabel("运行模式:"), 0, 0)
self.pipeline_combo = QComboBox()
self.pipeline_combo.addItem("FULL - 在线抓取 + 入库", "FULL")
self.pipeline_combo.addItem("INGEST_ONLY - 仅入库", "INGEST_ONLY")
config_layout.addWidget(self.pipeline_combo, 0, 1)
config_layout.addWidget(QLabel("回溯小时:"), 1, 0)
self.lookback_hours = QSpinBox()
self.lookback_hours.setRange(1, 720)
self.lookback_hours.setValue(24)
self.lookback_hours.setSuffix(" 小时")
self.lookback_hours.setToolTip("每次执行时,抓取最近 N 小时的数据")
config_layout.addWidget(self.lookback_hours, 1, 1)
layout.addWidget(config_group)
# CLI 命令行预览
cli_group = QGroupBox("命令行预览")
cli_layout = QVBoxLayout(cli_group)
self.cli_preview = QPlainTextEdit()
self.cli_preview.setMaximumHeight(100)
self.cli_preview.setFont(QFont("Consolas", 9))
self.cli_preview.setPlaceholderText("CLI 命令行将在此显示...")
cli_layout.addWidget(self.cli_preview)
# CLI 编辑提示和复制按钮
cli_btn_layout = QHBoxLayout()
self.cli_editable_check = QCheckBox("允许手动编辑")
self.cli_editable_check.setToolTip("勾选后可以手动修改命令行参数")
self.cli_editable_check.stateChanged.connect(self._on_cli_editable_changed)
cli_btn_layout.addWidget(self.cli_editable_check)
cli_btn_layout.addStretch()
self.copy_cli_btn = QPushButton("复制命令")
self.copy_cli_btn.setProperty("secondary", True)
self.copy_cli_btn.clicked.connect(self._copy_cli_to_clipboard)
cli_btn_layout.addWidget(self.copy_cli_btn)
self.refresh_cli_btn = QPushButton("刷新预览")
self.refresh_cli_btn.setProperty("secondary", True)
self.refresh_cli_btn.clicked.connect(self._update_cli_preview)
cli_btn_layout.addWidget(self.refresh_cli_btn)
cli_layout.addLayout(cli_btn_layout)
layout.addWidget(cli_group)
# 按钮
btn_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
btn_box.accepted.connect(self.accept)
btn_box.rejected.connect(self.reject)
layout.addWidget(btn_box)
def _on_type_changed(self, index: int):
schedule_type = self.schedule_type_combo.currentData()
self.interval_widget.setVisible(schedule_type == ScheduleType.INTERVAL)
self.daily_widget.setVisible(schedule_type == ScheduleType.DAILY)
self.weekly_widget.setVisible(schedule_type == ScheduleType.WEEKLY)
self.cron_widget.setVisible(schedule_type == ScheduleType.CRON)
def _load_task(self, task: ScheduledTask):
"""加载任务数据"""
self.name_edit.setText(task.name)
self.enabled_check.setChecked(task.enabled)
# 选择任务
for i in range(self.task_list.count()):
item = self.task_list.item(i)
code = item.data(Qt.UserRole)
item.setSelected(code in task.task_codes)
# 调度设置
schedule = task.schedule
# 设置类型
for i in range(self.schedule_type_combo.count()):
if self.schedule_type_combo.itemData(i) == schedule.schedule_type:
self.schedule_type_combo.setCurrentIndex(i)
break
self.interval_value.setValue(schedule.interval_value)
for i in range(self.interval_unit.count()):
if self.interval_unit.itemData(i) == schedule.interval_unit:
self.interval_unit.setCurrentIndex(i)
break
if schedule.daily_time:
h, m = map(int, schedule.daily_time.split(":"))
self.daily_time.setTime(QTime(h, m))
for day, check in self.day_checks.items():
check.setChecked(day in schedule.weekly_days)
if schedule.weekly_time:
h, m = map(int, schedule.weekly_time.split(":"))
self.weekly_time.setTime(QTime(h, m))
self.cron_edit.setText(schedule.cron_expression)
# 任务配置
if task.task_config.get("pipeline_flow"):
for i in range(self.pipeline_combo.count()):
if self.pipeline_combo.itemData(i) == task.task_config["pipeline_flow"]:
self.pipeline_combo.setCurrentIndex(i)
break
self.lookback_hours.setValue(task.task_config.get("lookback_hours", 24))
def get_task(self) -> Optional[ScheduledTask]:
"""获取配置的任务"""
name = self.name_edit.text().strip()
if not name:
QMessageBox.warning(self, "提示", "请输入任务名称")
return None
# 获取选中的任务
task_codes = []
for i in range(self.task_list.count()):
item = self.task_list.item(i)
if item.isSelected():
task_codes.append(item.data(Qt.UserRole))
if not task_codes:
QMessageBox.warning(self, "提示", "请至少选择一个任务")
return None
# 构建调度配置
schedule_type = self.schedule_type_combo.currentData()
weekly_days = [day for day, check in self.day_checks.items() if check.isChecked()]
schedule = ScheduleConfig(
schedule_type=schedule_type,
interval_value=self.interval_value.value(),
interval_unit=self.interval_unit.currentData(),
daily_time=self.daily_time.time().toString("HH:mm"),
weekly_days=weekly_days or [1],
weekly_time=self.weekly_time.time().toString("HH:mm"),
cron_expression=self.cron_edit.text().strip(),
enabled=True,
)
# 构建任务配置
task_config = {
"pipeline_flow": self.pipeline_combo.currentData(),
"lookback_hours": self.lookback_hours.value(),
}
# 创建或更新任务
if self.task:
task = self.task
task.name = name
task.task_codes = task_codes
task.schedule = schedule
task.task_config = task_config
task.enabled = self.enabled_check.isChecked()
else:
task = ScheduledTask(
id=str(uuid.uuid4())[:8],
name=name,
task_codes=task_codes,
schedule=schedule,
task_config=task_config,
enabled=self.enabled_check.isChecked(),
)
task.update_next_run()
return task
def _connect_preview_signals(self):
"""连接信号以实时更新 CLI 预览"""
# 任务选择变化
self.task_list.itemSelectionChanged.connect(self._update_cli_preview)
# 调度配置变化
self.schedule_type_combo.currentIndexChanged.connect(self._update_cli_preview)
self.interval_value.valueChanged.connect(self._update_cli_preview)
self.interval_unit.currentIndexChanged.connect(self._update_cli_preview)
# 任务配置变化
self.pipeline_combo.currentIndexChanged.connect(self._update_cli_preview)
self.lookback_hours.valueChanged.connect(self._update_cli_preview)
def _update_cli_preview(self):
"""更新 CLI 命令行预览"""
# 如果用户正在手动编辑,不自动更新
if self.cli_editable_check.isChecked():
return
# 获取选中的任务
task_codes = []
for i in range(self.task_list.count()):
item = self.task_list.item(i)
if item.isSelected():
task_codes.append(item.data(Qt.UserRole))
if not task_codes:
self.cli_preview.setPlainText("# 请选择至少一个任务")
return
# 获取配置
lookback_hours = self.lookback_hours.value()
pipeline_flow = self.pipeline_combo.currentData()
# 构建说明注释
lines = []
# 调度规则说明
schedule_type = self.schedule_type_combo.currentData()
if schedule_type == ScheduleType.INTERVAL:
interval_val = self.interval_value.value()
interval_unit = self.interval_unit.currentText()
lines.append(f"# 调度:每 {interval_val} {interval_unit} 执行一次")
elif schedule_type == ScheduleType.DAILY:
daily_time = self.daily_time.time().toString("HH:mm")
lines.append(f"# 调度:每天 {daily_time} 执行")
elif schedule_type == ScheduleType.WEEKLY:
weekly_time = self.weekly_time.time().toString("HH:mm")
days = [f"{['','','','','','',''][d-1]}"
for d, c in self.day_checks.items() if c.isChecked()]
lines.append(f"# 调度:每周 {','.join(days)} {weekly_time} 执行")
elif schedule_type == ScheduleType.CRON:
cron_expr = self.cron_edit.text().strip()
lines.append(f"# 调度Cron 表达式 {cron_expr}")
# 动态时间窗口说明
lines.append(f"# 回溯窗口:{lookback_hours} 小时")
lines.append("#")
lines.append("# ⚠ 时间窗口在每次执行时动态计算:")
lines.append(f"# --window-start = <执行时间> - {lookback_hours}h")
lines.append(f"# --window-end = <执行时间>")
lines.append("")
# 生成命令行(使用占位符表示动态时间)
tasks_str = ",".join(task_codes)
cmd_parts = [
"python -m cli.main",
f"--tasks {tasks_str}",
f"--pipeline-flow {pipeline_flow}",
f'--window-start "<执行时间 - {lookback_hours}h>"',
f'--window-end "<执行时间>"',
]
lines.append(" \\\n ".join(cmd_parts))
# 添加示例(使用当前时间作为示例)
lines.append("")
lines.append("# -------- 示例(假设现在执行)--------")
now = datetime.now()
start_time = now - timedelta(hours=lookback_hours)
# 构建示例 TaskConfig
config = TaskConfig(
tasks=task_codes,
pipeline_flow=pipeline_flow,
window_start=start_time.strftime("%Y-%m-%d %H:%M:%S"),
window_end=now.strftime("%Y-%m-%d %H:%M:%S"),
)
example_cmd = self.cli_builder.build_command_string(config)
lines.append(f"# {example_cmd}")
self.cli_preview.setPlainText("\n".join(lines))
def _on_cli_editable_changed(self, state):
"""切换 CLI 编辑模式"""
editable = state == Qt.Checked
self.cli_preview.setReadOnly(not editable)
if editable:
# 切换到编辑模式,提示用户
current_text = self.cli_preview.toPlainText()
if not current_text.startswith("# [手动编辑模式]"):
self.cli_preview.setPlainText(f"# [手动编辑模式] 修改后点击「刷新预览」可恢复自动生成\n{current_text}")
else:
# 切换回只读模式,刷新预览
self._update_cli_preview()
def _copy_cli_to_clipboard(self):
"""复制 CLI 命令到剪贴板"""
from PySide6.QtWidgets import QApplication
text = self.cli_preview.toPlainText()
# 提取实际命令行(跳过注释行)
lines = text.split('\n')
cmd_lines = [line for line in lines if line.strip() and not line.strip().startswith('#')]
cmd_text = '\n'.join(cmd_lines)
if cmd_text:
QApplication.clipboard().setText(cmd_text)
QMessageBox.information(self, "提示", "命令行已复制到剪贴板")
else:
QMessageBox.warning(self, "提示", "没有可复制的命令")
class ScheduleLogDialog(QDialog):
"""调度任务日志查看对话框"""
def __init__(self, scheduled_task: ScheduledTask, task_history: List[QueuedTask],
task_queue: List[QueuedTask] = None, parent=None):
super().__init__(parent)
self.scheduled_task = scheduled_task
self.task_history = task_history # 引用任务管理器的执行历史
self.task_queue = task_queue or [] # 引用任务队列(用于获取执行中任务的实时日志)
self.setWindowTitle(f"调度日志 - {scheduled_task.name}")
self.setMinimumSize(900, 600)
self._init_ui()
# 定时刷新执行中任务的日志
self._refresh_timer = QTimer(self)
self._refresh_timer.timeout.connect(self._refresh_running_log)
self._refresh_timer.start(1000) # 每秒刷新
def _init_ui(self):
layout = QVBoxLayout(self)
# 调度任务基本信息
info_group = QGroupBox("调度任务信息")
info_layout = QGridLayout(info_group)
info_layout.addWidget(QLabel("任务名称:"), 0, 0)
info_layout.addWidget(QLabel(self.scheduled_task.name), 0, 1)
info_layout.addWidget(QLabel("执行任务:"), 0, 2)
tasks_str = ", ".join(self.scheduled_task.task_codes[:3])
if len(self.scheduled_task.task_codes) > 3:
tasks_str += f" (+{len(self.scheduled_task.task_codes) - 3})"
info_layout.addWidget(QLabel(tasks_str), 0, 3)
info_layout.addWidget(QLabel("调度规则:"), 1, 0)
info_layout.addWidget(QLabel(self.scheduled_task.schedule.get_description()), 1, 1)
info_layout.addWidget(QLabel("执行次数:"), 1, 2)
info_layout.addWidget(QLabel(str(self.scheduled_task.run_count)), 1, 3)
layout.addWidget(info_group)
# 分割器
splitter = QSplitter(Qt.Vertical)
layout.addWidget(splitter, 1)
# 执行历史列表
history_group = QGroupBox(f"执行历史 (最近 {len(self.scheduled_task.execution_history)} 次)")
history_layout = QVBoxLayout(history_group)
self.history_table = QTableWidget()
self.history_table.setColumnCount(5)
self.history_table.setHorizontalHeaderLabels(["执行时间", "状态", "耗时", "退出码", "摘要"])
self.history_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.history_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Stretch)
self.history_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.history_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.history_table.itemSelectionChanged.connect(self._on_selection_changed)
history_layout.addWidget(self.history_table)
splitter.addWidget(history_group)
# 日志详情
log_group = QGroupBox("执行日志")
log_layout = QVBoxLayout(log_group)
self.log_text = QPlainTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setFont(QFont("Consolas", 9))
self.log_text.setPlaceholderText("选择上方的执行记录查看详细日志...")
log_layout.addWidget(self.log_text)
splitter.addWidget(log_group)
splitter.setSizes([250, 350])
# 按钮
btn_layout = QHBoxLayout()
copy_btn = QPushButton("复制日志")
copy_btn.clicked.connect(self._copy_log)
btn_layout.addWidget(copy_btn)
btn_layout.addStretch()
close_btn = QPushButton("关闭")
close_btn.clicked.connect(self.accept)
btn_layout.addWidget(close_btn)
layout.addLayout(btn_layout)
# 填充历史表格
self._load_history()
def _load_history(self):
"""加载执行历史"""
records = self.scheduled_task.execution_history
self.history_table.setRowCount(len(records))
for row, record in enumerate(records):
# 执行时间
time_str = record.executed_at.strftime("%Y-%m-%d %H:%M:%S")
self.history_table.setItem(row, 0, QTableWidgetItem(time_str))
# 状态
status_item = QTableWidgetItem(self._get_status_text(record.status))
status_item.setForeground(self._get_status_color(record.status))
self.history_table.setItem(row, 1, status_item)
# 耗时
if record.duration_seconds > 0:
if record.duration_seconds < 60:
duration_str = f"{record.duration_seconds:.1f}"
else:
mins = int(record.duration_seconds // 60)
secs = int(record.duration_seconds % 60)
duration_str = f"{mins}{secs}"
else:
duration_str = "-"
self.history_table.setItem(row, 2, QTableWidgetItem(duration_str))
# 退出码
exit_str = str(record.exit_code) if record.exit_code is not None else "-"
self.history_table.setItem(row, 3, QTableWidgetItem(exit_str))
# 摘要
summary_item = QTableWidgetItem(record.summary[:50] if record.summary else "-")
summary_item.setData(Qt.UserRole, record.task_id) # 存储关联的任务ID
self.history_table.setItem(row, 4, summary_item)
# 自动选择第一行
if records:
self.history_table.selectRow(0)
def _on_selection_changed(self):
"""选择变化时显示对应的日志"""
row = self.history_table.currentRow()
if row < 0 or row >= len(self.scheduled_task.execution_history):
self.log_text.clear()
return
record = self.scheduled_task.execution_history[row]
# 如果是执行中的任务,从任务队列获取实时日志
if record.status == "pending":
self._show_running_task_log(record)
return
# 优先使用执行记录中保存的日志
if record.output:
log_content = record.output
# 如果有错误信息,附加到末尾
if record.error:
log_content += f"\n\n===== 错误信息 =====\n{record.error}"
self.log_text.setPlainText(log_content)
else:
# 尝试从任务历史中查找(兼容旧记录)
queued_task = None
for task in self.task_history:
if task.id == record.task_id:
queued_task = task
break
if queued_task and queued_task.output:
self.log_text.setPlainText(queued_task.output)
else:
# 显示基本信息
info_lines = [
f"执行时间: {record.executed_at.strftime('%Y-%m-%d %H:%M:%S')}",
f"状态: {self._get_status_text(record.status)}",
f"耗时: {record.duration_seconds:.1f}",
f"退出码: {record.exit_code}",
f"",
f"执行摘要:",
record.summary if record.summary else "(无)",
]
if record.error:
info_lines.extend(["", "错误信息:", record.error])
self.log_text.setPlainText("\n".join(info_lines))
def _show_running_task_log(self, record: ScheduleExecutionRecord):
"""显示执行中任务的实时日志"""
# 1. 先从任务队列中查找正在执行的任务
found_task = None
task_source = "queue"
for task in self.task_queue:
if task.id == record.task_id:
found_task = task
break
# 2. 如果队列中找不到,可能任务刚完成还在历史中,从历史中查找
if not found_task:
for task in self.task_history:
if task.id == record.task_id:
found_task = task
task_source = "history"
break
if found_task and found_task.output:
# 显示日志
if task_source == "queue" and found_task.status == TaskStatus.RUNNING:
# 任务还在执行中
header = f"===== 任务执行中 (实时日志) =====\n"
header += f"任务 ID: {found_task.id}\n"
if found_task.started_at:
elapsed = (datetime.now() - found_task.started_at).total_seconds()
header += f"已运行: {elapsed:.0f}\n"
header += "=" * 40 + "\n\n"
else:
# 任务已完成(可能是刚完成,记录状态还没更新)
status_text = "成功" if found_task.status == TaskStatus.SUCCESS else "已完成"
if found_task.status == TaskStatus.FAILED:
status_text = "失败"
header = f"===== 任务 {status_text} =====\n"
header += f"任务 ID: {found_task.id}\n"
if found_task.started_at and found_task.finished_at:
duration = (found_task.finished_at - found_task.started_at).total_seconds()
header += f"执行耗时: {duration:.1f}\n"
header += "=" * 40 + "\n\n"
self.log_text.setPlainText(header + found_task.output)
else:
# 任务可能还未开始
info_lines = [
"===== 任务执行中 =====",
f"任务 ID: {record.task_id}",
f"开始时间: {record.executed_at.strftime('%Y-%m-%d %H:%M:%S')}",
"",
"日志正在生成中,请稍候...",
"(日志将在任务执行过程中实时更新)",
]
self.log_text.setPlainText("\n".join(info_lines))
def _refresh_running_log(self):
"""定时刷新执行中任务的日志"""
row = self.history_table.currentRow()
if row < 0 or row >= len(self.scheduled_task.execution_history):
return
record = self.scheduled_task.execution_history[row]
# 检查任务是否还在执行中
if record.status == "pending":
# 保存当前滚动位置
scrollbar = self.log_text.verticalScrollBar()
at_bottom = scrollbar.value() >= scrollbar.maximum() - 10
# 检查任务是否已经完成(可能记录状态还没更新)
task_completed = False
for task in self.task_history:
if task.id == record.task_id:
task_completed = True
break
if task_completed:
# 任务已完成,刷新历史表格以更新状态显示
# 重新加载历史数据(从 schedule_store 获取最新状态)
# 注意:这里需要从父窗口重新获取调度任务的最新状态
self._show_running_task_log(record)
# 触发重新选择以更新显示
self._on_selection_changed()
else:
self._show_running_task_log(record)
# 如果之前在底部,保持在底部
if at_bottom:
scrollbar.setValue(scrollbar.maximum())
def _copy_log(self):
"""复制日志到剪贴板"""
from PySide6.QtWidgets import QApplication
text = self.log_text.toPlainText()
if text:
QApplication.clipboard().setText(text)
QMessageBox.information(self, "提示", "日志已复制到剪贴板")
@staticmethod
def _get_status_text(status: str) -> str:
return {
"pending": "执行中",
"success": "成功",
"failed": "失败",
}.get(status, status or "未知")
@staticmethod
def _get_status_color(status: str) -> QColor:
return {
"pending": QColor("#1a73e8"),
"success": QColor("#1e8e3e"),
"failed": QColor("#d93025"),
}.get(status, QColor("#333333"))
class TaskManager(QWidget):
"""任务管理器"""
# 信号
task_started = Signal(str) # 任务开始
task_finished = Signal(bool, str) # 任务完成
log_message = Signal(str) # 日志消息
def __init__(self, parent=None):
super().__init__(parent)
self.cli_builder = CLIBuilder()
self.task_queue: List[QueuedTask] = []
self.task_history: List[QueuedTask] = []
self.current_worker: Optional[TaskWorker] = None
self.auto_run = False
# 调度任务存储
self.schedule_store = ScheduleStore()
self.scheduler_enabled = False
# 任务ID到调度任务ID的映射用于在任务完成时更新调度执行记录
self._task_schedule_mapping: Dict[str, str] = {}
self._init_ui()
self._connect_signals()
# 定时刷新
self.refresh_timer = QTimer(self)
self.refresh_timer.timeout.connect(self._refresh_display)
self.refresh_timer.start(1000) # 每秒刷新
# 调度检查定时器
self.schedule_timer = QTimer(self)
self.schedule_timer.timeout.connect(self._check_scheduled_tasks)
# 加载调度任务
self._refresh_schedule_table()
# 加载任务历史
self._load_task_history()
def _init_ui(self):
"""初始化界面"""
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(16)
# 标题
title = QLabel("任务管理")
title.setProperty("heading", True)
layout.addWidget(title)
# 使用选项卡
tab_widget = QTabWidget()
layout.addWidget(tab_widget, 1)
# ====== 任务队列选项卡 ======
queue_tab = QWidget()
queue_tab_layout = QVBoxLayout(queue_tab)
queue_tab_layout.setContentsMargins(0, 8, 0, 0)
# 控制按钮
header_layout = QHBoxLayout()
self.auto_run_btn = QPushButton("自动执行: 关")
self.auto_run_btn.setProperty("secondary", True)
self.auto_run_btn.setCheckable(True)
header_layout.addWidget(self.auto_run_btn)
self.clear_queue_btn = QPushButton("清空队列")
self.clear_queue_btn.setProperty("secondary", True)
header_layout.addWidget(self.clear_queue_btn)
header_layout.addStretch()
queue_tab_layout.addLayout(header_layout)
# 分割器
splitter = QSplitter(Qt.Vertical)
queue_tab_layout.addWidget(splitter, 1)
# 任务队列
queue_group = QGroupBox("任务队列")
queue_layout = QVBoxLayout(queue_group)
self.queue_table = QTableWidget()
self.queue_table.setColumnCount(5)
self.queue_table.setHorizontalHeaderLabels(["ID", "任务", "状态", "创建时间", "操作"])
self.queue_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.queue_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.queue_table.setContextMenuPolicy(Qt.CustomContextMenu)
queue_layout.addWidget(self.queue_table)
queue_btn_layout = QHBoxLayout()
self.run_next_btn = QPushButton("执行下一个")
self.run_all_btn = QPushButton("执行全部")
self.stop_btn = QPushButton("停止当前")
self.stop_btn.setProperty("danger", True)
self.stop_btn.setEnabled(False)
queue_btn_layout.addWidget(self.run_next_btn)
queue_btn_layout.addWidget(self.run_all_btn)
queue_btn_layout.addStretch()
queue_btn_layout.addWidget(self.stop_btn)
queue_layout.addLayout(queue_btn_layout)
splitter.addWidget(queue_group)
# 实时日志
log_group = QGroupBox("执行日志 (实时)")
log_layout = QVBoxLayout(log_group)
self.live_log = QPlainTextEdit()
self.live_log.setReadOnly(True)
self.live_log.setFont(QFont("Consolas", 9))
self.live_log.setMaximumBlockCount(1000) # 限制行数
self.live_log.setPlaceholderText("任务执行时,日志将在此实时显示...")
log_layout.addWidget(self.live_log)
log_btn_layout = QHBoxLayout()
self.clear_log_btn = QPushButton("清空日志")
self.clear_log_btn.setProperty("secondary", True)
self.clear_log_btn.clicked.connect(self.live_log.clear)
self.auto_scroll_check = QCheckBox("自动滚动")
self.auto_scroll_check.setChecked(True)
log_btn_layout.addWidget(self.clear_log_btn)
log_btn_layout.addWidget(self.auto_scroll_check)
log_btn_layout.addStretch()
log_layout.addLayout(log_btn_layout)
splitter.addWidget(log_group)
# 执行历史
history_group = QGroupBox("执行历史")
history_layout = QVBoxLayout(history_group)
self.history_table = QTableWidget()
self.history_table.setColumnCount(6)
self.history_table.setHorizontalHeaderLabels(["ID", "任务", "状态", "开始时间", "耗时", "结果"])
self.history_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.history_table.horizontalHeader().setSectionResizeMode(5, QHeaderView.Stretch)
self.history_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.history_table.setContextMenuPolicy(Qt.CustomContextMenu)
history_layout.addWidget(self.history_table)
# 提示标签
hint_label = QLabel("提示:双击任务可查看详细日志")
hint_label.setProperty("subheading", True)
history_layout.addWidget(hint_label)
history_btn_layout = QHBoxLayout()
self.clear_history_btn = QPushButton("清空历史")
self.clear_history_btn.setProperty("secondary", True)
history_btn_layout.addStretch()
history_btn_layout.addWidget(self.clear_history_btn)
history_layout.addLayout(history_btn_layout)
splitter.addWidget(history_group)
splitter.setSizes([200, 250, 250])
tab_widget.addTab(queue_tab, "任务队列")
# ====== 定时调度选项卡 ======
schedule_tab = QWidget()
schedule_layout = QVBoxLayout(schedule_tab)
schedule_layout.setContentsMargins(0, 8, 0, 0)
# 调度控制
schedule_header = QHBoxLayout()
self.scheduler_btn = QPushButton("调度器: 关")
self.scheduler_btn.setProperty("secondary", True)
self.scheduler_btn.setCheckable(True)
self.scheduler_btn.setToolTip("开启后将自动执行到期的调度任务")
schedule_header.addWidget(self.scheduler_btn)
schedule_header.addStretch()
self.add_schedule_btn = QPushButton("新建调度")
schedule_header.addWidget(self.add_schedule_btn)
schedule_layout.addLayout(schedule_header)
# 调度任务表格
self.schedule_table = QTableWidget()
self.schedule_table.setColumnCount(7)
self.schedule_table.setHorizontalHeaderLabels([
"名称", "任务", "调度", "下次执行", "上次执行", "执行次数", "状态"
])
self.schedule_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.schedule_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.schedule_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.schedule_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.schedule_table.setContextMenuPolicy(Qt.CustomContextMenu)
schedule_layout.addWidget(self.schedule_table, 1)
# 调度说明
schedule_note = QLabel(
"提示: 双击调度任务可查看执行日志。"
"调度器运行时会自动检查并执行到期的任务。"
"新建调度任务首次执行将延迟 60 秒。"
)
schedule_note.setProperty("subheading", True)
schedule_note.setWordWrap(True)
schedule_layout.addWidget(schedule_note)
tab_widget.addTab(schedule_tab, "定时调度")
def _connect_signals(self):
"""连接信号"""
self.auto_run_btn.toggled.connect(self._toggle_auto_run)
self.clear_queue_btn.clicked.connect(self._clear_queue)
self.run_next_btn.clicked.connect(self._run_next)
self.run_all_btn.clicked.connect(self._run_all)
self.stop_btn.clicked.connect(self._stop_current)
self.clear_history_btn.clicked.connect(self._clear_history)
self.queue_table.customContextMenuRequested.connect(self._show_queue_menu)
# 历史表格
self.history_table.customContextMenuRequested.connect(self._show_history_menu)
self.history_table.doubleClicked.connect(self._view_task_log)
# 调度相关
self.scheduler_btn.toggled.connect(self._toggle_scheduler)
self.add_schedule_btn.clicked.connect(self._add_schedule)
self.schedule_table.customContextMenuRequested.connect(self._show_schedule_menu)
self.schedule_table.doubleClicked.connect(self._on_schedule_double_click)
def add_task(self, config: TaskConfig) -> str:
"""添加任务到队列"""
task_id = str(uuid.uuid4())[:8]
task = QueuedTask(
id=task_id,
config=config,
status=TaskStatus.PENDING,
)
self.task_queue.append(task)
self._refresh_queue_table()
# 如果开启了自动执行且当前没有任务在运行
if self.auto_run and not self._is_running():
self._run_next()
return task_id
def _toggle_auto_run(self, checked: bool):
"""切换自动执行"""
self.auto_run = checked
self.auto_run_btn.setText(f"自动执行: {'' if checked else ''}")
# 如果开启自动执行且有待执行任务
if checked and self.task_queue and not self._is_running():
self._run_next()
def _clear_queue(self):
"""清空队列"""
# 只清除未执行的任务
self.task_queue = [t for t in self.task_queue if t.status == TaskStatus.RUNNING]
self._refresh_queue_table()
def _run_next(self):
"""执行下一个任务"""
if self._is_running():
QMessageBox.information(self, "提示", "当前有任务正在执行")
return
# 找到下一个待执行的任务
for task in self.task_queue:
if task.status == TaskStatus.PENDING:
self._execute_task(task)
break
def _run_all(self):
"""执行全部任务"""
self.auto_run = True
self.auto_run_btn.setChecked(True)
if not self._is_running():
self._run_next()
def _stop_current(self):
"""停止当前任务"""
if self.current_worker and self.current_worker.isRunning():
self.current_worker.stop()
def _clear_history(self):
"""清空历史"""
self.task_history.clear()
self._refresh_history_table()
self._save_task_history() # 保存到文件
def _execute_task(self, task: QueuedTask):
"""执行任务"""
# 构建命令
cmd = self.cli_builder.build_command(task.config)
# 获取额外环境变量
extra_env = task.config.env_vars if hasattr(task.config, 'env_vars') else {}
# 创建工作线程
self.current_worker = TaskWorker(cmd, extra_env=extra_env)
self.current_worker.output_received.connect(lambda line: self._on_output(task, line))
self.current_worker.task_finished.connect(lambda code, summary: self._on_finished(task, code, summary))
self.current_worker.error_occurred.connect(lambda error: self._on_error(task, error))
# 更新任务状态
task.status = TaskStatus.RUNNING
task.started_at = datetime.now()
# 更新 UI
self.stop_btn.setEnabled(True)
self._refresh_queue_table()
# 发送信号
task_info = ",".join(task.config.tasks[:2])
if len(task.config.tasks) > 2:
task_info += f"{len(task.config.tasks)}"
self.task_started.emit(task_info)
# 在实时日志中显示任务开始信息
self.live_log.appendPlainText("")
self.live_log.appendPlainText("=" * 60)
self.live_log.appendPlainText(f"▶ 开始执行任务: {task_info}")
self.live_log.appendPlainText(f" 任务 ID: {task.id}")
self.live_log.appendPlainText(f" 开始时间: {task.started_at.strftime('%Y-%m-%d %H:%M:%S')}")
self.live_log.appendPlainText("=" * 60)
# 启动
self.current_worker.start()
def _on_output(self, task: QueuedTask, line: str):
"""收到输出"""
task.output += line + "\n"
self.log_message.emit(line)
# 显示到实时日志区域
timestamp = datetime.now().strftime("%H:%M:%S")
self.live_log.appendPlainText(f"[{timestamp}] {line}")
# 自动滚动
if self.auto_scroll_check.isChecked():
scrollbar = self.live_log.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def _on_finished(self, task: QueuedTask, exit_code: int, summary: str):
"""任务完成"""
task.finished_at = datetime.now()
task.exit_code = exit_code
if exit_code == 0:
task.status = TaskStatus.SUCCESS
else:
task.status = TaskStatus.FAILED
task.error = summary
# 在实时日志中显示任务完成信息(详细报告)
duration = (task.finished_at - task.started_at).total_seconds() if task.started_at else 0
status_text = "✓ 成功" if exit_code == 0 else f"✗ 失败 (退出码: {exit_code})"
self.live_log.appendPlainText("")
self.live_log.appendPlainText("=" * 60)
self.live_log.appendPlainText(f"■ 任务执行报告")
self.live_log.appendPlainText("=" * 60)
self.live_log.appendPlainText(f" 状态: {status_text}")
self.live_log.appendPlainText(f" 任务 ID: {task.id}")
self.live_log.appendPlainText(f" 任务列表: {', '.join(task.config.tasks)}")
self.live_log.appendPlainText(f" 开始时间: {task.started_at.strftime('%Y-%m-%d %H:%M:%S') if task.started_at else '-'}")
self.live_log.appendPlainText(f" 结束时间: {task.finished_at.strftime('%Y-%m-%d %H:%M:%S') if task.finished_at else '-'}")
# 格式化耗时
if duration < 60:
duration_str = f"{duration:.1f}"
elif duration < 3600:
mins = int(duration // 60)
secs = int(duration % 60)
duration_str = f"{mins}{secs}"
else:
hours = int(duration // 3600)
mins = int((duration % 3600) // 60)
duration_str = f"{hours}{mins}"
self.live_log.appendPlainText(f" 总耗时: {duration_str}")
# 显示详细摘要
if summary:
self.live_log.appendPlainText("")
self.live_log.appendPlainText(" -------- 执行摘要 --------")
for line in summary.split('\n'):
self.live_log.appendPlainText(f" {line}")
self.live_log.appendPlainText("=" * 60)
self.live_log.appendPlainText("")
# 【重要】先更新调度执行记录(如果此任务来自调度),确保日志能正确保存
# 必须在移除任务之前执行,否则 ScheduleLogDialog 无法获取实时日志
self._update_schedule_execution_record(task, duration, summary)
# 移动到历史
self.task_queue.remove(task)
self.task_history.insert(0, task)
# 限制历史记录数量
if len(self.task_history) > 100:
self.task_history = self.task_history[:100]
# 保存历史到文件
self._save_task_history()
# 更新 UI
self.stop_btn.setEnabled(False)
self._refresh_queue_table()
self._refresh_history_table()
self._refresh_schedule_table()
# 发送信号
self.task_finished.emit(exit_code == 0, summary)
# 如果开启自动执行,或者队列中有定时任务待执行,继续下一个
if self.auto_run or self._has_scheduled_tasks_pending():
QTimer.singleShot(500, self._run_next)
def _on_error(self, task: QueuedTask, error: str):
"""发生错误"""
task.error = error
self.log_message.emit(f"[错误] {error}")
def _is_running(self) -> bool:
"""是否有任务在运行"""
return self.current_worker is not None and self.current_worker.isRunning()
def _has_scheduled_tasks_pending(self) -> bool:
"""检查队列中是否有待执行的定时任务"""
for task in self.task_queue:
if task.status == TaskStatus.PENDING and task.id in self._task_schedule_mapping:
return True
return False
def _refresh_display(self):
"""刷新显示"""
# 更新运行中任务的状态
if self._is_running():
for row in range(self.queue_table.rowCount()):
status_item = self.queue_table.item(row, 2)
if status_item and status_item.text() == "执行中":
# 添加动画效果
dots = "." * (int(datetime.now().timestamp()) % 4)
status_item.setText(f"执行中{dots}")
def _refresh_queue_table(self):
"""刷新队列表格"""
self.queue_table.setRowCount(len(self.task_queue))
for row, task in enumerate(self.task_queue):
# ID
self.queue_table.setItem(row, 0, QTableWidgetItem(task.id))
# 任务
tasks_str = ", ".join(task.config.tasks[:3])
if len(task.config.tasks) > 3:
tasks_str += f" (+{len(task.config.tasks) - 3})"
self.queue_table.setItem(row, 1, QTableWidgetItem(tasks_str))
# 状态
status_item = QTableWidgetItem(self._get_status_text(task.status))
status_item.setForeground(self._get_status_color(task.status))
self.queue_table.setItem(row, 2, status_item)
# 创建时间
time_str = task.created_at.strftime("%H:%M:%S")
self.queue_table.setItem(row, 3, QTableWidgetItem(time_str))
# 操作按钮
self.queue_table.setItem(row, 4, QTableWidgetItem(""))
def _refresh_history_table(self):
"""刷新历史表格"""
self.history_table.setRowCount(len(self.task_history))
for row, task in enumerate(self.task_history):
# ID
self.history_table.setItem(row, 0, QTableWidgetItem(task.id))
# 任务
tasks_str = ", ".join(task.config.tasks[:3])
if len(task.config.tasks) > 3:
tasks_str += f" (+{len(task.config.tasks) - 3})"
self.history_table.setItem(row, 1, QTableWidgetItem(tasks_str))
# 状态
status_item = QTableWidgetItem(self._get_status_text(task.status))
status_item.setForeground(self._get_status_color(task.status))
self.history_table.setItem(row, 2, status_item)
# 开始时间
time_str = task.started_at.strftime("%Y-%m-%d %H:%M:%S") if task.started_at else "-"
self.history_table.setItem(row, 3, QTableWidgetItem(time_str))
# 耗时
if task.started_at and task.finished_at:
duration = (task.finished_at - task.started_at).total_seconds()
if duration < 60:
duration_str = f"{duration:.1f}"
else:
duration_str = f"{int(duration // 60)}{int(duration % 60)}"
else:
duration_str = "-"
self.history_table.setItem(row, 4, QTableWidgetItem(duration_str))
# 结果 - 提取摘要的第一行作为显示
if task.status == TaskStatus.SUCCESS:
# 从 output 中提取摘要信息
result = self._extract_result_summary(task)
else:
result = task.error if task.error else "失败"
# 显示结果取第一行最多80字符
result_display = result.split('\n')[0][:80] if result else "成功"
result_item = QTableWidgetItem(result_display)
result_item.setToolTip(result) # 完整内容作为 tooltip
self.history_table.setItem(row, 5, result_item)
def _show_queue_menu(self, pos):
"""显示队列右键菜单"""
item = self.queue_table.itemAt(pos)
if not item:
return
row = item.row()
if row >= len(self.task_queue):
return
task = self.task_queue[row]
menu = QMenu(self)
if task.status == TaskStatus.PENDING:
run_action = QAction("立即执行", self)
run_action.triggered.connect(lambda: self._execute_task(task))
menu.addAction(run_action)
remove_action = QAction("移除", self)
remove_action.triggered.connect(lambda: self._remove_task(task))
menu.addAction(remove_action)
elif task.status == TaskStatus.RUNNING:
stop_action = QAction("停止", self)
stop_action.triggered.connect(self._stop_current)
menu.addAction(stop_action)
menu.exec(self.queue_table.mapToGlobal(pos))
def _remove_task(self, task: QueuedTask):
"""移除任务"""
if task in self.task_queue:
self.task_queue.remove(task)
self._refresh_queue_table()
def _show_history_menu(self, pos):
"""显示历史右键菜单"""
item = self.history_table.itemAt(pos)
if not item:
return
row = item.row()
if row >= len(self.task_history):
return
task = self.task_history[row]
menu = QMenu(self)
view_action = QAction("查看日志", self)
view_action.triggered.connect(lambda: self._show_task_log_dialog(task))
menu.addAction(view_action)
rerun_action = QAction("重新执行", self)
rerun_action.triggered.connect(lambda: self._rerun_task(task))
menu.addAction(rerun_action)
menu.addSeparator()
remove_action = QAction("从历史删除", self)
remove_action.triggered.connect(lambda: self._remove_from_history(task))
menu.addAction(remove_action)
menu.exec(self.history_table.mapToGlobal(pos))
def _view_task_log(self, index):
"""双击查看任务日志"""
row = index.row()
if row < len(self.task_history):
self._show_task_log_dialog(self.task_history[row])
def _show_task_log_dialog(self, task: QueuedTask):
"""显示任务日志对话框"""
dialog = TaskLogDialog(task, self)
dialog.exec()
def _rerun_task(self, task: QueuedTask):
"""重新执行任务"""
# 创建新任务(使用相同配置)
self.add_task(task.config)
QMessageBox.information(self, "提示", "任务已添加到队列")
def _remove_from_history(self, task: QueuedTask):
"""从历史中删除任务"""
if task in self.task_history:
self.task_history.remove(task)
self._refresh_history_table()
self._save_task_history() # 保存到文件
def _load_task_history(self):
"""从文件加载任务历史"""
try:
history_data = app_settings.load_task_history()
for item in history_data:
try:
# 重建 TaskConfig
config = TaskConfig(
tasks=item.get("tasks", []),
pipeline_flow=item.get("pipeline_flow", "FULL"),
window_start=item.get("window_start"),
window_end=item.get("window_end"),
)
# 重建 QueuedTask
status_str = item.get("status", "success")
status_map = {
"pending": TaskStatus.PENDING,
"running": TaskStatus.RUNNING,
"success": TaskStatus.SUCCESS,
"failed": TaskStatus.FAILED,
"cancelled": TaskStatus.CANCELLED,
}
status = status_map.get(status_str, TaskStatus.SUCCESS)
task = QueuedTask(
id=item.get("id", "unknown"),
config=config,
status=status,
)
# 恢复时间
if item.get("created_at"):
task.created_at = datetime.fromisoformat(item["created_at"])
if item.get("started_at"):
task.started_at = datetime.fromisoformat(item["started_at"])
if item.get("finished_at"):
task.finished_at = datetime.fromisoformat(item["finished_at"])
task.exit_code = item.get("exit_code")
task.error = item.get("error", "")
task.output = item.get("output_preview", "")
self.task_history.append(task)
except Exception:
continue
# 刷新显示
self._refresh_history_table()
if self.task_history:
self.log_message.emit(f"[系统] 已加载 {len(self.task_history)} 条历史任务记录")
except Exception as e:
print(f"加载任务历史失败: {e}")
def _save_task_history(self):
"""保存任务历史到文件"""
try:
app_settings.save_task_history(self.task_history)
except Exception as e:
print(f"保存任务历史失败: {e}")
def _extract_result_summary(self, task: QueuedTask) -> str:
"""从任务输出中提取结果摘要"""
import re
if not task.output:
return "成功"
lines = task.output.split('\n')
summary_parts = []
# 统计关键数据
total_inserted = 0
total_missing = 0
total_records = 0
for line in lines:
# 解析 DWD 装载统计
if "完成,统计=" in line:
try:
match = re.search(r"统计=(\{.+\})", line)
if match:
import json
stats_str = match.group(1).replace("'", '"')
stats = json.loads(stats_str)
if 'tables' in stats:
for tbl in stats['tables']:
inserted = tbl.get('inserted', 0)
processed = tbl.get('processed', 0)
total_inserted += inserted + processed
except Exception:
pass
# 解析数据校验结果
if "结果统计:" in line or "结果统计:" in line:
try:
match = re.search(r"\{.+\}", line)
if match:
import json
stats_str = match.group(0).replace("'", '"')
stats = json.loads(stats_str)
total_missing = stats.get('missing', 0)
except Exception:
pass
# 解析 CHECK_DONE
match = re.search(r'CHECK_DONE.*records=(\d+)', line)
if match:
total_records += int(match.group(1))
# 构建摘要
if total_inserted > 0:
summary_parts.append(f"处理 {total_inserted}")
if total_records > 0:
if total_missing > 0:
summary_parts.append(f"校验 {total_records} 条, 缺失 {total_missing}")
else:
summary_parts.append(f"校验 {total_records} 条, 数据完整")
if summary_parts:
return " | ".join(summary_parts)
return "成功"
@staticmethod
def _get_status_text(status: TaskStatus) -> str:
"""获取状态文本"""
return {
TaskStatus.PENDING: "待执行",
TaskStatus.RUNNING: "执行中",
TaskStatus.SUCCESS: "成功",
TaskStatus.FAILED: "失败",
TaskStatus.CANCELLED: "已取消",
}.get(status, "未知")
@staticmethod
def _get_status_color(status: TaskStatus) -> QColor:
"""获取状态颜色"""
return {
TaskStatus.PENDING: QColor("#5f6368"),
TaskStatus.RUNNING: QColor("#1a73e8"),
TaskStatus.SUCCESS: QColor("#1e8e3e"),
TaskStatus.FAILED: QColor("#d93025"),
TaskStatus.CANCELLED: QColor("#9aa0a6"),
}.get(status, QColor("#333333"))
# ========== 调度相关方法 ==========
def _toggle_scheduler(self, checked: bool):
"""切换调度器状态"""
self.scheduler_enabled = checked
self.scheduler_btn.setText(f"调度器: {'' if checked else ''}")
if checked:
# 启动调度检查定时器(每分钟检查一次)
self.schedule_timer.start(60000)
# 立即检查一次
self._check_scheduled_tasks()
self.log_message.emit("[调度器] 已启动")
else:
self.schedule_timer.stop()
self.log_message.emit("[调度器] 已停止")
def _check_scheduled_tasks(self):
"""检查并执行到期的调度任务"""
if not self.scheduler_enabled:
return
due_tasks = self.schedule_store.get_due_tasks()
for task in due_tasks:
self._execute_scheduled_task(task)
def _execute_scheduled_task(self, scheduled_task: ScheduledTask):
"""执行调度任务"""
# 构建任务配置
lookback_hours = scheduled_task.task_config.get("lookback_hours", 24)
now = datetime.now()
start_time = now - timedelta(hours=lookback_hours)
config = TaskConfig(
tasks=scheduled_task.task_codes,
pipeline_flow=scheduled_task.task_config.get("pipeline_flow", "FULL"),
window_start=start_time.strftime("%Y-%m-%d %H:%M:%S"),
window_end=now.strftime("%Y-%m-%d %H:%M:%S"),
)
# 添加到队列
task_id = self.add_task(config)
# 创建执行记录
execution_record = ScheduleExecutionRecord(
task_id=task_id,
executed_at=now,
status="pending",
)
scheduled_task.add_execution_record(execution_record)
# 保存映射关系,以便任务完成时更新记录
self._task_schedule_mapping[task_id] = scheduled_task.id
# 更新调度任务状态
scheduled_task.last_run = now
scheduled_task.run_count += 1
scheduled_task.last_status = "执行中"
scheduled_task.update_next_run()
self.schedule_store.update_task(scheduled_task)
self._refresh_schedule_table()
self.log_message.emit(f"[调度器] 执行任务: {scheduled_task.name} (ID: {task_id})")
# 定时任务必须立即启动执行,不受 auto_run 设置影响
if not self._is_running():
# 从队列中找到刚添加的任务并执行
for queued_task in self.task_queue:
if queued_task.id == task_id:
self._execute_task(queued_task)
break
def _add_schedule(self):
"""添加调度任务"""
dialog = ScheduleEditDialog(parent=self)
if dialog.exec() == QDialog.Accepted:
task = dialog.get_task()
if task:
self.schedule_store.add_task(task)
self._refresh_schedule_table()
self.log_message.emit(f"[调度器] 已创建任务: {task.name}")
def _edit_schedule(self):
"""编辑调度任务"""
row = self.schedule_table.currentRow()
if row < 0:
return
tasks = self.schedule_store.get_all_tasks()
if row >= len(tasks):
return
task = tasks[row]
dialog = ScheduleEditDialog(task=task, parent=self)
if dialog.exec() == QDialog.Accepted:
updated_task = dialog.get_task()
if updated_task:
self.schedule_store.update_task(updated_task)
self._refresh_schedule_table()
self.log_message.emit(f"[调度器] 已更新任务: {updated_task.name}")
def _delete_schedule(self, task_id: str):
"""删除调度任务"""
task = self.schedule_store.get_task(task_id)
if not task:
return
reply = QMessageBox.question(
self,
"确认删除",
f"确定要删除调度任务 '{task.name}' 吗?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.schedule_store.remove_task(task_id)
self._refresh_schedule_table()
self.log_message.emit(f"[调度器] 已删除任务: {task.name}")
def _toggle_schedule_enabled(self, task_id: str):
"""切换调度任务启用状态"""
task = self.schedule_store.get_task(task_id)
if task:
task.enabled = not task.enabled
task.update_next_run()
self.schedule_store.update_task(task)
self._refresh_schedule_table()
status = "启用" if task.enabled else "禁用"
self.log_message.emit(f"[调度器] {task.name}: {status}")
def _run_schedule_now(self, task_id: str):
"""立即执行调度任务"""
task = self.schedule_store.get_task(task_id)
if task:
self._execute_scheduled_task(task)
def _show_schedule_menu(self, pos):
"""显示调度任务右键菜单"""
item = self.schedule_table.itemAt(pos)
if not item:
return
row = item.row()
tasks = self.schedule_store.get_all_tasks()
if row >= len(tasks):
return
task = tasks[row]
menu = QMenu(self)
# 查看日志
log_action = QAction("查看执行日志", self)
log_action.triggered.connect(lambda: self._view_schedule_log(task.id))
menu.addAction(log_action)
menu.addSeparator()
# 立即执行
run_action = QAction("立即执行", self)
run_action.triggered.connect(lambda: self._run_schedule_now(task.id))
menu.addAction(run_action)
# 编辑
edit_action = QAction("编辑", self)
edit_action.triggered.connect(self._edit_schedule)
menu.addAction(edit_action)
menu.addSeparator()
# 启用/禁用
toggle_text = "禁用" if task.enabled else "启用"
toggle_action = QAction(toggle_text, self)
toggle_action.triggered.connect(lambda: self._toggle_schedule_enabled(task.id))
menu.addAction(toggle_action)
menu.addSeparator()
# 删除
delete_action = QAction("删除", self)
delete_action.triggered.connect(lambda: self._delete_schedule(task.id))
menu.addAction(delete_action)
menu.exec(self.schedule_table.mapToGlobal(pos))
def _refresh_schedule_table(self):
"""刷新调度任务表格"""
tasks = self.schedule_store.get_all_tasks()
self.schedule_table.setRowCount(len(tasks))
for row, task in enumerate(tasks):
# 名称
name_item = QTableWidgetItem(task.name)
name_item.setData(Qt.UserRole, task.id)
self.schedule_table.setItem(row, 0, name_item)
# 任务
tasks_str = ", ".join(task.task_codes[:2])
if len(task.task_codes) > 2:
tasks_str += f" (+{len(task.task_codes) - 2})"
self.schedule_table.setItem(row, 1, QTableWidgetItem(tasks_str))
# 调度
self.schedule_table.setItem(row, 2, QTableWidgetItem(task.schedule.get_description()))
# 下次执行
if task.next_run:
next_str = task.next_run.strftime("%m-%d %H:%M")
else:
next_str = "-"
self.schedule_table.setItem(row, 3, QTableWidgetItem(next_str))
# 上次执行
if task.last_run:
last_str = task.last_run.strftime("%m-%d %H:%M")
else:
last_str = "-"
self.schedule_table.setItem(row, 4, QTableWidgetItem(last_str))
# 执行次数
self.schedule_table.setItem(row, 5, QTableWidgetItem(str(task.run_count)))
# 状态
if task.enabled:
status_text = "启用"
status_color = QColor("#1e8e3e")
else:
status_text = "禁用"
status_color = QColor("#9aa0a6")
status_item = QTableWidgetItem(status_text)
status_item.setForeground(status_color)
self.schedule_table.setItem(row, 6, status_item)
def _update_schedule_execution_record(self, task: QueuedTask, duration: float, summary: str):
"""更新调度执行记录(如果此任务来自调度)"""
schedule_id = self._task_schedule_mapping.get(task.id)
if not schedule_id:
return
# 从映射中移除(一次性)
del self._task_schedule_mapping[task.id]
# 获取调度任务
scheduled_task = self.schedule_store.get_task(schedule_id)
if not scheduled_task:
return
# 更新执行记录(包含完整日志)
status = "success" if task.status == TaskStatus.SUCCESS else "failed"
scheduled_task.update_execution_record(
task_id=task.id,
status=status,
exit_code=task.exit_code or 0,
duration=duration,
summary=summary or self._extract_result_summary(task),
output=task.output or "",
error=task.error or "",
)
# 更新调度任务状态
scheduled_task.last_status = "成功" if status == "success" else "失败"
# 保存
self.schedule_store.update_task(scheduled_task)
self.log_message.emit(f"[调度器] 任务 {scheduled_task.name} 执行完成: {scheduled_task.last_status}")
def _view_schedule_log(self, task_id: str):
"""查看调度任务日志"""
task = self.schedule_store.get_task(task_id)
if not task:
QMessageBox.warning(self, "提示", "未找到调度任务")
return
if not task.execution_history:
QMessageBox.information(self, "提示", "该调度任务尚无执行记录")
return
# 传递 task_queue 以便获取执行中任务的实时日志
dialog = ScheduleLogDialog(task, self.task_history, self.task_queue, self)
dialog.exec()
def _on_schedule_double_click(self, index):
"""双击调度任务查看日志"""
row = index.row()
tasks = self.schedule_store.get_all_tasks()
if row < len(tasks):
self._view_schedule_log(tasks[row].id)