Files
ZQYY.FQ-ETL/gui/widgets/task_panel.py

1225 lines
52 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 shutil
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QGroupBox, QLabel, QLineEdit, QComboBox, QCheckBox,
QPushButton, QPlainTextEdit, QFrame, QFileDialog, QMessageBox, QScrollArea,
QSpinBox, QDateTimeEdit, QSizePolicy, QTabWidget, QRadioButton, QButtonGroup
)
from PySide6.QtCore import Qt, Signal, QDateTime, QTimer
from PySide6.QtGui import QFont
from ..models.task_model import TaskConfig
from ..models.task_registry import (
task_registry, BusinessDomain, DOMAIN_LABELS, TaskDefinition,
get_fact_ods_task_codes, get_dimension_ods_task_codes,
)
from ..utils.cli_builder import CLIBuilder
from ..utils.app_settings import app_settings
from .task_selector import TaskSelectorWidget, DwdTableSelectorWidget
from .pipeline_selector import (
PipelineSelectorWidget, PIPELINE_OPTIONS, get_pipeline_layers,
WINDOW_SPLIT_OPTIONS, WINDOW_SPLIT_DAY_OPTIONS
)
class CollapsibleSection(QWidget):
"""可折叠区域组件"""
def __init__(self, title: str, parent: Optional[QWidget] = None):
super().__init__(parent)
self._is_expanded = False
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# 标题按钮
self._toggle_btn = QPushButton(f"{title}")
self._toggle_btn.setStyleSheet("""
QPushButton {
text-align: left;
padding: 8px 12px;
background: #f0f0f0;
border: 1px solid #ddd;
border-radius: 4px;
font-weight: bold;
}
QPushButton:hover {
background: #e0e0e0;
}
""")
self._toggle_btn.clicked.connect(self._toggle)
layout.addWidget(self._toggle_btn)
# 内容区域
self._content = QWidget()
self._content.setVisible(False)
self._content_layout = QVBoxLayout(self._content)
self._content_layout.setContentsMargins(12, 8, 12, 8)
layout.addWidget(self._content)
self._title = title
def _toggle(self):
"""切换展开/折叠状态"""
self._is_expanded = not self._is_expanded
self._content.setVisible(self._is_expanded)
icon = "" if self._is_expanded else ""
self._toggle_btn.setText(f"{icon} {self._title}")
def set_expanded(self, expanded: bool):
"""设置展开状态"""
if self._is_expanded != expanded:
self._toggle()
def setExpanded(self, expanded: bool):
"""Qt 风格别名,保持兼容性"""
self.set_expanded(expanded)
def isExpanded(self) -> bool:
"""获取当前展开状态"""
return self._is_expanded
def content_layout(self) -> QVBoxLayout:
"""获取内容布局"""
return self._content_layout
class TaskPanel(QWidget):
"""任务配置面板 - 简化版"""
ML_IMPORT_TASK_CODE = "DWS_ML_MANUAL_IMPORT"
ML_TEMPLATE_RELATIVE_PATH = "docs/templates/ml_manual_ledger_template.xlsx"
# 信号
task_started = Signal(str)
task_finished = Signal(bool, str)
log_message = Signal(str)
add_to_queue = Signal(object) # TaskConfig
create_schedule = Signal(str, list, dict)
def __init__(self, parent=None):
super().__init__(parent)
self.cli_builder = CLIBuilder()
self._init_ui()
self._connect_signals()
self._load_settings()
# 定时器:每秒更新时间预览
self._time_preview_timer = QTimer(self)
self._time_preview_timer.timeout.connect(self._update_time_preview)
self._time_preview_timer.start(1000)
def _init_ui(self):
"""初始化界面"""
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
# 标题
title = QLabel("ETL 分组配置")
title.setProperty("heading", True)
layout.addWidget(title)
# 任务分组标签页
self.task_tabs = QTabWidget()
self._init_update_tab()
self._init_build_tab()
layout.addWidget(self.task_tabs, 1)
# 通用选项
common_options = self._create_common_options()
layout.addWidget(common_options)
# 底部CLI 预览和执行按钮
bottom_widget = self._create_bottom_area()
layout.addWidget(bottom_widget)
def _init_update_tab(self):
"""初始化数据更新选项卡"""
self.update_tab = QWidget()
update_layout = QVBoxLayout(self.update_tab)
update_layout.setContentsMargins(0, 0, 0, 0)
update_layout.setSpacing(12)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 8, 0)
content_layout.setSpacing(12)
# 1. 管道选择组件
self.pipeline_selector = PipelineSelectorWidget()
content_layout.addWidget(self.pipeline_selector)
# 2. 高级选项(折叠)
self.advanced_section = CollapsibleSection("高级选项 - 任务分组与参数")
self._create_update_advanced_content()
content_layout.addWidget(self.advanced_section)
content_layout.addStretch()
scroll_area.setWidget(content_widget)
update_layout.addWidget(scroll_area, 1)
self.task_tabs.addTab(self.update_tab, "数据更新")
def _init_build_tab(self):
"""初始化数据建设选项卡"""
self.build_tab = QWidget()
build_layout = QVBoxLayout(self.build_tab)
build_layout.setContentsMargins(0, 0, 0, 0)
build_layout.setSpacing(12)
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setFrameShape(QFrame.NoFrame)
content_widget = QWidget()
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 8, 0)
content_layout.setSpacing(12)
# 数据建设任务分组
self.build_task_checks: Dict[str, QCheckBox] = {}
schema_tasks = task_registry.get_tasks_by_domain(BusinessDomain.SCHEMA)
quality_tasks = task_registry.get_tasks_by_domain(BusinessDomain.QUALITY)
other_tasks = task_registry.get_tasks_by_domain(BusinessDomain.OTHER)
if schema_tasks:
content_layout.addWidget(
self._create_task_group("数据库初始化", schema_tasks, self.build_task_checks)
)
if quality_tasks:
content_layout.addWidget(
self._create_task_group("质检与校验", quality_tasks, self.build_task_checks)
)
if other_tasks:
content_layout.addWidget(
self._create_task_group("其他工具", other_tasks, self.build_task_checks)
)
# ML 人工台账导入(数据建设专用)
ml_import_task = task_registry.get_task(self.ML_IMPORT_TASK_CODE)
relation_task = task_registry.get_task("DWS_RELATION_INDEX")
if ml_import_task or relation_task:
content_layout.addWidget(
self._create_ml_import_group(ml_import_task, relation_task)
)
# 时间窗口设置(可选)
self.build_window_group = self._create_build_window_group()
content_layout.addWidget(self.build_window_group)
content_layout.addStretch()
scroll_area.setWidget(content_widget)
build_layout.addWidget(scroll_area, 1)
self.task_tabs.addTab(self.build_tab, "数据建设")
def _create_common_options(self) -> QWidget:
"""创建通用选项区"""
group = QGroupBox("通用选项")
layout = QGridLayout(group)
self.dry_run_check = QCheckBox("Dry-run 模式(不提交数据库)")
layout.addWidget(self.dry_run_check, 0, 0, 1, 2)
layout.addWidget(QLabel("JSON 数据目录:"), 1, 0)
self.ingest_source_edit = QLineEdit()
self.ingest_source_edit.setPlaceholderText("可选,用于 INGEST_ONLY / MANUAL_INGEST")
layout.addWidget(self.ingest_source_edit, 1, 1)
self.browse_btn = QPushButton("浏览...")
self.browse_btn.setProperty("secondary", True)
self.browse_btn.setFixedWidth(80)
layout.addWidget(self.browse_btn, 1, 2)
return group
def _create_update_advanced_content(self):
"""创建数据更新高级选项内容"""
adv_layout = self.advanced_section.content_layout()
# ODS 表选择(当管道包含 ODS 时可用)
self.ods_group = QGroupBox("ODS 表选择")
ods_layout = QVBoxLayout(self.ods_group)
ods_desc = QLabel("选择要处理的 ODS 表(默认全选)")
ods_desc.setStyleSheet("color: #666;")
ods_layout.addWidget(ods_desc)
self.ods_task_selector = TaskSelectorWidget(
show_dimensions=True,
show_facts=True,
default_select_facts=True,
default_select_dimensions=True,
compact=True,
max_height=0,
)
ods_layout.addWidget(self.ods_task_selector)
adv_layout.addWidget(self.ods_group)
# DWD 表选择(按业务域分组,类似 ODS 选择器)
self.dwd_tables_group = QGroupBox("DWD 装载表选择")
dwd_group_layout = QVBoxLayout(self.dwd_tables_group)
dwd_desc = QLabel("选择要装载的 DWD 表(默认全选)")
dwd_desc.setStyleSheet("color: #666;")
dwd_group_layout.addWidget(dwd_desc)
self.dwd_table_selector = DwdTableSelectorWidget()
dwd_group_layout.addWidget(self.dwd_table_selector)
adv_layout.addWidget(self.dwd_tables_group)
# DWS 汇总任务选择
self.dws_task_checks: Dict[str, QCheckBox] = {}
dws_tasks = task_registry.get_tasks_by_domain(BusinessDomain.DWS)
self.dws_tasks_group = self._create_task_group(
"DWS 汇总任务",
dws_tasks,
self.dws_task_checks,
default_checked={"DWS_BUILD_ORDER_SUMMARY"},
)
adv_layout.addWidget(self.dws_tasks_group)
# 指数任务选择
self.index_group = QGroupBox("DWS 指数任务")
index_layout = QVBoxLayout(self.index_group)
self.index_task_checks: Dict[str, QCheckBox] = {}
self.index_task_order: List[str] = []
index_tasks = [
task for task in task_registry.get_tasks_by_domain(BusinessDomain.INDEX)
if task.code not in {"DWS_RECALL_INDEX", self.ML_IMPORT_TASK_CODE}
]
default_index_tasks = {"DWS_WINBACK_INDEX", "DWS_NEWCONV_INDEX", "DWS_RELATION_INDEX"}
self._attach_select_buttons(index_layout, self.index_task_checks)
for task in index_tasks:
checkbox = QCheckBox(task.name)
checkbox.setToolTip(f"{task.code}: {task.description}")
checkbox.setProperty("task_code", task.code)
checkbox.setChecked(task.code in default_index_tasks)
checkbox.stateChanged.connect(self._update_preview)
checkbox.stateChanged.connect(self._save_settings)
self.index_task_checks[task.code] = checkbox
self.index_task_order.append(task.code)
if task.code == "DWS_WINBACK_INDEX":
self.index_winback_check = checkbox
elif task.code == "DWS_NEWCONV_INDEX":
self.index_newconv_check = checkbox
elif task.code == "DWS_INTIMACY_INDEX":
self.index_intimacy_check = checkbox
elif task.code == "DWS_RELATION_INDEX":
self.index_relation_check = checkbox
elif task.code == "DWS_RECALL_INDEX":
self.index_recall_check = checkbox
index_layout.addWidget(checkbox)
# 指数回溯天数
index_params = QHBoxLayout()
index_params.addWidget(QLabel("回溯天数:"))
self.index_lookback_days = QSpinBox()
self.index_lookback_days.setRange(7, 180)
self.index_lookback_days.setValue(60)
self.index_lookback_days.setSuffix("")
index_params.addWidget(self.index_lookback_days)
index_params.addStretch()
index_layout.addLayout(index_params)
adv_layout.addWidget(self.index_group)
# 初始化可见性
self._update_advanced_visibility()
def _create_task_group(
self,
title: str,
tasks: List[TaskDefinition],
checkbox_map: Dict[str, QCheckBox],
default_checked: Optional[Set[str]] = None,
) -> QGroupBox:
"""创建任务复选框分组"""
group_box = QGroupBox(title)
group_layout = QVBoxLayout(group_box)
group_layout.setContentsMargins(8, 4, 8, 4)
group_layout.setSpacing(4)
self._attach_select_buttons(group_layout, checkbox_map)
for task in tasks:
checkbox = QCheckBox(task.name)
checkbox.setToolTip(f"{task.code}: {task.description}")
checkbox.setProperty("task_code", task.code)
if default_checked is not None:
checkbox.setChecked(task.code in default_checked)
checkbox.stateChanged.connect(self._update_preview)
checkbox.stateChanged.connect(self._save_settings)
checkbox_map[task.code] = checkbox
group_layout.addWidget(checkbox)
return group_box
def _attach_select_buttons(self, layout: QVBoxLayout, checkbox_map: Dict[str, QCheckBox]):
"""为任务分组添加全选/全不选按钮"""
btn_layout = QHBoxLayout()
btn_layout.setSpacing(8)
select_all_btn = QPushButton("全选")
select_all_btn.setProperty("secondary", True)
select_all_btn.setFixedWidth(60)
select_all_btn.clicked.connect(lambda: self._set_all_checked(checkbox_map, True))
deselect_all_btn = QPushButton("全不选")
deselect_all_btn.setProperty("secondary", True)
deselect_all_btn.setFixedWidth(60)
deselect_all_btn.clicked.connect(lambda: self._set_all_checked(checkbox_map, False))
btn_layout.addWidget(select_all_btn)
btn_layout.addWidget(deselect_all_btn)
btn_layout.addStretch()
layout.addLayout(btn_layout)
def _create_build_window_group(self) -> QGroupBox:
"""创建数据建设时间窗口设置"""
group = QGroupBox("时间窗口(可选)")
layout = QVBoxLayout(group)
layout.setSpacing(8)
self.build_window_button_group = QButtonGroup(self)
lookback_layout = QHBoxLayout()
self.build_lookback_radio = QRadioButton("回溯:")
self.build_lookback_radio.setProperty("mode_id", "lookback")
self.build_lookback_radio.setChecked(True)
self.build_window_button_group.addButton(self.build_lookback_radio, 0)
lookback_layout.addWidget(self.build_lookback_radio)
self.build_lookback_hours = QSpinBox()
self.build_lookback_hours.setRange(1, 720)
self.build_lookback_hours.setValue(24)
self.build_lookback_hours.setSuffix(" 小时")
self.build_lookback_hours.setToolTip("回溯时间长度")
self.build_lookback_hours.setFixedWidth(110)
lookback_layout.addWidget(self.build_lookback_hours)
lookback_layout.addStretch()
layout.addLayout(lookback_layout)
custom_layout = QHBoxLayout()
self.build_custom_radio = QRadioButton("自定义:")
self.build_custom_radio.setProperty("mode_id", "custom")
self.build_window_button_group.addButton(self.build_custom_radio, 1)
custom_layout.addWidget(self.build_custom_radio)
self.build_start_datetime = QDateTimeEdit()
self.build_start_datetime.setCalendarPopup(True)
self.build_start_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
self.build_start_datetime.setDateTime(QDateTime.currentDateTime().addDays(-1))
custom_layout.addWidget(self.build_start_datetime)
custom_layout.addWidget(QLabel("~"))
self.build_end_datetime = QDateTimeEdit()
self.build_end_datetime.setCalendarPopup(True)
self.build_end_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
self.build_end_datetime.setDateTime(QDateTime.currentDateTime())
custom_layout.addWidget(self.build_end_datetime)
custom_layout.addStretch()
layout.addLayout(custom_layout)
# 时间窗口切分
split_layout = QHBoxLayout()
split_layout.addWidget(QLabel("时间窗口切分:"))
self.build_split_combo = QComboBox()
for split_id, split_name in WINDOW_SPLIT_OPTIONS:
self.build_split_combo.addItem(split_name, split_id)
default_split_index = self.build_split_combo.findData("day")
if default_split_index >= 0:
self.build_split_combo.setCurrentIndex(default_split_index)
self.build_split_combo.setFixedWidth(110)
split_layout.addWidget(self.build_split_combo)
split_layout.addWidget(QLabel("切分天数:"))
self.build_split_days_combo = QComboBox()
for days, label in WINDOW_SPLIT_DAY_OPTIONS:
self.build_split_days_combo.addItem(label, days)
default_days_index = self.build_split_days_combo.findData(10)
if default_days_index >= 0:
self.build_split_days_combo.setCurrentIndex(default_days_index)
self.build_split_days_combo.setFixedWidth(90)
split_layout.addWidget(self.build_split_days_combo)
split_layout.addStretch()
layout.addLayout(split_layout)
self._update_build_window_controls()
return group
def _create_ml_import_group(
self,
ml_import_task: Optional[TaskDefinition],
relation_task: Optional[TaskDefinition],
) -> QGroupBox:
"""创建 ML 台账导入与关系指数重算区域。"""
group = QGroupBox("ML人工台账导入")
layout = QVBoxLayout(group)
layout.setSpacing(8)
desc = QLabel(
"先导入人工台账再执行关系指数RS/OS/MS/ML\n"
"覆盖策略30天内按日覆盖超过30天按固定纪元30天批次覆盖。"
)
desc.setStyleSheet("color: #666;")
layout.addWidget(desc)
if ml_import_task:
self.ml_manual_import_check = QCheckBox(ml_import_task.name)
self.ml_manual_import_check.setToolTip(
f"{ml_import_task.code}: {ml_import_task.description}"
)
self.ml_manual_import_check.setChecked(False)
self.ml_manual_import_check.stateChanged.connect(self._update_preview)
self.ml_manual_import_check.stateChanged.connect(self._save_settings)
self.build_task_checks[ml_import_task.code] = self.ml_manual_import_check
layout.addWidget(self.ml_manual_import_check)
else:
self.ml_manual_import_check = None
if relation_task:
self.build_relation_index_check = QCheckBox(relation_task.name)
self.build_relation_index_check.setToolTip(
f"{relation_task.code}: {relation_task.description}"
)
self.build_relation_index_check.setChecked(True)
self.build_relation_index_check.stateChanged.connect(self._update_preview)
self.build_relation_index_check.stateChanged.connect(self._save_settings)
self.build_task_checks[relation_task.code] = self.build_relation_index_check
layout.addWidget(self.build_relation_index_check)
else:
self.build_relation_index_check = None
file_layout = QHBoxLayout()
file_layout.addWidget(QLabel("台账文件:"))
self.ml_manual_file_edit = QLineEdit()
self.ml_manual_file_edit.setPlaceholderText("请选择 .xlsx 台账文件订单一行最多5个助教")
self.ml_manual_file_edit.textChanged.connect(self._update_preview)
self.ml_manual_file_edit.textChanged.connect(self._save_settings)
file_layout.addWidget(self.ml_manual_file_edit, 1)
self.ml_manual_file_btn = QPushButton("选择文件...")
self.ml_manual_file_btn.setProperty("secondary", True)
self.ml_manual_file_btn.clicked.connect(self._browse_ml_manual_file)
file_layout.addWidget(self.ml_manual_file_btn)
self.ml_manual_template_btn = QPushButton("下载模板")
self.ml_manual_template_btn.setProperty("secondary", True)
self.ml_manual_template_btn.clicked.connect(self._download_ml_template)
file_layout.addWidget(self.ml_manual_template_btn)
layout.addLayout(file_layout)
return group
def _update_build_window_controls(self):
"""更新数据建设时间窗口控件状态"""
is_lookback = self.build_lookback_radio.isChecked()
self.build_lookback_hours.setEnabled(is_lookback)
self.build_start_datetime.setEnabled(not is_lookback)
self.build_end_datetime.setEnabled(not is_lookback)
if hasattr(self, "build_split_days_combo") and hasattr(self, "build_split_combo"):
self.build_split_days_combo.setEnabled(self.build_split_combo.currentData() == "day")
def _get_selected_task_codes(self, checkbox_map: Dict[str, QCheckBox]) -> List[str]:
"""获取勾选的任务编码"""
return [code for code, checkbox in checkbox_map.items() if checkbox.isChecked()]
def _set_checked_codes(self, checkbox_map: Dict[str, QCheckBox], codes: List[str]):
"""设置勾选的任务编码"""
codes_set = set(codes or [])
for code, checkbox in checkbox_map.items():
checkbox.blockSignals(True)
checkbox.setChecked(code in codes_set)
checkbox.blockSignals(False)
def _set_all_checked(self, checkbox_map: Dict[str, QCheckBox], checked: bool):
"""设置全部勾选/取消"""
for checkbox in checkbox_map.values():
checkbox.blockSignals(True)
checkbox.setChecked(checked)
checkbox.blockSignals(False)
self._update_preview()
self._save_settings()
def _get_selected_index_tasks(self) -> List[str]:
"""获取勾选的指数任务编码"""
selected = []
for code in self.index_task_order:
checkbox = self.index_task_checks.get(code)
if checkbox and checkbox.isChecked():
selected.append(code)
return selected
def _get_build_window_strings(self) -> Tuple[str, str]:
"""获取数据建设窗口字符串"""
if self.build_lookback_radio.isChecked():
now = datetime.now()
start_time = now - timedelta(hours=self.build_lookback_hours.value())
return (
start_time.strftime("%Y-%m-%d %H:%M:%S"),
now.strftime("%Y-%m-%d %H:%M:%S"),
)
start_str = self.build_start_datetime.dateTime().toString("yyyy-MM-dd HH:mm:ss")
end_str = self.build_end_datetime.dateTime().toString("yyyy-MM-dd HH:mm:ss")
return start_str, end_str
def _get_build_window_split(self) -> Tuple[str, Optional[int]]:
"""获取数据建设时间窗口切分配置"""
split_unit = "none"
split_days: Optional[int] = None
if hasattr(self, "build_split_combo"):
split_unit = self.build_split_combo.currentData()
if split_unit == "day" and hasattr(self, "build_split_days_combo"):
split_days = int(self.build_split_days_combo.currentData())
return split_unit, split_days
def _is_update_tab(self) -> bool:
"""是否数据更新选项卡"""
return self.task_tabs.currentIndex() == 0
def _is_build_tab(self) -> bool:
"""是否数据建设选项卡"""
return self.task_tabs.currentIndex() == 1
def _create_bottom_area(self) -> QWidget:
"""创建底部区域"""
widget = QWidget()
layout = QVBoxLayout(widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
# 时间窗口预览
self.time_preview_label = QLabel()
self.time_preview_label.setStyleSheet("color: #666; font-size: 12px;")
layout.addWidget(self.time_preview_label)
self._update_time_preview()
# CLI 预览
preview_group = QGroupBox("命令行预览(可编辑)")
preview_layout = QVBoxLayout(preview_group)
self.cli_preview = QPlainTextEdit()
self.cli_preview.setMaximumHeight(100)
self.cli_preview.setFont(QFont("Consolas", 10))
self.cli_preview.setPlaceholderText("命令将在这里显示...")
preview_layout.addWidget(self.cli_preview)
layout.addWidget(preview_group)
# 执行按钮
btn_layout = QHBoxLayout()
btn_layout.addStretch()
self.run_btn = QPushButton("单次执行")
self.run_btn.setFixedWidth(120)
btn_layout.addWidget(self.run_btn)
self.schedule_btn = QPushButton("创建调度")
self.schedule_btn.setProperty("secondary", True)
self.schedule_btn.setFixedWidth(100)
btn_layout.addWidget(self.schedule_btn)
self.stop_btn = QPushButton("停止")
self.stop_btn.setProperty("danger", True)
self.stop_btn.setEnabled(False)
self.stop_btn.setFixedWidth(80)
btn_layout.addWidget(self.stop_btn)
layout.addLayout(btn_layout)
return widget
def _connect_signals(self):
"""连接信号"""
# 选项卡变化
self.task_tabs.currentChanged.connect(self._on_tab_changed)
# 管道选择变化
self.pipeline_selector.pipeline_changed.connect(self._on_pipeline_changed)
self.pipeline_selector.config_changed.connect(self._update_preview)
self.pipeline_selector.config_changed.connect(self._update_time_preview)
# 高级选项变化
self.ods_task_selector.selection_changed.connect(self._update_preview)
self.dwd_table_selector.selection_changed.connect(self._update_preview)
self.dwd_table_selector.selection_changed.connect(self._save_settings)
self.index_lookback_days.valueChanged.connect(self._update_preview)
self.dry_run_check.stateChanged.connect(self._update_preview)
self.ingest_source_edit.textChanged.connect(self._update_preview)
self.build_lookback_hours.valueChanged.connect(self._update_preview)
self.build_start_datetime.dateTimeChanged.connect(self._update_preview)
self.build_end_datetime.dateTimeChanged.connect(self._update_preview)
if hasattr(self, "build_split_combo"):
self.build_split_combo.currentIndexChanged.connect(self._on_build_window_split_changed)
if hasattr(self, "build_split_days_combo"):
self.build_split_days_combo.currentIndexChanged.connect(self._on_build_window_split_changed)
self.build_lookback_hours.valueChanged.connect(self._update_time_preview)
self.build_start_datetime.dateTimeChanged.connect(self._update_time_preview)
self.build_end_datetime.dateTimeChanged.connect(self._update_time_preview)
# 浏览目录
self.browse_btn.clicked.connect(self._browse_source_dir)
# 执行按钮
self.run_btn.clicked.connect(self._run_task)
self.schedule_btn.clicked.connect(self._create_schedule)
self.stop_btn.clicked.connect(self._stop_task)
# 保存设置
self.pipeline_selector.config_changed.connect(self._save_settings)
self.ods_task_selector.selection_changed.connect(self._save_settings)
self.index_lookback_days.valueChanged.connect(self._save_settings)
self.dry_run_check.stateChanged.connect(self._save_settings)
self.ingest_source_edit.textChanged.connect(self._save_settings)
self.build_lookback_hours.valueChanged.connect(self._save_settings)
self.build_start_datetime.dateTimeChanged.connect(self._save_settings)
self.build_end_datetime.dateTimeChanged.connect(self._save_settings)
if hasattr(self, "build_split_combo"):
self.build_split_combo.currentIndexChanged.connect(self._save_settings)
if hasattr(self, "build_split_days_combo"):
self.build_split_days_combo.currentIndexChanged.connect(self._save_settings)
self.build_lookback_radio.toggled.connect(self._on_build_window_mode_changed)
self.build_custom_radio.toggled.connect(self._on_build_window_mode_changed)
def _on_pipeline_changed(self, pipeline_id: str):
"""管道选择变化"""
self._update_advanced_visibility()
self._update_preview()
def _on_tab_changed(self, index: int):
"""选项卡切换"""
self._update_time_preview()
self._update_preview()
self._save_settings()
def _on_build_window_mode_changed(self):
"""数据建设窗口模式变化"""
self._update_build_window_controls()
self._update_time_preview()
self._update_preview()
self._save_settings()
def _on_build_window_split_changed(self):
"""数据建设窗口切分变化"""
self._update_build_window_controls()
self._update_preview()
def _update_advanced_visibility(self):
"""更新高级选项的可见性"""
layers = self.pipeline_selector.get_pipeline_layers()
self.ods_group.setVisible("ODS" in layers)
self.dwd_tables_group.setVisible("DWD" in layers)
self.dws_tasks_group.setVisible("DWS" in layers)
self.index_group.setVisible("INDEX" in layers)
def _update_time_preview(self):
"""更新时间窗口预览"""
if self._is_update_tab():
config = self.pipeline_selector.get_config()
mode = config.get("window_mode", "lookback")
if mode == "lookback":
now = datetime.now()
hours = config.get("lookback_hours", 24)
overlap = config.get("overlap_seconds", 600)
start_time = now - timedelta(hours=hours, seconds=overlap)
start_str = start_time.strftime("%Y-%m-%d %H:%M:%S")
end_str = now.strftime("%Y-%m-%d %H:%M:%S")
self.time_preview_label.setText(f"时间窗口: {start_str} ~ {end_str}")
else:
start_str = config.get("window_start", "")
end_str = config.get("window_end", "")
self.time_preview_label.setText(f"时间窗口: {start_str} ~ {end_str}")
return
# 数据建设窗口预览
start_str, end_str = self._get_build_window_strings()
self.time_preview_label.setText(f"时间窗口: {start_str} ~ {end_str}")
def _browse_source_dir(self):
"""浏览数据源目录"""
dir_path = QFileDialog.getExistingDirectory(self, "选择 JSON 数据目录")
if dir_path:
self.ingest_source_edit.setText(dir_path)
def _browse_ml_manual_file(self):
"""选择 ML 人工台账文件。"""
file_path, _ = QFileDialog.getOpenFileName(
self,
"选择 ML 人工台账文件",
self.ml_manual_file_edit.text().strip() or "",
"Excel 文件 (*.xlsx)",
)
if file_path:
self.ml_manual_file_edit.setText(file_path)
def _resolve_project_root(self) -> Path:
"""解析 ETL 项目根目录。"""
candidates: List[Path] = []
if app_settings.etl_project_path:
candidates.append(Path(app_settings.etl_project_path))
candidates.extend(
[
Path.cwd() / "etl_billiards",
Path(__file__).resolve().parents[2],
]
)
for path in candidates:
if path and (path / "cli" / "main.py").exists():
return path
return Path.cwd()
def _download_ml_template(self):
"""下载复制ML 台账模板到用户指定位置。"""
project_root = self._resolve_project_root()
template_path = project_root / self.ML_TEMPLATE_RELATIVE_PATH
if not template_path.exists():
QMessageBox.warning(
self,
"提示",
f"未找到模板文件:{template_path}\n请先执行模板生成脚本。",
)
return
save_path, _ = QFileDialog.getSaveFileName(
self,
"保存台账模板",
str(Path.home() / "ml_manual_ledger_template.xlsx"),
"Excel 文件 (*.xlsx)",
)
if not save_path:
return
try:
shutil.copyfile(template_path, save_path)
QMessageBox.information(self, "成功", f"模板已保存到:\n{save_path}")
except Exception as exc: # noqa: BLE001
QMessageBox.critical(self, "失败", f"模板保存失败:{exc}")
def _get_config(self) -> TaskConfig:
"""获取当前配置"""
if self._is_build_tab():
return self._get_build_config()
return self._get_update_config()
def _get_update_config(self) -> TaskConfig:
"""获取数据更新配置"""
pipeline_config = self.pipeline_selector.get_config()
layers = self.pipeline_selector.get_pipeline_layers()
# 收集任务列表
tasks: List[str] = []
if "ODS" in layers:
tasks.extend(self.ods_task_selector.get_selected_codes())
if "DWD" in layers:
tasks.append("DWD_LOAD_FROM_ODS")
if "DWS" in layers:
tasks.extend(self._get_selected_task_codes(self.dws_task_checks))
selected_index_tasks = []
if "INDEX" in layers:
selected_index_tasks = self._get_selected_index_tasks()
tasks.extend(selected_index_tasks)
# 构建时间窗口
window_mode = pipeline_config.get("window_mode", "lookback")
if window_mode == "lookback":
now = datetime.now()
hours = pipeline_config.get("lookback_hours", 24)
overlap = pipeline_config.get("overlap_seconds", 600)
start_time = now - timedelta(hours=hours, seconds=overlap)
window_start = start_time.strftime("%Y-%m-%d %H:%M:%S")
window_end = now.strftime("%Y-%m-%d %H:%M:%S")
else:
window_start = pipeline_config.get("window_start")
window_end = pipeline_config.get("window_end")
# 构建环境变量
env_vars = {}
split_unit = pipeline_config.get("window_split", "day")
split_days = pipeline_config.get("window_split_days") or 10
if split_unit:
env_vars["WINDOW_SPLIT_UNIT"] = split_unit
if split_unit == "day":
env_vars["WINDOW_SPLIT_DAYS"] = str(split_days)
if selected_index_tasks:
env_vars["INDEX_LOOKBACK_DAYS"] = str(self.index_lookback_days.value())
# DWD 表过滤(仅当未全选时传递,避免不必要的过滤)
if "DWD" in layers:
selected_dwd_tables = self.dwd_table_selector.get_selected_codes()
if not self.dwd_table_selector.is_all_selected():
env_vars["DWD_ONLY_TABLES"] = ",".join(selected_dwd_tables)
# 处理模式
processing_mode = pipeline_config.get("processing_mode", "increment_only")
if processing_mode == "increment_verify":
env_vars["ENABLE_POST_VERIFICATION"] = "1"
# 校验附加选项(通过环境变量覆盖配置)
skip_ods = pipeline_config.get("skip_ods_when_fetch_before_verify")
if skip_ods is not None:
env_vars["VERIFY_SKIP_ODS_ON_FETCH"] = "true" if skip_ods else "false"
use_local_json = pipeline_config.get("ods_use_local_json")
if use_local_json is not None:
env_vars["VERIFY_ODS_LOCAL_JSON"] = "true" if use_local_json else "false"
config = TaskConfig(
tasks=tasks,
pipeline_flow="FULL",
dry_run=self.dry_run_check.isChecked(),
window_start=window_start,
window_end=window_end,
window_split=split_unit,
window_split_days=split_days if split_unit == "day" else None,
ingest_source=self.ingest_source_edit.text().strip() or None,
env_vars=env_vars,
pipeline=pipeline_config.get("pipeline", "api_ods_dwd"),
processing_mode=processing_mode,
fetch_before_verify=pipeline_config.get("fetch_before_verify", False),
window_mode=window_mode,
lookback_hours=pipeline_config.get("lookback_hours", 24),
overlap_seconds=pipeline_config.get("overlap_seconds", 600),
)
return config
def _get_build_config(self) -> TaskConfig:
"""获取数据建设配置"""
tasks = self._get_selected_task_codes(self.build_task_checks)
window_start, window_end = self._get_build_window_strings()
env_vars: Dict[str, str] = {}
split_unit, split_days = self._get_build_window_split()
if split_unit:
env_vars["WINDOW_SPLIT_UNIT"] = split_unit
if split_unit == "day" and split_days:
env_vars["WINDOW_SPLIT_DAYS"] = str(split_days)
# ML 台账导入任务通过环境变量传递 Excel 路径
if self.ML_IMPORT_TASK_CODE in tasks:
ledger_file = self.ml_manual_file_edit.text().strip() if hasattr(self, "ml_manual_file_edit") else ""
if ledger_file:
env_vars["ML_MANUAL_LEDGER_FILE"] = ledger_file
config = TaskConfig(
tasks=tasks,
pipeline_flow="FULL",
dry_run=self.dry_run_check.isChecked(),
window_start=window_start,
window_end=window_end,
window_split=split_unit,
window_split_days=split_days if split_unit == "day" else None,
ingest_source=self.ingest_source_edit.text().strip() or None,
env_vars=env_vars,
pipeline="legacy",
)
return config
def _update_preview(self):
"""更新命令行预览"""
config = self._get_config()
cmd_str = self.cli_builder.build_command_string(config)
# 添加环境变量注释
if config.env_vars:
env_preview = "\n".join(f"# {k}={v}" for k, v in config.env_vars.items())
cmd_str = f"{cmd_str}\n\n# 环境变量:\n{env_preview}"
self.cli_preview.setPlainText(cmd_str)
def _run_task(self):
"""执行任务 - 通过任务管理器执行,以便在任务队列中显示"""
config = self._get_config()
if not config.tasks:
QMessageBox.warning(self, "提示", "当前配置没有可执行的任务")
return
# ML 台账导入前置校验:必须选择有效文件
if self._is_build_tab() and self.ML_IMPORT_TASK_CODE in config.tasks:
ledger_file = config.env_vars.get("ML_MANUAL_LEDGER_FILE", "").strip()
if not ledger_file:
QMessageBox.warning(self, "提示", "已勾选“ML人工台账导入”请先选择台账文件")
return
if not Path(ledger_file).exists():
QMessageBox.warning(self, "提示", f"台账文件不存在:\n{ledger_file}")
return
# 获取管道名称
pipeline_name = "数据建设"
if self._is_update_tab():
pipeline_name = ""
for pid, name, _ in PIPELINE_OPTIONS:
if pid == config.pipeline:
pipeline_name = name
break
pipeline_name = pipeline_name or config.pipeline
# 通过 add_to_queue 信号将任务添加到任务管理器的队列中
# 主窗口会自动切换到任务管理面板并开始执行
self.add_to_queue.emit(config)
self.log_message.emit(f"[GUI] 任务已添加到队列: {pipeline_name} ({len(config.tasks)}个任务)")
def _create_schedule(self):
"""创建调度任务"""
config = self._get_config()
if not config.tasks:
QMessageBox.warning(self, "提示", "当前配置没有可执行的任务")
return
if self._is_update_tab():
pipeline_config = self.pipeline_selector.get_config()
pipeline_name = ""
for pid, name, _ in PIPELINE_OPTIONS:
if pid == config.pipeline:
pipeline_name = name
break
pipeline_name = pipeline_name or config.pipeline
task_config = {
"pipeline": config.pipeline,
"processing_mode": config.processing_mode,
"window_mode": config.window_mode,
"lookback_hours": config.lookback_hours,
"overlap_seconds": config.overlap_seconds,
"window_split": config.window_split,
"window_split_days": config.window_split_days,
"skip_ods_when_fetch_before_verify": pipeline_config.get("skip_ods_when_fetch_before_verify"),
"ods_use_local_json": pipeline_config.get("ods_use_local_json"),
"pipeline_flow": "FULL",
}
schedule_name = f"调度: {pipeline_name}"
schedule_desc = f"管道: {pipeline_name}"
else:
lookback_hours = self.build_lookback_hours.value()
if self.build_custom_radio.isChecked():
start_sec = self.build_start_datetime.dateTime().toSecsSinceEpoch()
end_sec = self.build_end_datetime.dateTime().toSecsSinceEpoch()
if end_sec > start_sec:
lookback_hours = max(1, int((end_sec - start_sec) // 3600))
split_unit, split_days = self._get_build_window_split()
task_config = {
"pipeline_flow": "FULL",
"lookback_hours": lookback_hours,
"window_split": split_unit,
"window_split_days": split_days,
}
schedule_name = "调度: 数据建设"
schedule_desc = "类型: 数据建设"
self.create_schedule.emit(schedule_name, config.tasks, task_config)
self.log_message.emit(f"[GUI] 创建调度任务: {schedule_name}")
QMessageBox.information(
self, "提示",
f"已创建调度任务\n\n{schedule_desc}\n任务数: {len(config.tasks)}"
)
def _stop_task(self):
"""停止任务 - 已委托给任务管理器,此方法保留兼容性"""
self.log_message.emit("[GUI] 请在「任务管理」页面停止任务")
QMessageBox.information(self, "提示", "请切换到「任务管理」页面停止任务")
def is_running(self) -> bool:
"""是否正在执行任务 - 现在任务由任务管理器执行"""
# 任务已委托给任务管理器,此处总是返回 False
# 主窗口会通过任务管理器检查任务状态
return False
# ==================== 设置持久化 ====================
def _load_settings(self):
"""从持久化存储加载设置"""
try:
# 加载管道配置
if hasattr(app_settings, 'unified_pipeline'):
self.pipeline_selector.set_pipeline_id(app_settings.unified_pipeline)
if hasattr(app_settings, 'unified_processing_mode'):
self.pipeline_selector.set_processing_mode(app_settings.unified_processing_mode)
if hasattr(app_settings, 'unified_fetch_before_verify'):
self.pipeline_selector.set_fetch_before_verify(app_settings.unified_fetch_before_verify)
if hasattr(app_settings, 'unified_skip_ods_when_fetch_before_verify'):
self.pipeline_selector.set_skip_ods_when_fetch_before_verify(
app_settings.unified_skip_ods_when_fetch_before_verify
)
if hasattr(app_settings, 'unified_ods_use_local_json'):
self.pipeline_selector.set_ods_use_local_json(
app_settings.unified_ods_use_local_json
)
if hasattr(app_settings, 'unified_window_mode'):
self.pipeline_selector.set_window_mode(app_settings.unified_window_mode)
if hasattr(app_settings, 'unified_lookback_hours'):
self.pipeline_selector.set_lookback_hours(app_settings.unified_lookback_hours)
if hasattr(app_settings, 'unified_overlap_seconds'):
self.pipeline_selector.set_overlap_seconds(app_settings.unified_overlap_seconds)
if hasattr(app_settings, 'unified_window_split'):
self.pipeline_selector.set_window_split(app_settings.unified_window_split)
if hasattr(app_settings, 'unified_window_split_days'):
self.pipeline_selector.set_window_split_days(app_settings.unified_window_split_days)
# 加载 ODS 任务选择
if hasattr(app_settings, 'unified_ods_tasks'):
saved_tasks = app_settings.unified_ods_tasks
if saved_tasks:
self.ods_task_selector.set_selected_codes(saved_tasks)
# 加载 DWD 表选择
if hasattr(app_settings, 'unified_dwd_tasks'):
saved_dwd = app_settings.unified_dwd_tasks
if saved_dwd:
self.dwd_table_selector.set_selected_codes(saved_dwd)
# 加载 DWS 任务选择
if hasattr(app_settings, 'unified_dws_tasks'):
saved_dws = app_settings.unified_dws_tasks
if saved_dws:
self._set_checked_codes(self.dws_task_checks, saved_dws)
# 加载指数设置
if hasattr(app_settings, 'index_winback_check'):
self.index_winback_check.setChecked(app_settings.index_winback_check)
elif hasattr(app_settings, 'index_recall_check'):
# 兼容旧设置
self.index_winback_check.setChecked(app_settings.index_recall_check)
if hasattr(app_settings, 'index_newconv_check'):
self.index_newconv_check.setChecked(app_settings.index_newconv_check)
if hasattr(app_settings, 'index_intimacy_check'):
self.index_intimacy_check.setChecked(app_settings.index_intimacy_check)
if hasattr(app_settings, 'index_relation_check') and hasattr(self, 'index_relation_check'):
self.index_relation_check.setChecked(app_settings.index_relation_check)
if hasattr(app_settings, 'index_recall_check') and hasattr(self, 'index_recall_check'):
self.index_recall_check.setChecked(app_settings.index_recall_check)
if hasattr(app_settings, 'index_lookback_days'):
self.index_lookback_days.setValue(app_settings.index_lookback_days)
# 加载数据建设任务选择
if hasattr(app_settings, 'build_tasks'):
build_tasks = app_settings.build_tasks
if build_tasks:
self._set_checked_codes(self.build_task_checks, build_tasks)
if hasattr(app_settings, 'ml_manual_file_path') and hasattr(self, "ml_manual_file_edit"):
self.ml_manual_file_edit.setText(app_settings.ml_manual_file_path)
# 加载数据建设窗口配置
if hasattr(app_settings, 'build_window_mode'):
if app_settings.build_window_mode == "custom":
self.build_custom_radio.setChecked(True)
else:
self.build_lookback_radio.setChecked(True)
if hasattr(app_settings, 'build_lookback_hours'):
self.build_lookback_hours.setValue(app_settings.build_lookback_hours)
if hasattr(app_settings, 'build_window_start'):
dt = QDateTime.fromString(app_settings.build_window_start, "yyyy-MM-dd HH:mm:ss")
if dt.isValid():
self.build_start_datetime.setDateTime(dt)
if hasattr(app_settings, 'build_window_end'):
dt = QDateTime.fromString(app_settings.build_window_end, "yyyy-MM-dd HH:mm:ss")
if dt.isValid():
self.build_end_datetime.setDateTime(dt)
if hasattr(app_settings, 'build_window_split') and hasattr(self, "build_split_combo"):
index = self.build_split_combo.findData(app_settings.build_window_split)
if index >= 0:
self.build_split_combo.setCurrentIndex(index)
if hasattr(app_settings, 'build_window_split_days') and hasattr(self, "build_split_days_combo"):
index = self.build_split_days_combo.findData(app_settings.build_window_split_days)
if index >= 0:
self.build_split_days_combo.setCurrentIndex(index)
# 恢复选项卡
if hasattr(app_settings, 'task_panel_tab'):
tab_idx = app_settings.task_panel_tab
if 0 <= tab_idx < self.task_tabs.count():
self.task_tabs.setCurrentIndex(tab_idx)
# 更新可见性
self._update_advanced_visibility()
self._update_preview()
self._update_time_preview()
except Exception as e:
self.log_message.emit(f"[GUI] 加载设置失败: {e}")
def _save_settings(self):
"""保存设置到持久化存储"""
try:
config = self.pipeline_selector.get_config()
app_settings.unified_pipeline = config.get("pipeline", "api_ods_dwd")
app_settings.unified_processing_mode = config.get("processing_mode", "increment_only")
app_settings.unified_fetch_before_verify = config.get("fetch_before_verify", False)
app_settings.unified_skip_ods_when_fetch_before_verify = config.get(
"skip_ods_when_fetch_before_verify", True
)
app_settings.unified_ods_use_local_json = config.get(
"ods_use_local_json", True
)
app_settings.unified_window_mode = config.get("window_mode", "lookback")
app_settings.unified_lookback_hours = config.get("lookback_hours", 24)
app_settings.unified_overlap_seconds = config.get("overlap_seconds", 600)
app_settings.unified_window_split = config.get("window_split", "day")
app_settings.unified_window_split_days = config.get("window_split_days") or 10
app_settings.unified_ods_tasks = self.ods_task_selector.get_selected_codes()
app_settings.unified_dwd_tasks = self.dwd_table_selector.get_selected_codes()
app_settings.unified_dws_tasks = self._get_selected_task_codes(self.dws_task_checks)
app_settings.index_winback_check = self.index_winback_check.isChecked()
app_settings.index_newconv_check = self.index_newconv_check.isChecked()
app_settings.index_intimacy_check = self.index_intimacy_check.isChecked()
if hasattr(self, 'index_relation_check'):
app_settings.index_relation_check = self.index_relation_check.isChecked()
if hasattr(self, 'index_recall_check'):
app_settings.index_recall_check = self.index_recall_check.isChecked()
app_settings.index_lookback_days = self.index_lookback_days.value()
app_settings.build_tasks = self._get_selected_task_codes(self.build_task_checks)
app_settings.build_window_mode = "custom" if self.build_custom_radio.isChecked() else "lookback"
app_settings.build_lookback_hours = self.build_lookback_hours.value()
app_settings.build_window_start = self.build_start_datetime.dateTime().toString("yyyy-MM-dd HH:mm:ss")
app_settings.build_window_end = self.build_end_datetime.dateTime().toString("yyyy-MM-dd HH:mm:ss")
if hasattr(self, "build_split_combo"):
app_settings.build_window_split = self.build_split_combo.currentData()
if hasattr(self, "build_split_days_combo"):
app_settings.build_window_split_days = int(self.build_split_days_combo.currentData()) or 10
if hasattr(self, "ml_manual_file_edit"):
app_settings.ml_manual_file_path = self.ml_manual_file_edit.text().strip()
app_settings.task_panel_tab = self.task_tabs.currentIndex()
except Exception as e:
self.log_message.emit(f"[GUI] 保存设置失败: {e}")
# ==================== 兼容性方法 ====================
def refresh_tasks(self):
"""刷新任务列表(兼容性方法)"""
pass