# -*- coding: utf-8 -*- # AI_CHANGELOG [2026-02-13] 移除 index_intimacy_check 复选框及相关信号连接 """任务配置面板 - 简化版统一界面""" 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 {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_RELATION_INDEX": self.index_relation_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_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() 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