初始提交:飞球 ETL 系统全量代码
This commit is contained in:
21
gui/widgets/__init__.py
Normal file
21
gui/widgets/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""GUI 组件模块"""
|
||||
|
||||
from .task_panel import TaskPanel
|
||||
from .env_editor import EnvEditor
|
||||
from .log_viewer import LogViewer
|
||||
from .db_viewer import DBViewer
|
||||
from .status_panel import StatusPanel
|
||||
from .task_manager import TaskManager
|
||||
from .task_selector import TaskSelectorWidget, CompactTaskSelector
|
||||
|
||||
__all__ = [
|
||||
"TaskPanel",
|
||||
"EnvEditor",
|
||||
"LogViewer",
|
||||
"DBViewer",
|
||||
"StatusPanel",
|
||||
"TaskManager",
|
||||
"TaskSelectorWidget",
|
||||
"CompactTaskSelector",
|
||||
]
|
||||
390
gui/widgets/db_viewer.py
Normal file
390
gui/widgets/db_viewer.py
Normal file
@@ -0,0 +1,390 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据库查看器"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||
QGroupBox, QLabel, QPushButton, QLineEdit, QPlainTextEdit,
|
||||
QTableWidget, QTableWidgetItem, QTreeWidget, QTreeWidgetItem,
|
||||
QHeaderView, QComboBox, QTabWidget, QMessageBox, QFrame
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from ..workers.db_worker import DBWorker
|
||||
from ..utils.config_helper import ConfigHelper
|
||||
|
||||
|
||||
# 常用查询模板
|
||||
QUERY_TEMPLATES = {
|
||||
"ODS 行数统计": """
|
||||
SELECT
|
||||
table_name,
|
||||
(xpath('/row/cnt/text()',
|
||||
query_to_xml('SELECT COUNT(*) AS cnt FROM ' || table_schema || '.' || table_name, false, false, ''))
|
||||
)[1]::text::bigint AS row_count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'billiards_ods'
|
||||
ORDER BY table_name;
|
||||
""",
|
||||
"DWD 行数统计": """
|
||||
SELECT
|
||||
table_name,
|
||||
(xpath('/row/cnt/text()',
|
||||
query_to_xml('SELECT COUNT(*) AS cnt FROM ' || table_schema || '.' || table_name, false, false, ''))
|
||||
)[1]::text::bigint AS row_count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'billiards_dwd'
|
||||
ORDER BY table_name;
|
||||
""",
|
||||
"ETL 游标状态": """
|
||||
SELECT
|
||||
task_code,
|
||||
last_start,
|
||||
last_end,
|
||||
last_run_id,
|
||||
updated_at
|
||||
FROM etl_admin.etl_cursor
|
||||
ORDER BY task_code;
|
||||
""",
|
||||
"最近运行记录": """
|
||||
SELECT
|
||||
run_id,
|
||||
task_code,
|
||||
status,
|
||||
started_at,
|
||||
finished_at,
|
||||
EXTRACT(EPOCH FROM (finished_at - started_at))::int AS duration_sec,
|
||||
rows_affected
|
||||
FROM etl_admin.run_tracker
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 50;
|
||||
""",
|
||||
"ODS 最新入库时间": """
|
||||
SELECT
|
||||
'payment_transactions' AS table_name, MAX(fetched_at) AS max_fetched_at FROM billiards_ods.payment_transactions
|
||||
UNION ALL
|
||||
SELECT 'member_profiles', MAX(fetched_at) FROM billiards_ods.member_profiles
|
||||
UNION ALL
|
||||
SELECT 'settlement_records', MAX(fetched_at) FROM billiards_ods.settlement_records
|
||||
UNION ALL
|
||||
SELECT 'recharge_settlements', MAX(fetched_at) FROM billiards_ods.recharge_settlements
|
||||
ORDER BY table_name;
|
||||
""",
|
||||
}
|
||||
|
||||
|
||||
class DBViewer(QWidget):
|
||||
"""数据库查看器"""
|
||||
|
||||
# 信号
|
||||
connection_changed = Signal(bool, str) # 连接状态变化
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.config_helper = ConfigHelper()
|
||||
self.db_worker = DBWorker(self)
|
||||
self._connected = False
|
||||
|
||||
self._init_ui()
|
||||
self._connect_signals()
|
||||
self._load_dsn_from_env()
|
||||
|
||||
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)
|
||||
|
||||
# 连接配置
|
||||
conn_group = QGroupBox("数据库连接")
|
||||
conn_layout = QHBoxLayout(conn_group)
|
||||
|
||||
conn_layout.addWidget(QLabel("DSN:"))
|
||||
self.dsn_edit = QLineEdit()
|
||||
self.dsn_edit.setPlaceholderText("postgresql://user:password@host:5432/dbname")
|
||||
self.dsn_edit.setEchoMode(QLineEdit.Password)
|
||||
conn_layout.addWidget(self.dsn_edit, 1)
|
||||
|
||||
self.show_dsn_btn = QPushButton("显示")
|
||||
self.show_dsn_btn.setProperty("secondary", True)
|
||||
self.show_dsn_btn.setCheckable(True)
|
||||
self.show_dsn_btn.setFixedWidth(60)
|
||||
conn_layout.addWidget(self.show_dsn_btn)
|
||||
|
||||
self.connect_btn = QPushButton("连接")
|
||||
self.connect_btn.setFixedWidth(80)
|
||||
conn_layout.addWidget(self.connect_btn)
|
||||
|
||||
self.disconnect_btn = QPushButton("断开")
|
||||
self.disconnect_btn.setProperty("secondary", True)
|
||||
self.disconnect_btn.setFixedWidth(80)
|
||||
self.disconnect_btn.setEnabled(False)
|
||||
conn_layout.addWidget(self.disconnect_btn)
|
||||
|
||||
layout.addWidget(conn_group)
|
||||
|
||||
# 主分割器
|
||||
main_splitter = QSplitter(Qt.Horizontal)
|
||||
layout.addWidget(main_splitter, 1)
|
||||
|
||||
# 左侧:表浏览器
|
||||
left_widget = self._create_table_browser()
|
||||
main_splitter.addWidget(left_widget)
|
||||
|
||||
# 右侧:查询和结果
|
||||
right_widget = self._create_query_area()
|
||||
main_splitter.addWidget(right_widget)
|
||||
|
||||
# 设置分割比例
|
||||
main_splitter.setSizes([300, 700])
|
||||
|
||||
def _create_table_browser(self) -> QWidget:
|
||||
"""创建表浏览器"""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setContentsMargins(0, 0, 8, 0)
|
||||
|
||||
# 标题和刷新按钮
|
||||
header_layout = QHBoxLayout()
|
||||
header_layout.addWidget(QLabel("表结构"))
|
||||
self.refresh_tables_btn = QPushButton("刷新")
|
||||
self.refresh_tables_btn.setProperty("secondary", True)
|
||||
self.refresh_tables_btn.setEnabled(False)
|
||||
header_layout.addWidget(self.refresh_tables_btn)
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 表树形视图
|
||||
self.table_tree = QTreeWidget()
|
||||
self.table_tree.setHeaderLabels(["名称", "行数", "最后更新"])
|
||||
self.table_tree.header().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.table_tree.setColumnWidth(1, 80)
|
||||
self.table_tree.setColumnWidth(2, 130)
|
||||
layout.addWidget(self.table_tree, 1)
|
||||
|
||||
return widget
|
||||
|
||||
def _create_query_area(self) -> QWidget:
|
||||
"""创建查询区域"""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
layout.setContentsMargins(8, 0, 0, 0)
|
||||
|
||||
# 查询输入区
|
||||
query_group = QGroupBox("SQL 查询")
|
||||
query_layout = QVBoxLayout(query_group)
|
||||
|
||||
# 模板选择
|
||||
template_layout = QHBoxLayout()
|
||||
template_layout.addWidget(QLabel("常用查询:"))
|
||||
self.template_combo = QComboBox()
|
||||
self.template_combo.addItem("-- 选择模板 --")
|
||||
for name in QUERY_TEMPLATES.keys():
|
||||
self.template_combo.addItem(name)
|
||||
template_layout.addWidget(self.template_combo, 1)
|
||||
query_layout.addLayout(template_layout)
|
||||
|
||||
# SQL 编辑器
|
||||
self.sql_editor = QPlainTextEdit()
|
||||
self.sql_editor.setObjectName("sqlEditor")
|
||||
self.sql_editor.setPlaceholderText("输入 SQL 查询语句...")
|
||||
self.sql_editor.setFont(QFont("Consolas", 11))
|
||||
self.sql_editor.setMaximumHeight(150)
|
||||
query_layout.addWidget(self.sql_editor)
|
||||
|
||||
# 执行按钮
|
||||
exec_layout = QHBoxLayout()
|
||||
exec_layout.addStretch()
|
||||
|
||||
self.exec_btn = QPushButton("执行查询 (Ctrl+Enter)")
|
||||
self.exec_btn.setEnabled(False)
|
||||
exec_layout.addWidget(self.exec_btn)
|
||||
|
||||
query_layout.addLayout(exec_layout)
|
||||
layout.addWidget(query_group)
|
||||
|
||||
# 结果区域
|
||||
result_group = QGroupBox("查询结果")
|
||||
result_layout = QVBoxLayout(result_group)
|
||||
|
||||
# 结果表格
|
||||
self.result_table = QTableWidget()
|
||||
self.result_table.setAlternatingRowColors(True)
|
||||
self.result_table.horizontalHeader().setStretchLastSection(True)
|
||||
result_layout.addWidget(self.result_table, 1)
|
||||
|
||||
# 结果统计
|
||||
self.result_label = QLabel("就绪")
|
||||
self.result_label.setProperty("subheading", True)
|
||||
result_layout.addWidget(self.result_label)
|
||||
|
||||
layout.addWidget(result_group, 1)
|
||||
|
||||
return widget
|
||||
|
||||
def _connect_signals(self):
|
||||
"""连接信号"""
|
||||
# 连接按钮
|
||||
self.show_dsn_btn.toggled.connect(self._toggle_dsn_visibility)
|
||||
self.connect_btn.clicked.connect(self._connect_db)
|
||||
self.disconnect_btn.clicked.connect(self._disconnect_db)
|
||||
self.refresh_tables_btn.clicked.connect(self._refresh_tables)
|
||||
|
||||
# 模板选择
|
||||
self.template_combo.currentIndexChanged.connect(self._on_template_selected)
|
||||
|
||||
# 执行查询
|
||||
self.exec_btn.clicked.connect(self._execute_query)
|
||||
|
||||
# 表双击
|
||||
self.table_tree.itemDoubleClicked.connect(self._on_table_double_clicked)
|
||||
|
||||
# 工作线程信号
|
||||
self.db_worker.connection_status.connect(self._on_connection_status)
|
||||
self.db_worker.tables_loaded.connect(self._on_tables_loaded)
|
||||
self.db_worker.query_finished.connect(self._on_query_finished)
|
||||
self.db_worker.query_error.connect(self._on_query_error)
|
||||
|
||||
def _load_dsn_from_env(self):
|
||||
"""从环境变量加载 DSN"""
|
||||
env_vars = self.config_helper.load_env()
|
||||
dsn = env_vars.get("PG_DSN", "")
|
||||
if dsn:
|
||||
self.dsn_edit.setText(dsn)
|
||||
|
||||
def _toggle_dsn_visibility(self, checked: bool):
|
||||
"""切换 DSN 可见性"""
|
||||
self.dsn_edit.setEchoMode(
|
||||
QLineEdit.Normal if checked else QLineEdit.Password
|
||||
)
|
||||
self.show_dsn_btn.setText("隐藏" if checked else "显示")
|
||||
|
||||
def _connect_db(self):
|
||||
"""连接数据库"""
|
||||
dsn = self.dsn_edit.text().strip()
|
||||
if not dsn:
|
||||
QMessageBox.warning(self, "提示", "请输入数据库连接字符串")
|
||||
return
|
||||
|
||||
self.connect_btn.setEnabled(False)
|
||||
self.connect_btn.setText("连接中...")
|
||||
self.db_worker.connect_db(dsn)
|
||||
|
||||
def _disconnect_db(self):
|
||||
"""断开数据库连接"""
|
||||
self.db_worker.disconnect_db()
|
||||
|
||||
def _refresh_tables(self):
|
||||
"""刷新表列表"""
|
||||
self.db_worker.load_tables()
|
||||
|
||||
def _on_connection_status(self, connected: bool, message: str):
|
||||
"""处理连接状态变化"""
|
||||
self._connected = connected
|
||||
self.connect_btn.setEnabled(not connected)
|
||||
self.connect_btn.setText("连接")
|
||||
self.disconnect_btn.setEnabled(connected)
|
||||
self.refresh_tables_btn.setEnabled(connected)
|
||||
self.exec_btn.setEnabled(connected)
|
||||
|
||||
self.connection_changed.emit(connected, message)
|
||||
|
||||
if connected:
|
||||
# 自动加载表列表
|
||||
self._refresh_tables()
|
||||
|
||||
def _on_tables_loaded(self, tables_dict: dict):
|
||||
"""处理表列表加载完成"""
|
||||
self.table_tree.clear()
|
||||
|
||||
for schema, tables in tables_dict.items():
|
||||
schema_item = QTreeWidgetItem([schema, "", ""])
|
||||
schema_item.setExpanded(True)
|
||||
|
||||
for table_name, row_count, updated_at in tables:
|
||||
table_item = QTreeWidgetItem([table_name, str(row_count), updated_at])
|
||||
table_item.setData(0, Qt.UserRole, f"{schema}.{table_name}")
|
||||
schema_item.addChild(table_item)
|
||||
|
||||
self.table_tree.addTopLevelItem(schema_item)
|
||||
|
||||
def _on_template_selected(self, index: int):
|
||||
"""模板选择变化"""
|
||||
if index <= 0:
|
||||
return
|
||||
|
||||
template_name = self.template_combo.currentText()
|
||||
if template_name in QUERY_TEMPLATES:
|
||||
self.sql_editor.setPlainText(QUERY_TEMPLATES[template_name].strip())
|
||||
|
||||
# 重置选择
|
||||
self.template_combo.setCurrentIndex(0)
|
||||
|
||||
def _on_table_double_clicked(self, item: QTreeWidgetItem, column: int):
|
||||
"""表双击事件"""
|
||||
full_name = item.data(0, Qt.UserRole)
|
||||
if full_name:
|
||||
# 生成预览查询
|
||||
sql = f"SELECT * FROM {full_name} LIMIT 100;"
|
||||
self.sql_editor.setPlainText(sql)
|
||||
self._execute_query()
|
||||
|
||||
def _execute_query(self):
|
||||
"""执行查询"""
|
||||
sql = self.sql_editor.toPlainText().strip()
|
||||
if not sql:
|
||||
QMessageBox.warning(self, "提示", "请输入 SQL 语句")
|
||||
return
|
||||
|
||||
self.exec_btn.setEnabled(False)
|
||||
self.exec_btn.setText("执行中...")
|
||||
self.result_label.setText("正在查询...")
|
||||
|
||||
self.db_worker.execute_query(sql)
|
||||
|
||||
def _on_query_finished(self, columns: list, rows: list):
|
||||
"""查询完成"""
|
||||
self.exec_btn.setEnabled(True)
|
||||
self.exec_btn.setText("执行查询 (Ctrl+Enter)")
|
||||
|
||||
# 更新结果表格
|
||||
self.result_table.clear()
|
||||
self.result_table.setColumnCount(len(columns))
|
||||
self.result_table.setRowCount(len(rows))
|
||||
self.result_table.setHorizontalHeaderLabels(columns)
|
||||
|
||||
for row_idx, row_data in enumerate(rows):
|
||||
for col_idx, col_name in enumerate(columns):
|
||||
value = row_data.get(col_name, "")
|
||||
item = QTableWidgetItem(str(value) if value is not None else "NULL")
|
||||
if value is None:
|
||||
item.setForeground(Qt.gray)
|
||||
self.result_table.setItem(row_idx, col_idx, item)
|
||||
|
||||
# 更新统计
|
||||
self.result_label.setText(f"返回 {len(rows)} 行, {len(columns)} 列")
|
||||
|
||||
def _on_query_error(self, error: str):
|
||||
"""查询错误"""
|
||||
self.exec_btn.setEnabled(True)
|
||||
self.exec_btn.setText("执行查询 (Ctrl+Enter)")
|
||||
self.result_label.setText(f"错误: {error}")
|
||||
QMessageBox.critical(self, "查询错误", error)
|
||||
|
||||
def close_connection(self):
|
||||
"""关闭连接"""
|
||||
if self._connected:
|
||||
self.db_worker.disconnect_db()
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
"""键盘事件"""
|
||||
# Ctrl+Enter 执行查询
|
||||
if event.modifiers() == Qt.ControlModifier and event.key() == Qt.Key_Return:
|
||||
if self._connected:
|
||||
self._execute_query()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
318
gui/widgets/env_editor.py
Normal file
318
gui/widgets/env_editor.py
Normal file
@@ -0,0 +1,318 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""环境变量编辑器"""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QGroupBox, QLabel, QLineEdit, QPushButton, QScrollArea,
|
||||
QFrame, QMessageBox, QFileDialog, QCheckBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
|
||||
from ..utils.config_helper import ConfigHelper, ENV_GROUPS
|
||||
|
||||
|
||||
class EnvEditor(QWidget):
|
||||
"""环境变量编辑器"""
|
||||
|
||||
# 信号
|
||||
config_saved = Signal() # 配置保存成功
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.config_helper = ConfigHelper()
|
||||
self.field_widgets = {} # 存储字段控件
|
||||
self.show_sensitive = False
|
||||
|
||||
self._init_ui()
|
||||
self.load_config()
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化界面"""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# 标题和按钮
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
title = QLabel("环境配置")
|
||||
title.setProperty("heading", True)
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.show_sensitive_check = QCheckBox("显示敏感信息")
|
||||
self.show_sensitive_check.stateChanged.connect(self._toggle_sensitive)
|
||||
header_layout.addWidget(self.show_sensitive_check)
|
||||
|
||||
self.import_btn = QPushButton("导入")
|
||||
self.import_btn.setProperty("secondary", True)
|
||||
self.import_btn.clicked.connect(self._import_config)
|
||||
header_layout.addWidget(self.import_btn)
|
||||
|
||||
self.export_btn = QPushButton("导出")
|
||||
self.export_btn.setProperty("secondary", True)
|
||||
self.export_btn.clicked.connect(self._export_config)
|
||||
header_layout.addWidget(self.export_btn)
|
||||
|
||||
self.reload_btn = QPushButton("重新加载")
|
||||
self.reload_btn.setProperty("secondary", True)
|
||||
self.reload_btn.clicked.connect(self.load_config)
|
||||
header_layout.addWidget(self.reload_btn)
|
||||
|
||||
self.save_btn = QPushButton("保存")
|
||||
self.save_btn.clicked.connect(self._save_config)
|
||||
header_layout.addWidget(self.save_btn)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 配置文件路径
|
||||
path_layout = QHBoxLayout()
|
||||
path_layout.addWidget(QLabel("配置文件:"))
|
||||
self.path_label = QLabel(str(self.config_helper.env_path))
|
||||
self.path_label.setProperty("subheading", True)
|
||||
path_layout.addWidget(self.path_label, 1)
|
||||
layout.addLayout(path_layout)
|
||||
|
||||
# 滚动区域
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
# 配置组容器
|
||||
config_widget = QWidget()
|
||||
self.config_layout = QVBoxLayout(config_widget)
|
||||
self.config_layout.setSpacing(16)
|
||||
|
||||
# 创建各配置组
|
||||
self._create_config_groups()
|
||||
|
||||
# 弹性空间
|
||||
self.config_layout.addStretch()
|
||||
|
||||
scroll_area.setWidget(config_widget)
|
||||
|
||||
# 验证结果
|
||||
self.validation_label = QLabel()
|
||||
self.validation_label.setWordWrap(True)
|
||||
layout.addWidget(self.validation_label)
|
||||
|
||||
def _create_config_groups(self):
|
||||
"""创建配置分组"""
|
||||
for group_id, group_info in ENV_GROUPS.items():
|
||||
group = QGroupBox(group_info["title"])
|
||||
grid_layout = QGridLayout(group)
|
||||
|
||||
for row, key in enumerate(group_info["keys"]):
|
||||
# 标签
|
||||
label = QLabel(f"{key}:")
|
||||
label.setMinimumWidth(180)
|
||||
grid_layout.addWidget(label, row, 0)
|
||||
|
||||
# 输入框
|
||||
edit = QLineEdit()
|
||||
edit.setPlaceholderText(self._get_placeholder(key))
|
||||
|
||||
# 敏感字段处理
|
||||
if key in group_info.get("sensitive", []):
|
||||
edit.setEchoMode(QLineEdit.Password)
|
||||
edit.setProperty("sensitive", True)
|
||||
|
||||
edit.textChanged.connect(self._on_value_changed)
|
||||
grid_layout.addWidget(edit, row, 1)
|
||||
|
||||
# 存储控件引用
|
||||
self.field_widgets[key] = edit
|
||||
|
||||
self.config_layout.addWidget(group)
|
||||
|
||||
# 其他配置组(动态添加)
|
||||
self.other_group = QGroupBox("其他配置")
|
||||
self.other_layout = QGridLayout(self.other_group)
|
||||
self.other_group.setVisible(False)
|
||||
self.config_layout.addWidget(self.other_group)
|
||||
|
||||
def load_config(self):
|
||||
"""加载配置"""
|
||||
env_vars = self.config_helper.load_env()
|
||||
|
||||
# 更新已知字段
|
||||
for key, edit in self.field_widgets.items():
|
||||
value = env_vars.get(key, "")
|
||||
edit.blockSignals(True)
|
||||
edit.setText(value)
|
||||
edit.blockSignals(False)
|
||||
|
||||
# 处理其他字段
|
||||
known_keys = set(self.field_widgets.keys())
|
||||
other_keys = [k for k in env_vars.keys() if k not in known_keys]
|
||||
|
||||
# 清除旧的其他字段
|
||||
while self.other_layout.count():
|
||||
item = self.other_layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
|
||||
# 添加其他字段
|
||||
if other_keys:
|
||||
self.other_group.setVisible(True)
|
||||
for row, key in enumerate(sorted(other_keys)):
|
||||
label = QLabel(f"{key}:")
|
||||
self.other_layout.addWidget(label, row, 0)
|
||||
|
||||
edit = QLineEdit(env_vars[key])
|
||||
edit.textChanged.connect(self._on_value_changed)
|
||||
self.other_layout.addWidget(edit, row, 1)
|
||||
|
||||
self.field_widgets[key] = edit
|
||||
else:
|
||||
self.other_group.setVisible(False)
|
||||
|
||||
self._validate()
|
||||
|
||||
def _save_config(self):
|
||||
"""保存配置"""
|
||||
# 收集所有值
|
||||
env_vars = {}
|
||||
for key, edit in self.field_widgets.items():
|
||||
value = edit.text().strip()
|
||||
if value:
|
||||
env_vars[key] = value
|
||||
|
||||
# 验证
|
||||
errors = self.config_helper.validate_env(env_vars)
|
||||
if errors:
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"验证警告",
|
||||
"配置存在以下问题:\n\n" + "\n".join(f"• {e}" for e in errors) + "\n\n是否仍要保存?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.No:
|
||||
return
|
||||
|
||||
# 保存
|
||||
if self.config_helper.save_env(env_vars):
|
||||
QMessageBox.information(self, "成功", "配置已保存")
|
||||
self.config_saved.emit()
|
||||
else:
|
||||
QMessageBox.critical(self, "错误", "保存配置失败")
|
||||
|
||||
def _import_config(self):
|
||||
"""导入配置"""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"导入配置文件",
|
||||
"",
|
||||
"环境文件 (*.env);;所有文件 (*.*)"
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
try:
|
||||
from pathlib import Path
|
||||
temp_helper = ConfigHelper(Path(file_path))
|
||||
env_vars = temp_helper.load_env()
|
||||
|
||||
# 更新字段
|
||||
for key, value in env_vars.items():
|
||||
if key in self.field_widgets:
|
||||
self.field_widgets[key].setText(value)
|
||||
|
||||
QMessageBox.information(self, "成功", f"已导入 {len(env_vars)} 个配置项")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "错误", f"导入失败: {e}")
|
||||
|
||||
def _export_config(self):
|
||||
"""导出配置"""
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"导出配置文件",
|
||||
".env.backup",
|
||||
"环境文件 (*.env);;所有文件 (*.*)"
|
||||
)
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
try:
|
||||
from pathlib import Path
|
||||
|
||||
# 收集当前值
|
||||
env_vars = {}
|
||||
for key, edit in self.field_widgets.items():
|
||||
value = edit.text().strip()
|
||||
if value:
|
||||
env_vars[key] = value
|
||||
|
||||
# 保存到指定路径
|
||||
temp_helper = ConfigHelper(Path(file_path))
|
||||
if temp_helper.save_env(env_vars):
|
||||
QMessageBox.information(self, "成功", f"配置已导出到:\n{file_path}")
|
||||
else:
|
||||
QMessageBox.critical(self, "错误", "导出失败")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "错误", f"导出失败: {e}")
|
||||
|
||||
def _toggle_sensitive(self, state: int):
|
||||
"""切换敏感信息显示"""
|
||||
self.show_sensitive = state == Qt.Checked
|
||||
|
||||
for key, edit in self.field_widgets.items():
|
||||
if edit.property("sensitive"):
|
||||
edit.setEchoMode(
|
||||
QLineEdit.Normal if self.show_sensitive else QLineEdit.Password
|
||||
)
|
||||
|
||||
def _on_value_changed(self):
|
||||
"""值变化时验证"""
|
||||
self._validate()
|
||||
|
||||
def _validate(self):
|
||||
"""验证配置"""
|
||||
env_vars = {}
|
||||
for key, edit in self.field_widgets.items():
|
||||
value = edit.text().strip()
|
||||
if value:
|
||||
env_vars[key] = value
|
||||
|
||||
errors = self.config_helper.validate_env(env_vars)
|
||||
|
||||
if errors:
|
||||
self.validation_label.setText("⚠ " + "; ".join(errors))
|
||||
self.validation_label.setProperty("status", "warning")
|
||||
else:
|
||||
self.validation_label.setText("✓ 配置验证通过")
|
||||
self.validation_label.setProperty("status", "success")
|
||||
|
||||
self.validation_label.style().unpolish(self.validation_label)
|
||||
self.validation_label.style().polish(self.validation_label)
|
||||
|
||||
@staticmethod
|
||||
def _get_placeholder(key: str) -> str:
|
||||
"""获取占位符提示"""
|
||||
placeholders = {
|
||||
"PG_DSN": "postgresql://user:password@host:5432/dbname",
|
||||
"PG_HOST": "localhost",
|
||||
"PG_PORT": "5432",
|
||||
"PG_NAME": "billiards",
|
||||
"PG_USER": "postgres",
|
||||
"PG_PASSWORD": "密码",
|
||||
"API_BASE": "https://pc.ficoo.vip/apiprod/admin/v1",
|
||||
"API_TOKEN": "Bearer token",
|
||||
"API_TIMEOUT": "20",
|
||||
"API_PAGE_SIZE": "200",
|
||||
"STORE_ID": "门店ID (数字)",
|
||||
"TIMEZONE": "Asia/Taipei",
|
||||
"EXPORT_ROOT": "export/JSON",
|
||||
"LOG_ROOT": "export/LOG",
|
||||
"FETCH_ROOT": "JSON 抓取输出目录",
|
||||
"INGEST_SOURCE_DIR": "本地 JSON 输入目录",
|
||||
"PIPELINE_FLOW": "FULL / FETCH_ONLY / INGEST_ONLY",
|
||||
"RUN_TASKS": "任务列表,逗号分隔",
|
||||
"OVERLAP_SECONDS": "3600",
|
||||
"WINDOW_START": "2025-07-01 00:00:00",
|
||||
"WINDOW_END": "2025-08-01 00:00:00",
|
||||
}
|
||||
return placeholders.get(key, "")
|
||||
247
gui/widgets/log_viewer.py
Normal file
247
gui/widgets/log_viewer.py
Normal file
@@ -0,0 +1,247 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""日志查看器"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPlainTextEdit, QPushButton, QLineEdit, QLabel,
|
||||
QComboBox, QCheckBox, QFileDialog, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, Slot
|
||||
from PySide6.QtGui import QTextCharFormat, QColor, QFont, QTextCursor
|
||||
|
||||
|
||||
class LogViewer(QWidget):
|
||||
"""日志查看器"""
|
||||
|
||||
# 信号
|
||||
log_cleared = Signal()
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.max_lines = 10000
|
||||
self.auto_scroll = True
|
||||
self.filter_text = ""
|
||||
self.filter_level = "ALL"
|
||||
self._all_logs = [] # 存储所有日志
|
||||
|
||||
self._init_ui()
|
||||
self._connect_signals()
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化界面"""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# 标题和工具栏
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
title = QLabel("执行日志")
|
||||
title.setProperty("heading", True)
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
# 日志级别过滤
|
||||
header_layout.addWidget(QLabel("级别:"))
|
||||
self.level_combo = QComboBox()
|
||||
self.level_combo.addItems(["ALL", "INFO", "WARNING", "ERROR", "DEBUG"])
|
||||
self.level_combo.setFixedWidth(100)
|
||||
header_layout.addWidget(self.level_combo)
|
||||
|
||||
# 搜索框
|
||||
header_layout.addWidget(QLabel("搜索:"))
|
||||
self.search_edit = QLineEdit()
|
||||
self.search_edit.setPlaceholderText("输入关键字...")
|
||||
self.search_edit.setFixedWidth(200)
|
||||
header_layout.addWidget(self.search_edit)
|
||||
|
||||
# 自动滚动
|
||||
self.auto_scroll_check = QCheckBox("自动滚动")
|
||||
self.auto_scroll_check.setChecked(True)
|
||||
header_layout.addWidget(self.auto_scroll_check)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 日志文本区域
|
||||
self.log_text = QPlainTextEdit()
|
||||
self.log_text.setObjectName("logViewer")
|
||||
self.log_text.setReadOnly(True)
|
||||
self.log_text.setFont(QFont("Consolas", 10))
|
||||
self.log_text.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||
layout.addWidget(self.log_text, 1)
|
||||
|
||||
# 底部工具栏
|
||||
footer_layout = QHBoxLayout()
|
||||
|
||||
self.line_count_label = QLabel("0 行")
|
||||
self.line_count_label.setProperty("subheading", True)
|
||||
footer_layout.addWidget(self.line_count_label)
|
||||
|
||||
footer_layout.addStretch()
|
||||
|
||||
self.copy_btn = QPushButton("复制全部")
|
||||
self.copy_btn.setProperty("secondary", True)
|
||||
footer_layout.addWidget(self.copy_btn)
|
||||
|
||||
self.export_btn = QPushButton("导出")
|
||||
self.export_btn.setProperty("secondary", True)
|
||||
footer_layout.addWidget(self.export_btn)
|
||||
|
||||
self.clear_btn = QPushButton("清空")
|
||||
self.clear_btn.setProperty("secondary", True)
|
||||
footer_layout.addWidget(self.clear_btn)
|
||||
|
||||
layout.addLayout(footer_layout)
|
||||
|
||||
def _connect_signals(self):
|
||||
"""连接信号"""
|
||||
self.level_combo.currentTextChanged.connect(self._apply_filter)
|
||||
self.search_edit.textChanged.connect(self._apply_filter)
|
||||
self.auto_scroll_check.stateChanged.connect(self._toggle_auto_scroll)
|
||||
self.copy_btn.clicked.connect(self._copy_all)
|
||||
self.export_btn.clicked.connect(self._export_log)
|
||||
self.clear_btn.clicked.connect(self._clear_log)
|
||||
|
||||
@Slot(str)
|
||||
def append_log(self, text: str):
|
||||
"""追加日志"""
|
||||
# 添加时间戳(如果没有)
|
||||
if not re.match(r'^\d{4}-\d{2}-\d{2}', text) and not text.startswith('['):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
text = f"[{timestamp}] {text}"
|
||||
|
||||
# 存储到全部日志
|
||||
self._all_logs.append(text)
|
||||
|
||||
# 限制日志行数
|
||||
if len(self._all_logs) > self.max_lines:
|
||||
self._all_logs = self._all_logs[-self.max_lines:]
|
||||
|
||||
# 检查是否通过过滤器
|
||||
if self._matches_filter(text):
|
||||
self._append_formatted_line(text)
|
||||
|
||||
# 更新行数
|
||||
self._update_line_count()
|
||||
|
||||
def _append_formatted_line(self, text: str):
|
||||
"""追加格式化的行"""
|
||||
cursor = self.log_text.textCursor()
|
||||
cursor.movePosition(QTextCursor.End)
|
||||
|
||||
# 设置格式
|
||||
fmt = QTextCharFormat()
|
||||
|
||||
text_lower = text.lower()
|
||||
if "[error]" in text_lower or "错误" in text or "失败" in text:
|
||||
fmt.setForeground(QColor("#d93025"))
|
||||
fmt.setFontWeight(QFont.Bold)
|
||||
elif "[warning]" in text_lower or "警告" in text or "warn" in text_lower:
|
||||
fmt.setForeground(QColor("#f9ab00"))
|
||||
elif "[info]" in text_lower:
|
||||
fmt.setForeground(QColor("#1a73e8"))
|
||||
elif "[debug]" in text_lower:
|
||||
fmt.setForeground(QColor("#9aa0a6"))
|
||||
elif "[gui]" in text_lower:
|
||||
fmt.setForeground(QColor("#1e8e3e"))
|
||||
else:
|
||||
fmt.setForeground(QColor("#333333"))
|
||||
|
||||
cursor.insertText(text + "\n", fmt)
|
||||
|
||||
# 自动滚动
|
||||
if self.auto_scroll:
|
||||
self.log_text.verticalScrollBar().setValue(
|
||||
self.log_text.verticalScrollBar().maximum()
|
||||
)
|
||||
|
||||
def _matches_filter(self, text: str) -> bool:
|
||||
"""检查是否匹配过滤器"""
|
||||
# 级别过滤
|
||||
if self.filter_level != "ALL":
|
||||
level_marker = f"[{self.filter_level}]"
|
||||
if level_marker.lower() not in text.lower():
|
||||
return False
|
||||
|
||||
# 文本过滤
|
||||
if self.filter_text:
|
||||
if self.filter_text.lower() not in text.lower():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _apply_filter(self):
|
||||
"""应用过滤器"""
|
||||
self.filter_level = self.level_combo.currentText()
|
||||
self.filter_text = self.search_edit.text().strip()
|
||||
|
||||
# 重新显示日志
|
||||
self.log_text.clear()
|
||||
for line in self._all_logs:
|
||||
if self._matches_filter(line):
|
||||
self._append_formatted_line(line)
|
||||
|
||||
self._update_line_count()
|
||||
|
||||
def _toggle_auto_scroll(self, state: int):
|
||||
"""切换自动滚动"""
|
||||
self.auto_scroll = state == Qt.Checked
|
||||
|
||||
def _copy_all(self):
|
||||
"""复制全部日志"""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
text = self.log_text.toPlainText()
|
||||
QApplication.clipboard().setText(text)
|
||||
QMessageBox.information(self, "提示", "日志已复制到剪贴板")
|
||||
|
||||
def _export_log(self):
|
||||
"""导出日志"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
default_name = f"etl_log_{timestamp}.txt"
|
||||
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"导出日志",
|
||||
default_name,
|
||||
"文本文件 (*.txt);;日志文件 (*.log);;所有文件 (*.*)"
|
||||
)
|
||||
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(self.log_text.toPlainText())
|
||||
QMessageBox.information(self, "成功", f"日志已导出到:\n{file_path}")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "错误", f"导出失败: {e}")
|
||||
|
||||
def _clear_log(self):
|
||||
"""清空日志"""
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"确认",
|
||||
"确定要清空所有日志吗?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self._all_logs.clear()
|
||||
self.log_text.clear()
|
||||
self._update_line_count()
|
||||
self.log_cleared.emit()
|
||||
|
||||
def _update_line_count(self):
|
||||
"""更新行数显示"""
|
||||
visible_count = self.log_text.document().blockCount() - 1
|
||||
total_count = len(self._all_logs)
|
||||
|
||||
if visible_count < total_count:
|
||||
self.line_count_label.setText(f"{visible_count} / {total_count} 行")
|
||||
else:
|
||||
self.line_count_label.setText(f"{total_count} 行")
|
||||
604
gui/widgets/pipeline_selector.py
Normal file
604
gui/widgets/pipeline_selector.py
Normal file
@@ -0,0 +1,604 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""管道选择组件:统一的 ETL 管道配置界面。"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
|
||||
QRadioButton, QButtonGroup, QLabel, QSpinBox,
|
||||
QDateTimeEdit, QComboBox, QCheckBox, QPushButton,
|
||||
QScrollArea, QFrame
|
||||
)
|
||||
from PySide6.QtCore import Signal, Qt, QDateTime
|
||||
|
||||
from ..models.task_registry import (
|
||||
TaskRegistry, TaskDefinition, BusinessDomain, DOMAIN_LABELS,
|
||||
task_registry, get_fact_ods_task_codes, get_dimension_ods_task_codes
|
||||
)
|
||||
|
||||
|
||||
# 管道选项定义:(id, 显示名称, 包含的层)
|
||||
PIPELINE_OPTIONS: List[Tuple[str, str, List[str]]] = [
|
||||
("api_ods", "API → ODS", ["ODS"]),
|
||||
("api_ods_dwd", "API → ODS → DWD", ["ODS", "DWD"]),
|
||||
("api_full", "API → ODS → DWD → DWS汇总 → DWS指数", ["ODS", "DWD", "DWS", "INDEX"]),
|
||||
("ods_dwd", "ODS → DWD", ["DWD"]),
|
||||
("dwd_dws", "DWD → DWS汇总", ["DWS"]),
|
||||
("dwd_dws_index", "DWD → DWS汇总 → DWS指数", ["DWS", "INDEX"]),
|
||||
("dwd_index", "DWD → DWS指数", ["INDEX"]),
|
||||
]
|
||||
|
||||
# 数据处理模式
|
||||
PROCESSING_MODES: List[Tuple[str, str, str]] = [
|
||||
("increment_only", "仅增量", "仅执行增量数据处理,不进行校验"),
|
||||
("verify_only", "校验并修复", "跳过增量处理,直接校验数据一致性并自动补齐缺失/不一致数据"),
|
||||
("increment_verify", "增量 + 校验并修复", "先执行增量处理,再校验并修复缺失/不一致数据"),
|
||||
]
|
||||
|
||||
# 校验模式附加选项
|
||||
VERIFY_MODE_OPTIONS = {
|
||||
"fetch_before_verify": "校验前先从 API 获取数据",
|
||||
"skip_ods_when_fetch_before_verify": "跳过 ODS 校验(仅在校验前获取时)",
|
||||
"ods_use_local_json": "ODS 校验使用本地 JSON(不请求 API)",
|
||||
}
|
||||
|
||||
# 时间窗口模式
|
||||
WINDOW_MODES: List[Tuple[str, str]] = [
|
||||
("lookback", "回溯 + 冗余"),
|
||||
("custom", "自定义时间范围"),
|
||||
]
|
||||
|
||||
# 时间窗口切分选项
|
||||
WINDOW_SPLIT_OPTIONS: List[Tuple[str, str]] = [
|
||||
("none", "不切分"),
|
||||
("day", "按天"),
|
||||
]
|
||||
|
||||
# 时间窗口切分天数(按天时生效)
|
||||
WINDOW_SPLIT_DAY_OPTIONS: List[Tuple[int, str]] = [
|
||||
(1, "1 天"),
|
||||
(10, "10 天"),
|
||||
(30, "30 天"),
|
||||
]
|
||||
|
||||
|
||||
def get_pipeline_layers(pipeline_id: str) -> List[str]:
|
||||
"""获取管道包含的层"""
|
||||
for pid, _, layers in PIPELINE_OPTIONS:
|
||||
if pid == pipeline_id:
|
||||
return layers
|
||||
return []
|
||||
|
||||
|
||||
def get_pipeline_display_name(pipeline_id: str) -> str:
|
||||
"""获取管道显示名称"""
|
||||
for pid, name, _ in PIPELINE_OPTIONS:
|
||||
if pid == pipeline_id:
|
||||
return name
|
||||
return pipeline_id
|
||||
|
||||
|
||||
class PipelineSelectorWidget(QWidget):
|
||||
"""管道选择组件"""
|
||||
|
||||
# 信号
|
||||
pipeline_changed = Signal(str) # 管道ID
|
||||
processing_mode_changed = Signal(str) # 处理模式
|
||||
window_mode_changed = Signal(str) # 时间窗口模式
|
||||
config_changed = Signal() # 任意配置变化
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
|
||||
# 当前选择
|
||||
self._pipeline_id = "api_ods_dwd"
|
||||
self._processing_mode = "increment_only"
|
||||
self._window_mode = "lookback"
|
||||
self._fetch_before_verify = False
|
||||
self._skip_ods_when_fetch_before_verify = True
|
||||
self._ods_use_local_json = True
|
||||
|
||||
self._init_ui()
|
||||
self._connect_signals()
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化界面"""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
|
||||
# 1. 管道选择
|
||||
pipeline_group = self._create_pipeline_group()
|
||||
layout.addWidget(pipeline_group)
|
||||
|
||||
# 2. 数据处理模式
|
||||
processing_group = self._create_processing_mode_group()
|
||||
layout.addWidget(processing_group)
|
||||
|
||||
# 3. 时间窗口配置
|
||||
window_group = self._create_window_group()
|
||||
layout.addWidget(window_group)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
def _create_pipeline_group(self) -> QGroupBox:
|
||||
"""创建管道选择分组"""
|
||||
group = QGroupBox("管道选择 (Pipeline)")
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(4)
|
||||
|
||||
self._pipeline_button_group = QButtonGroup(self)
|
||||
|
||||
for i, (pid, name, layers) in enumerate(PIPELINE_OPTIONS):
|
||||
radio = QRadioButton(name)
|
||||
radio.setProperty("pipeline_id", pid)
|
||||
radio.setToolTip(f"包含层: {' → '.join(layers)}")
|
||||
|
||||
if pid == self._pipeline_id:
|
||||
radio.setChecked(True)
|
||||
|
||||
self._pipeline_button_group.addButton(radio, i)
|
||||
layout.addWidget(radio)
|
||||
|
||||
return group
|
||||
|
||||
def _create_processing_mode_group(self) -> QGroupBox:
|
||||
"""创建数据处理模式分组"""
|
||||
group = QGroupBox("数据处理模式")
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(4)
|
||||
|
||||
self._processing_button_group = QButtonGroup(self)
|
||||
|
||||
for i, (mode_id, name, tooltip) in enumerate(PROCESSING_MODES):
|
||||
radio = QRadioButton(name)
|
||||
radio.setProperty("mode_id", mode_id)
|
||||
radio.setToolTip(tooltip)
|
||||
|
||||
if mode_id == self._processing_mode:
|
||||
radio.setChecked(True)
|
||||
|
||||
self._processing_button_group.addButton(radio, i)
|
||||
layout.addWidget(radio)
|
||||
|
||||
# 校验模式附加选项:校验前从 API 获取数据
|
||||
option_layout = QHBoxLayout()
|
||||
option_layout.setContentsMargins(20, 4, 0, 0) # 缩进以表示从属关系
|
||||
|
||||
self._fetch_before_verify_checkbox = QCheckBox(
|
||||
VERIFY_MODE_OPTIONS["fetch_before_verify"]
|
||||
)
|
||||
self._fetch_before_verify_checkbox.setToolTip(
|
||||
"勾选后,在执行校验前会先从 API 获取最新数据到 ODS 层。\n"
|
||||
"适用于需要同时获取新数据并校验修复的场景。"
|
||||
)
|
||||
self._fetch_before_verify_checkbox.setChecked(self._fetch_before_verify)
|
||||
# 默认禁用,仅在 verify_only 模式下启用
|
||||
self._fetch_before_verify_checkbox.setEnabled(
|
||||
self._processing_mode == "verify_only"
|
||||
)
|
||||
option_layout.addWidget(self._fetch_before_verify_checkbox)
|
||||
option_layout.addStretch()
|
||||
layout.addLayout(option_layout)
|
||||
|
||||
# 仅在 fetch_before_verify 时生效的附加选项
|
||||
skip_ods_layout = QHBoxLayout()
|
||||
skip_ods_layout.setContentsMargins(40, 2, 0, 0)
|
||||
self._skip_ods_when_fetch_before_verify_checkbox = QCheckBox(
|
||||
VERIFY_MODE_OPTIONS["skip_ods_when_fetch_before_verify"]
|
||||
)
|
||||
self._skip_ods_when_fetch_before_verify_checkbox.setToolTip(
|
||||
"勾选后,在校验前先抓取数据的场景下跳过 ODS 校验。\n"
|
||||
"适用于仅关心 ODS 入库统计或避免重复校验的场景。"
|
||||
)
|
||||
self._skip_ods_when_fetch_before_verify_checkbox.setChecked(
|
||||
self._skip_ods_when_fetch_before_verify
|
||||
)
|
||||
skip_ods_layout.addWidget(self._skip_ods_when_fetch_before_verify_checkbox)
|
||||
skip_ods_layout.addStretch()
|
||||
layout.addLayout(skip_ods_layout)
|
||||
|
||||
local_json_layout = QHBoxLayout()
|
||||
local_json_layout.setContentsMargins(40, 2, 0, 0)
|
||||
self._ods_use_local_json_checkbox = QCheckBox(
|
||||
VERIFY_MODE_OPTIONS["ods_use_local_json"]
|
||||
)
|
||||
self._ods_use_local_json_checkbox.setToolTip(
|
||||
"勾选后,ODS 校验将完全基于落盘 JSON 进行,不再请求 API。\n"
|
||||
"需要先执行“校验前先从 API 获取数据”以生成 JSON。"
|
||||
)
|
||||
self._ods_use_local_json_checkbox.setChecked(self._ods_use_local_json)
|
||||
local_json_layout.addWidget(self._ods_use_local_json_checkbox)
|
||||
local_json_layout.addStretch()
|
||||
layout.addLayout(local_json_layout)
|
||||
|
||||
self._update_verify_option_states()
|
||||
|
||||
return group
|
||||
|
||||
def _create_window_group(self) -> QGroupBox:
|
||||
"""创建时间窗口配置分组"""
|
||||
group = QGroupBox("时间窗口")
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# 时间窗口模式选择
|
||||
self._window_button_group = QButtonGroup(self)
|
||||
|
||||
# 回溯模式
|
||||
lookback_layout = QHBoxLayout()
|
||||
self._lookback_radio = QRadioButton("回溯 + 冗余:")
|
||||
self._lookback_radio.setProperty("mode_id", "lookback")
|
||||
self._lookback_radio.setChecked(True)
|
||||
self._window_button_group.addButton(self._lookback_radio, 0)
|
||||
lookback_layout.addWidget(self._lookback_radio)
|
||||
|
||||
self._lookback_hours_spin = QSpinBox()
|
||||
self._lookback_hours_spin.setRange(1, 720)
|
||||
self._lookback_hours_spin.setValue(24)
|
||||
self._lookback_hours_spin.setSuffix(" 小时")
|
||||
self._lookback_hours_spin.setToolTip("回溯时间长度")
|
||||
self._lookback_hours_spin.setFixedWidth(100)
|
||||
lookback_layout.addWidget(self._lookback_hours_spin)
|
||||
|
||||
lookback_layout.addWidget(QLabel("冗余:"))
|
||||
|
||||
self._overlap_seconds_spin = QSpinBox()
|
||||
self._overlap_seconds_spin.setRange(0, 7200)
|
||||
self._overlap_seconds_spin.setValue(600)
|
||||
self._overlap_seconds_spin.setSuffix(" 秒")
|
||||
self._overlap_seconds_spin.setToolTip("时间窗口前后的重叠冗余")
|
||||
self._overlap_seconds_spin.setFixedWidth(100)
|
||||
lookback_layout.addWidget(self._overlap_seconds_spin)
|
||||
|
||||
lookback_layout.addStretch()
|
||||
layout.addLayout(lookback_layout)
|
||||
|
||||
# 自定义模式
|
||||
custom_layout = QHBoxLayout()
|
||||
self._custom_radio = QRadioButton("自定义:")
|
||||
self._custom_radio.setProperty("mode_id", "custom")
|
||||
self._window_button_group.addButton(self._custom_radio, 1)
|
||||
custom_layout.addWidget(self._custom_radio)
|
||||
|
||||
self._start_datetime = QDateTimeEdit()
|
||||
self._start_datetime.setCalendarPopup(True)
|
||||
self._start_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
|
||||
self._start_datetime.setDateTime(QDateTime.currentDateTime().addDays(-1))
|
||||
self._start_datetime.setFixedWidth(160)
|
||||
self._start_datetime.setEnabled(False)
|
||||
custom_layout.addWidget(self._start_datetime)
|
||||
|
||||
custom_layout.addWidget(QLabel("至"))
|
||||
|
||||
self._end_datetime = QDateTimeEdit()
|
||||
self._end_datetime.setCalendarPopup(True)
|
||||
self._end_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
|
||||
self._end_datetime.setDateTime(QDateTime.currentDateTime())
|
||||
self._end_datetime.setFixedWidth(160)
|
||||
self._end_datetime.setEnabled(False)
|
||||
custom_layout.addWidget(self._end_datetime)
|
||||
|
||||
custom_layout.addStretch()
|
||||
layout.addLayout(custom_layout)
|
||||
|
||||
# 时间窗口切分
|
||||
split_layout = QHBoxLayout()
|
||||
split_layout.addWidget(QLabel("时间窗口切分:"))
|
||||
|
||||
self._split_combo = QComboBox()
|
||||
for split_id, split_name in WINDOW_SPLIT_OPTIONS:
|
||||
self._split_combo.addItem(split_name, split_id)
|
||||
default_split_index = self._split_combo.findData("day")
|
||||
if default_split_index >= 0:
|
||||
self._split_combo.setCurrentIndex(default_split_index)
|
||||
self._split_combo.setFixedWidth(100)
|
||||
split_layout.addWidget(self._split_combo)
|
||||
|
||||
split_layout.addWidget(QLabel("切分天数:"))
|
||||
self._split_days_combo = QComboBox()
|
||||
for days, label in WINDOW_SPLIT_DAY_OPTIONS:
|
||||
self._split_days_combo.addItem(label, days)
|
||||
default_days_index = self._split_days_combo.findData(10)
|
||||
if default_days_index >= 0:
|
||||
self._split_days_combo.setCurrentIndex(default_days_index)
|
||||
self._split_days_combo.setFixedWidth(90)
|
||||
split_layout.addWidget(self._split_days_combo)
|
||||
|
||||
split_layout.addStretch()
|
||||
layout.addLayout(split_layout)
|
||||
|
||||
self._update_split_days_state()
|
||||
|
||||
return group
|
||||
|
||||
def _connect_signals(self):
|
||||
"""连接信号"""
|
||||
# 管道选择变化
|
||||
self._pipeline_button_group.buttonClicked.connect(self._on_pipeline_changed)
|
||||
|
||||
# 处理模式变化
|
||||
self._processing_button_group.buttonClicked.connect(self._on_processing_mode_changed)
|
||||
|
||||
# 时间窗口模式变化
|
||||
self._window_button_group.buttonClicked.connect(self._on_window_mode_changed)
|
||||
|
||||
# 其他配置变化
|
||||
self._lookback_hours_spin.valueChanged.connect(self._emit_config_changed)
|
||||
self._overlap_seconds_spin.valueChanged.connect(self._emit_config_changed)
|
||||
self._start_datetime.dateTimeChanged.connect(self._emit_config_changed)
|
||||
self._end_datetime.dateTimeChanged.connect(self._emit_config_changed)
|
||||
self._split_combo.currentIndexChanged.connect(self._on_split_changed)
|
||||
self._split_days_combo.currentIndexChanged.connect(self._emit_config_changed)
|
||||
|
||||
# 校验模式附加选项变化
|
||||
self._fetch_before_verify_checkbox.stateChanged.connect(self._on_fetch_before_verify_changed)
|
||||
self._skip_ods_when_fetch_before_verify_checkbox.stateChanged.connect(
|
||||
self._on_skip_ods_when_fetch_before_verify_changed
|
||||
)
|
||||
self._ods_use_local_json_checkbox.stateChanged.connect(
|
||||
self._on_ods_use_local_json_changed
|
||||
)
|
||||
|
||||
def _on_pipeline_changed(self, button: QRadioButton):
|
||||
"""管道选择变化"""
|
||||
pipeline_id = button.property("pipeline_id")
|
||||
if pipeline_id and pipeline_id != self._pipeline_id:
|
||||
self._pipeline_id = pipeline_id
|
||||
self.pipeline_changed.emit(pipeline_id)
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_processing_mode_changed(self, button: QRadioButton):
|
||||
"""处理模式变化"""
|
||||
mode_id = button.property("mode_id")
|
||||
if mode_id and mode_id != self._processing_mode:
|
||||
self._processing_mode = mode_id
|
||||
|
||||
# 更新 "校验前获取数据" 选项的启用状态
|
||||
# 仅在 verify_only 模式下可用
|
||||
is_verify_only = mode_id == "verify_only"
|
||||
self._fetch_before_verify_checkbox.setEnabled(is_verify_only)
|
||||
if not is_verify_only:
|
||||
# 非 verify_only 模式时,自动取消勾选
|
||||
self._fetch_before_verify_checkbox.setChecked(False)
|
||||
|
||||
self._update_verify_option_states()
|
||||
|
||||
self.processing_mode_changed.emit(mode_id)
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_fetch_before_verify_changed(self, state: int):
|
||||
"""校验前获取数据选项变化"""
|
||||
from PySide6.QtCore import Qt
|
||||
self._fetch_before_verify = state == Qt.Checked.value
|
||||
self._update_verify_option_states()
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_skip_ods_when_fetch_before_verify_changed(self, state: int):
|
||||
"""跳过 ODS 校验选项变化"""
|
||||
from PySide6.QtCore import Qt
|
||||
self._skip_ods_when_fetch_before_verify = state == Qt.Checked.value
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_ods_use_local_json_changed(self, state: int):
|
||||
"""ODS 校验使用本地 JSON 选项变化"""
|
||||
from PySide6.QtCore import Qt
|
||||
self._ods_use_local_json = state == Qt.Checked.value
|
||||
self.config_changed.emit()
|
||||
|
||||
def _update_verify_option_states(self):
|
||||
"""更新校验附加选项的启用状态"""
|
||||
enable_suboptions = self._processing_mode == "verify_only" and self._fetch_before_verify
|
||||
self._skip_ods_when_fetch_before_verify_checkbox.setEnabled(enable_suboptions)
|
||||
self._ods_use_local_json_checkbox.setEnabled(enable_suboptions)
|
||||
|
||||
def _on_window_mode_changed(self, button: QRadioButton):
|
||||
"""时间窗口模式变化"""
|
||||
mode_id = button.property("mode_id")
|
||||
if mode_id and mode_id != self._window_mode:
|
||||
self._window_mode = mode_id
|
||||
|
||||
# 更新控件启用状态
|
||||
is_lookback = mode_id == "lookback"
|
||||
self._lookback_hours_spin.setEnabled(is_lookback)
|
||||
self._overlap_seconds_spin.setEnabled(is_lookback)
|
||||
self._start_datetime.setEnabled(not is_lookback)
|
||||
self._end_datetime.setEnabled(not is_lookback)
|
||||
|
||||
self.window_mode_changed.emit(mode_id)
|
||||
self.config_changed.emit()
|
||||
|
||||
def _on_split_changed(self):
|
||||
"""时间窗口切分方式变化"""
|
||||
self._update_split_days_state()
|
||||
self.config_changed.emit()
|
||||
|
||||
def _update_split_days_state(self):
|
||||
"""按天切分才允许选择天数"""
|
||||
is_day_split = self.get_window_split() == "day"
|
||||
self._split_days_combo.setEnabled(is_day_split)
|
||||
|
||||
def _emit_config_changed(self):
|
||||
"""发出配置变化信号"""
|
||||
self.config_changed.emit()
|
||||
|
||||
# === 公共接口 ===
|
||||
|
||||
def get_pipeline_id(self) -> str:
|
||||
"""获取当前管道ID"""
|
||||
return self._pipeline_id
|
||||
|
||||
def set_pipeline_id(self, pipeline_id: str):
|
||||
"""设置管道ID"""
|
||||
for button in self._pipeline_button_group.buttons():
|
||||
if button.property("pipeline_id") == pipeline_id:
|
||||
button.setChecked(True)
|
||||
self._pipeline_id = pipeline_id
|
||||
break
|
||||
|
||||
def get_pipeline_layers(self) -> List[str]:
|
||||
"""获取当前管道包含的层"""
|
||||
return get_pipeline_layers(self._pipeline_id)
|
||||
|
||||
def get_processing_mode(self) -> str:
|
||||
"""获取数据处理模式"""
|
||||
return self._processing_mode
|
||||
|
||||
def set_processing_mode(self, mode: str):
|
||||
"""设置数据处理模式"""
|
||||
for button in self._processing_button_group.buttons():
|
||||
if button.property("mode_id") == mode:
|
||||
button.setChecked(True)
|
||||
self._processing_mode = mode
|
||||
# 更新复选框启用状态
|
||||
is_verify_only = mode == "verify_only"
|
||||
self._fetch_before_verify_checkbox.setEnabled(is_verify_only)
|
||||
if not is_verify_only:
|
||||
self._fetch_before_verify_checkbox.setChecked(False)
|
||||
self._update_verify_option_states()
|
||||
break
|
||||
|
||||
def get_fetch_before_verify(self) -> bool:
|
||||
"""获取是否在校验前从 API 获取数据"""
|
||||
return self._fetch_before_verify
|
||||
|
||||
def set_fetch_before_verify(self, enabled: bool):
|
||||
"""设置是否在校验前从 API 获取数据"""
|
||||
self._fetch_before_verify = enabled
|
||||
self._fetch_before_verify_checkbox.setChecked(enabled)
|
||||
self._update_verify_option_states()
|
||||
|
||||
def get_skip_ods_when_fetch_before_verify(self) -> bool:
|
||||
"""获取是否跳过 ODS 校验(仅校验前获取时生效)"""
|
||||
return self._skip_ods_when_fetch_before_verify
|
||||
|
||||
def set_skip_ods_when_fetch_before_verify(self, enabled: bool):
|
||||
"""设置是否跳过 ODS 校验(仅校验前获取时生效)"""
|
||||
self._skip_ods_when_fetch_before_verify = enabled
|
||||
self._skip_ods_when_fetch_before_verify_checkbox.setChecked(enabled)
|
||||
self._update_verify_option_states()
|
||||
|
||||
def get_ods_use_local_json(self) -> bool:
|
||||
"""获取是否使用本地 JSON 进行 ODS 校验"""
|
||||
return self._ods_use_local_json
|
||||
|
||||
def set_ods_use_local_json(self, enabled: bool):
|
||||
"""设置是否使用本地 JSON 进行 ODS 校验"""
|
||||
self._ods_use_local_json = enabled
|
||||
self._ods_use_local_json_checkbox.setChecked(enabled)
|
||||
self._update_verify_option_states()
|
||||
|
||||
def get_window_mode(self) -> str:
|
||||
"""获取时间窗口模式"""
|
||||
return self._window_mode
|
||||
|
||||
def set_window_mode(self, mode: str):
|
||||
"""设置时间窗口模式"""
|
||||
for button in self._window_button_group.buttons():
|
||||
if button.property("mode_id") == mode:
|
||||
button.setChecked(True)
|
||||
self._on_window_mode_changed(button)
|
||||
break
|
||||
|
||||
def get_lookback_hours(self) -> int:
|
||||
"""获取回溯小时数"""
|
||||
return self._lookback_hours_spin.value()
|
||||
|
||||
def set_lookback_hours(self, hours: int):
|
||||
"""设置回溯小时数"""
|
||||
self._lookback_hours_spin.setValue(hours)
|
||||
|
||||
def get_overlap_seconds(self) -> int:
|
||||
"""获取冗余秒数"""
|
||||
return self._overlap_seconds_spin.value()
|
||||
|
||||
def set_overlap_seconds(self, seconds: int):
|
||||
"""设置冗余秒数"""
|
||||
self._overlap_seconds_spin.setValue(seconds)
|
||||
|
||||
def get_window_start(self) -> str:
|
||||
"""获取开始时间(ISO格式)"""
|
||||
return self._start_datetime.dateTime().toString("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
def set_window_start(self, dt_str: str):
|
||||
"""设置开始时间"""
|
||||
dt = QDateTime.fromString(dt_str, "yyyy-MM-dd HH:mm:ss")
|
||||
if dt.isValid():
|
||||
self._start_datetime.setDateTime(dt)
|
||||
|
||||
def get_window_end(self) -> str:
|
||||
"""获取结束时间(ISO格式)"""
|
||||
return self._end_datetime.dateTime().toString("yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
def set_window_end(self, dt_str: str):
|
||||
"""设置结束时间"""
|
||||
dt = QDateTime.fromString(dt_str, "yyyy-MM-dd HH:mm:ss")
|
||||
if dt.isValid():
|
||||
self._end_datetime.setDateTime(dt)
|
||||
|
||||
def get_window_split(self) -> str:
|
||||
"""获取窗口切分模式"""
|
||||
return self._split_combo.currentData()
|
||||
|
||||
def get_window_split_days(self) -> int:
|
||||
"""获取按天切分天数"""
|
||||
return int(self._split_days_combo.currentData())
|
||||
|
||||
def set_window_split(self, split: str):
|
||||
"""设置窗口切分模式"""
|
||||
index = self._split_combo.findData(split)
|
||||
if index >= 0:
|
||||
self._split_combo.setCurrentIndex(index)
|
||||
self._update_split_days_state()
|
||||
|
||||
def set_window_split_days(self, days: int):
|
||||
"""设置按天切分天数"""
|
||||
index = self._split_days_combo.findData(days)
|
||||
if index >= 0:
|
||||
self._split_days_combo.setCurrentIndex(index)
|
||||
|
||||
def get_config(self) -> dict:
|
||||
"""获取完整配置字典"""
|
||||
split_unit = self.get_window_split()
|
||||
split_days = self.get_window_split_days()
|
||||
return {
|
||||
"pipeline": self._pipeline_id,
|
||||
"processing_mode": self._processing_mode,
|
||||
"fetch_before_verify": self._fetch_before_verify,
|
||||
"skip_ods_when_fetch_before_verify": self._skip_ods_when_fetch_before_verify,
|
||||
"ods_use_local_json": self._ods_use_local_json,
|
||||
"window_mode": self._window_mode,
|
||||
"lookback_hours": self.get_lookback_hours(),
|
||||
"overlap_seconds": self.get_overlap_seconds(),
|
||||
"window_start": self.get_window_start(),
|
||||
"window_end": self.get_window_end(),
|
||||
"window_split": split_unit,
|
||||
"window_split_days": split_days,
|
||||
}
|
||||
|
||||
def set_config(self, config: dict):
|
||||
"""从配置字典恢复设置"""
|
||||
if "pipeline" in config:
|
||||
self.set_pipeline_id(config["pipeline"])
|
||||
if "processing_mode" in config:
|
||||
self.set_processing_mode(config["processing_mode"])
|
||||
if "fetch_before_verify" in config:
|
||||
self.set_fetch_before_verify(config["fetch_before_verify"])
|
||||
if "skip_ods_when_fetch_before_verify" in config:
|
||||
self.set_skip_ods_when_fetch_before_verify(config["skip_ods_when_fetch_before_verify"])
|
||||
if "ods_use_local_json" in config:
|
||||
self.set_ods_use_local_json(config["ods_use_local_json"])
|
||||
if "window_mode" in config:
|
||||
self.set_window_mode(config["window_mode"])
|
||||
if "lookback_hours" in config:
|
||||
self.set_lookback_hours(config["lookback_hours"])
|
||||
if "overlap_seconds" in config:
|
||||
self.set_overlap_seconds(config["overlap_seconds"])
|
||||
if "window_start" in config:
|
||||
self.set_window_start(config["window_start"])
|
||||
if "window_end" in config:
|
||||
self.set_window_end(config["window_end"])
|
||||
if "window_split" in config:
|
||||
self.set_window_split(config["window_split"])
|
||||
if "window_split_days" in config and config["window_split_days"]:
|
||||
self.set_window_split_days(config["window_split_days"])
|
||||
166
gui/widgets/settings_dialog.py
Normal file
166
gui/widgets/settings_dialog.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""应用程序设置对话框"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QGroupBox, QLabel, QLineEdit, QPushButton,
|
||||
QFileDialog, QMessageBox, QDialogButtonBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ..utils.app_settings import app_settings
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""设置对话框"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("应用程序设置")
|
||||
self.setMinimumWidth(600)
|
||||
self._init_ui()
|
||||
self._load_settings()
|
||||
|
||||
def _init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# ETL 项目路径
|
||||
project_group = QGroupBox("ETL 项目配置")
|
||||
project_layout = QGridLayout(project_group)
|
||||
|
||||
project_layout.addWidget(QLabel("ETL 项目路径:"), 0, 0)
|
||||
self.project_path_edit = QLineEdit()
|
||||
self.project_path_edit.setPlaceholderText("例: C:\\ZQYY\\FQ-ETL")
|
||||
project_layout.addWidget(self.project_path_edit, 0, 1)
|
||||
|
||||
browse_project_btn = QPushButton("浏览...")
|
||||
browse_project_btn.clicked.connect(self._browse_project_path)
|
||||
project_layout.addWidget(browse_project_btn, 0, 2)
|
||||
|
||||
project_layout.addWidget(QLabel(".env 文件路径:"), 1, 0)
|
||||
self.env_path_edit = QLineEdit()
|
||||
self.env_path_edit.setPlaceholderText("例: .env")
|
||||
project_layout.addWidget(self.env_path_edit, 1, 1)
|
||||
|
||||
browse_env_btn = QPushButton("浏览...")
|
||||
browse_env_btn.clicked.connect(self._browse_env_path)
|
||||
project_layout.addWidget(browse_env_btn, 1, 2)
|
||||
|
||||
# 验证按钮
|
||||
validate_btn = QPushButton("验证配置")
|
||||
validate_btn.clicked.connect(self._validate_config)
|
||||
project_layout.addWidget(validate_btn, 2, 1)
|
||||
|
||||
# 验证结果
|
||||
self.validation_label = QLabel()
|
||||
self.validation_label.setWordWrap(True)
|
||||
project_layout.addWidget(self.validation_label, 3, 0, 1, 3)
|
||||
|
||||
layout.addWidget(project_group)
|
||||
|
||||
# 说明
|
||||
note = QLabel(
|
||||
"说明:\n"
|
||||
"• ETL 项目路径:包含 cli/main.py 的目录\n"
|
||||
"• .env 文件路径:环境变量配置文件\n"
|
||||
"• 配置后才能正常执行 ETL 任务"
|
||||
)
|
||||
note.setProperty("subheading", True)
|
||||
note.setWordWrap(True)
|
||||
layout.addWidget(note)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# 按钮
|
||||
btn_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||
btn_box.accepted.connect(self._save_and_accept)
|
||||
btn_box.rejected.connect(self.reject)
|
||||
layout.addWidget(btn_box)
|
||||
|
||||
def _load_settings(self):
|
||||
"""加载设置"""
|
||||
self.project_path_edit.setText(app_settings.etl_project_path)
|
||||
self.env_path_edit.setText(app_settings.env_file_path)
|
||||
self._validate_config()
|
||||
|
||||
def _browse_project_path(self):
|
||||
"""浏览项目路径"""
|
||||
path = QFileDialog.getExistingDirectory(
|
||||
self, "选择 ETL 项目目录",
|
||||
self.project_path_edit.text() or str(Path.home())
|
||||
)
|
||||
if path:
|
||||
self.project_path_edit.setText(path)
|
||||
# 自动填充 .env 路径
|
||||
env_path = Path(path) / ".env"
|
||||
if env_path.exists():
|
||||
self.env_path_edit.setText(str(env_path))
|
||||
self._validate_config()
|
||||
|
||||
def _browse_env_path(self):
|
||||
"""浏览 .env 文件"""
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, "选择 .env 文件",
|
||||
self.env_path_edit.text() or str(Path.home()),
|
||||
"环境变量文件 (*.env);;所有文件 (*.*)"
|
||||
)
|
||||
if path:
|
||||
self.env_path_edit.setText(path)
|
||||
self._validate_config()
|
||||
|
||||
def _validate_config(self):
|
||||
"""验证配置"""
|
||||
project_path = self.project_path_edit.text().strip()
|
||||
env_path = self.env_path_edit.text().strip()
|
||||
|
||||
issues = []
|
||||
|
||||
if not project_path:
|
||||
issues.append("• 未设置 ETL 项目路径")
|
||||
else:
|
||||
p = Path(project_path)
|
||||
if not p.exists():
|
||||
issues.append(f"• ETL 项目路径不存在")
|
||||
elif not (p / "cli" / "main.py").exists():
|
||||
issues.append(f"• 找不到 cli/main.py")
|
||||
|
||||
if not env_path:
|
||||
issues.append("• 未设置 .env 文件路径")
|
||||
elif not Path(env_path).exists():
|
||||
issues.append("• .env 文件不存在")
|
||||
|
||||
if issues:
|
||||
self.validation_label.setText("❌ 配置问题:\n" + "\n".join(issues))
|
||||
self.validation_label.setStyleSheet("color: #d93025;")
|
||||
else:
|
||||
self.validation_label.setText("✅ 配置有效")
|
||||
self.validation_label.setStyleSheet("color: #1e8e3e;")
|
||||
|
||||
def _save_and_accept(self):
|
||||
"""保存并关闭"""
|
||||
project_path = self.project_path_edit.text().strip()
|
||||
env_path = self.env_path_edit.text().strip()
|
||||
|
||||
# 简单验证
|
||||
if project_path:
|
||||
p = Path(project_path)
|
||||
if not p.exists():
|
||||
QMessageBox.warning(self, "警告", "ETL 项目路径不存在")
|
||||
return
|
||||
if not (p / "cli" / "main.py").exists():
|
||||
reply = QMessageBox.question(
|
||||
self, "确认",
|
||||
"找不到 cli/main.py,确定要使用此路径吗?",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.No:
|
||||
return
|
||||
|
||||
# 保存设置
|
||||
app_settings.etl_project_path = project_path
|
||||
if env_path:
|
||||
app_settings.env_file_path = env_path
|
||||
|
||||
self.accept()
|
||||
406
gui/widgets/status_panel.py
Normal file
406
gui/widgets/status_panel.py
Normal file
@@ -0,0 +1,406 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""ETL 状态面板"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
|
||||
QGroupBox, QLabel, QPushButton, QTableWidget, QTableWidgetItem,
|
||||
QHeaderView, QFrame, QScrollArea, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, Signal, QTimer
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
from ..workers.db_worker import DBWorker
|
||||
from ..utils.config_helper import ConfigHelper
|
||||
|
||||
|
||||
class StatusCard(QFrame):
|
||||
"""状态卡片"""
|
||||
|
||||
def __init__(self, title: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setProperty("card", True)
|
||||
self.setFrameShape(QFrame.StyledPanel)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
# 标题
|
||||
self.title_label = QLabel(title)
|
||||
self.title_label.setProperty("subheading", True)
|
||||
layout.addWidget(self.title_label)
|
||||
|
||||
# 值
|
||||
self.value_label = QLabel("-")
|
||||
self.value_label.setStyleSheet("font-size: 24px; font-weight: bold;")
|
||||
layout.addWidget(self.value_label)
|
||||
|
||||
# 描述
|
||||
self.desc_label = QLabel("")
|
||||
self.desc_label.setProperty("subheading", True)
|
||||
layout.addWidget(self.desc_label)
|
||||
|
||||
def set_value(self, value: str, description: str = "", status: str = ""):
|
||||
"""设置值"""
|
||||
self.value_label.setText(value)
|
||||
self.desc_label.setText(description)
|
||||
|
||||
if status:
|
||||
self.value_label.setProperty("status", status)
|
||||
self.value_label.style().unpolish(self.value_label)
|
||||
self.value_label.style().polish(self.value_label)
|
||||
|
||||
|
||||
class StatusPanel(QWidget):
|
||||
"""ETL 状态面板"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.config_helper = ConfigHelper()
|
||||
self.db_worker = DBWorker(self)
|
||||
self._connected = False
|
||||
|
||||
self._init_ui()
|
||||
self._connect_signals()
|
||||
|
||||
# 定时刷新
|
||||
self.refresh_timer = QTimer(self)
|
||||
self.refresh_timer.timeout.connect(self._auto_refresh)
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化界面"""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 16, 16, 16)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# 标题和按钮
|
||||
header_layout = QHBoxLayout()
|
||||
|
||||
title = QLabel("ETL 状态")
|
||||
title.setProperty("heading", True)
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.auto_refresh_btn = QPushButton("自动刷新: 关")
|
||||
self.auto_refresh_btn.setProperty("secondary", True)
|
||||
self.auto_refresh_btn.setCheckable(True)
|
||||
header_layout.addWidget(self.auto_refresh_btn)
|
||||
|
||||
self.refresh_btn = QPushButton("刷新")
|
||||
self.refresh_btn.clicked.connect(self._refresh_all)
|
||||
header_layout.addWidget(self.refresh_btn)
|
||||
|
||||
layout.addLayout(header_layout)
|
||||
|
||||
# 连接状态
|
||||
self.conn_status_label = QLabel("数据库: 未连接")
|
||||
self.conn_status_label.setProperty("status", "warning")
|
||||
layout.addWidget(self.conn_status_label)
|
||||
|
||||
# 滚动区域
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
|
||||
# 内容容器
|
||||
content_widget = QWidget()
|
||||
content_layout = QVBoxLayout(content_widget)
|
||||
content_layout.setSpacing(16)
|
||||
|
||||
# 概览卡片
|
||||
cards_layout = QHBoxLayout()
|
||||
|
||||
self.ods_card = StatusCard("ODS 表数量")
|
||||
cards_layout.addWidget(self.ods_card)
|
||||
|
||||
self.dwd_card = StatusCard("DWD 表数量")
|
||||
cards_layout.addWidget(self.dwd_card)
|
||||
|
||||
self.last_update_card = StatusCard("最后更新")
|
||||
cards_layout.addWidget(self.last_update_card)
|
||||
|
||||
self.task_count_card = StatusCard("今日任务")
|
||||
cards_layout.addWidget(self.task_count_card)
|
||||
|
||||
content_layout.addLayout(cards_layout)
|
||||
|
||||
# ODS Cutoff 状态
|
||||
cutoff_group = QGroupBox("ODS Cutoff 状态")
|
||||
cutoff_layout = QVBoxLayout(cutoff_group)
|
||||
|
||||
self.cutoff_table = QTableWidget()
|
||||
self.cutoff_table.setColumnCount(4)
|
||||
self.cutoff_table.setHorizontalHeaderLabels(["表名", "最新 fetched_at", "行数", "状态"])
|
||||
self.cutoff_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.cutoff_table.setMaximumHeight(250)
|
||||
cutoff_layout.addWidget(self.cutoff_table)
|
||||
|
||||
content_layout.addWidget(cutoff_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.setMaximumHeight(250)
|
||||
history_layout.addWidget(self.history_table)
|
||||
|
||||
content_layout.addWidget(history_group)
|
||||
|
||||
# 弹性空间
|
||||
content_layout.addStretch()
|
||||
|
||||
scroll_area.setWidget(content_widget)
|
||||
|
||||
def _connect_signals(self):
|
||||
"""连接信号"""
|
||||
self.auto_refresh_btn.toggled.connect(self._toggle_auto_refresh)
|
||||
self.db_worker.connection_status.connect(self._on_connection_status)
|
||||
self.db_worker.query_finished.connect(self._on_query_finished)
|
||||
self.db_worker.query_error.connect(self._on_query_error)
|
||||
|
||||
def _toggle_auto_refresh(self, checked: bool):
|
||||
"""切换自动刷新"""
|
||||
if checked:
|
||||
self.auto_refresh_btn.setText("自动刷新: 开")
|
||||
self.refresh_timer.start(30000) # 30秒刷新一次
|
||||
self._refresh_all()
|
||||
else:
|
||||
self.auto_refresh_btn.setText("自动刷新: 关")
|
||||
self.refresh_timer.stop()
|
||||
|
||||
def _auto_refresh(self):
|
||||
"""自动刷新"""
|
||||
if self._connected:
|
||||
self._refresh_all()
|
||||
|
||||
def _refresh_all(self):
|
||||
"""刷新所有数据"""
|
||||
# 尝试连接数据库
|
||||
if not self._connected:
|
||||
env_vars = self.config_helper.load_env()
|
||||
dsn = env_vars.get("PG_DSN", "")
|
||||
if dsn:
|
||||
self.db_worker.connect_db(dsn)
|
||||
else:
|
||||
self.conn_status_label.setText("数据库: 未配置 DSN")
|
||||
return
|
||||
else:
|
||||
self._load_status_data()
|
||||
|
||||
def _on_connection_status(self, connected: bool, message: str):
|
||||
"""处理连接状态"""
|
||||
self._connected = connected
|
||||
|
||||
if connected:
|
||||
self.conn_status_label.setText(f"数据库: 已连接")
|
||||
self.conn_status_label.setProperty("status", "success")
|
||||
self._load_status_data()
|
||||
else:
|
||||
self.conn_status_label.setText(f"数据库: {message}")
|
||||
self.conn_status_label.setProperty("status", "error")
|
||||
|
||||
self.conn_status_label.style().unpolish(self.conn_status_label)
|
||||
self.conn_status_label.style().polish(self.conn_status_label)
|
||||
|
||||
def _load_status_data(self):
|
||||
"""加载状态数据"""
|
||||
# 加载表统计
|
||||
self._current_query = "table_count"
|
||||
self.db_worker.execute_query("""
|
||||
SELECT
|
||||
table_schema,
|
||||
COUNT(*) as table_count
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema IN ('billiards_ods', 'billiards_dwd', 'billiards_dws')
|
||||
GROUP BY table_schema
|
||||
""")
|
||||
|
||||
def _on_query_finished(self, columns: list, rows: list):
|
||||
"""处理查询结果"""
|
||||
query_type = getattr(self, '_current_query', '')
|
||||
|
||||
if query_type == "table_count":
|
||||
self._process_table_count(rows)
|
||||
# 继续加载 cutoff 数据
|
||||
self._current_query = "cutoff"
|
||||
self.db_worker.execute_query("""
|
||||
SELECT
|
||||
'payment_transactions' AS table_name,
|
||||
MAX(fetched_at) AS max_fetched_at,
|
||||
COUNT(*) AS row_count
|
||||
FROM billiards_ods.payment_transactions
|
||||
UNION ALL
|
||||
SELECT 'member_profiles', MAX(fetched_at), COUNT(*)
|
||||
FROM billiards_ods.member_profiles
|
||||
UNION ALL
|
||||
SELECT 'settlement_records', MAX(fetched_at), COUNT(*)
|
||||
FROM billiards_ods.settlement_records
|
||||
UNION ALL
|
||||
SELECT 'recharge_settlements', MAX(fetched_at), COUNT(*)
|
||||
FROM billiards_ods.recharge_settlements
|
||||
UNION ALL
|
||||
SELECT 'assistant_service_records', MAX(fetched_at), COUNT(*)
|
||||
FROM billiards_ods.assistant_service_records
|
||||
ORDER BY table_name
|
||||
""")
|
||||
elif query_type == "cutoff":
|
||||
self._process_cutoff_data(rows)
|
||||
# 继续加载运行历史
|
||||
self._current_query = "history"
|
||||
self.db_worker.execute_query("""
|
||||
SELECT
|
||||
run_id,
|
||||
task_code,
|
||||
status,
|
||||
started_at,
|
||||
finished_at,
|
||||
rows_affected
|
||||
FROM etl_admin.run_tracker
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 20
|
||||
""")
|
||||
elif query_type == "history":
|
||||
self._process_history_data(rows)
|
||||
self._current_query = ""
|
||||
|
||||
def _process_table_count(self, rows: list):
|
||||
"""处理表数量数据"""
|
||||
ods_count = 0
|
||||
dwd_count = 0
|
||||
|
||||
for row in rows:
|
||||
schema = row.get("table_schema", "")
|
||||
count = row.get("table_count", 0)
|
||||
|
||||
if schema == "billiards_ods":
|
||||
ods_count = count
|
||||
elif schema == "billiards_dwd":
|
||||
dwd_count = count
|
||||
|
||||
self.ods_card.set_value(str(ods_count), "个表")
|
||||
self.dwd_card.set_value(str(dwd_count), "个表")
|
||||
|
||||
def _process_cutoff_data(self, rows: list):
|
||||
"""处理 Cutoff 数据"""
|
||||
self.cutoff_table.setRowCount(len(rows))
|
||||
|
||||
latest_time = None
|
||||
now = datetime.now()
|
||||
|
||||
for row_idx, row in enumerate(rows):
|
||||
table_name = row.get("table_name", "")
|
||||
max_fetched = row.get("max_fetched_at")
|
||||
row_count = row.get("row_count", 0)
|
||||
|
||||
self.cutoff_table.setItem(row_idx, 0, QTableWidgetItem(table_name))
|
||||
|
||||
if max_fetched:
|
||||
time_str = str(max_fetched)[:19]
|
||||
self.cutoff_table.setItem(row_idx, 1, QTableWidgetItem(time_str))
|
||||
|
||||
# 更新最新时间
|
||||
if latest_time is None or max_fetched > latest_time:
|
||||
latest_time = max_fetched
|
||||
|
||||
# 计算状态
|
||||
if isinstance(max_fetched, datetime):
|
||||
hours_ago = (now - max_fetched).total_seconds() / 3600
|
||||
if hours_ago < 1:
|
||||
status = "正常"
|
||||
status_color = QColor("#1e8e3e")
|
||||
elif hours_ago < 24:
|
||||
status = "较新"
|
||||
status_color = QColor("#1a73e8")
|
||||
else:
|
||||
status = f"落后 {int(hours_ago)}h"
|
||||
status_color = QColor("#f9ab00")
|
||||
else:
|
||||
status = "-"
|
||||
status_color = QColor("#9aa0a6")
|
||||
else:
|
||||
self.cutoff_table.setItem(row_idx, 1, QTableWidgetItem("-"))
|
||||
status = "无数据"
|
||||
status_color = QColor("#d93025")
|
||||
|
||||
self.cutoff_table.setItem(row_idx, 2, QTableWidgetItem(str(row_count)))
|
||||
|
||||
status_item = QTableWidgetItem(status)
|
||||
status_item.setForeground(status_color)
|
||||
self.cutoff_table.setItem(row_idx, 3, status_item)
|
||||
|
||||
# 更新最后更新时间卡片
|
||||
if latest_time:
|
||||
time_str = str(latest_time)[:16]
|
||||
self.last_update_card.set_value(time_str, "")
|
||||
else:
|
||||
self.last_update_card.set_value("-", "无数据")
|
||||
|
||||
def _process_history_data(self, rows: list):
|
||||
"""处理运行历史数据"""
|
||||
self.history_table.setRowCount(len(rows))
|
||||
|
||||
today_count = 0
|
||||
today = datetime.now().date()
|
||||
|
||||
for row_idx, row in enumerate(rows):
|
||||
run_id = row.get("run_id", "")
|
||||
task_code = row.get("task_code", "")
|
||||
status = row.get("status", "")
|
||||
started_at = row.get("started_at")
|
||||
finished_at = row.get("finished_at")
|
||||
rows_affected = row.get("rows_affected", 0)
|
||||
|
||||
# 统计今日任务
|
||||
if started_at and isinstance(started_at, datetime):
|
||||
if started_at.date() == today:
|
||||
today_count += 1
|
||||
|
||||
self.history_table.setItem(row_idx, 0, QTableWidgetItem(str(run_id)[:8] if run_id else "-"))
|
||||
self.history_table.setItem(row_idx, 1, QTableWidgetItem(task_code))
|
||||
|
||||
# 状态
|
||||
status_item = QTableWidgetItem(status)
|
||||
if status and "success" in status.lower():
|
||||
status_item.setForeground(QColor("#1e8e3e"))
|
||||
elif status and ("fail" in status.lower() or "error" in status.lower()):
|
||||
status_item.setForeground(QColor("#d93025"))
|
||||
self.history_table.setItem(row_idx, 2, status_item)
|
||||
|
||||
# 开始时间
|
||||
time_str = str(started_at)[:19] if started_at else "-"
|
||||
self.history_table.setItem(row_idx, 3, QTableWidgetItem(time_str))
|
||||
|
||||
# 耗时
|
||||
if started_at and finished_at:
|
||||
try:
|
||||
duration = (finished_at - started_at).total_seconds()
|
||||
if duration < 60:
|
||||
duration_str = f"{duration:.1f}秒"
|
||||
else:
|
||||
duration_str = f"{int(duration // 60)}分{int(duration % 60)}秒"
|
||||
except:
|
||||
duration_str = "-"
|
||||
else:
|
||||
duration_str = "-"
|
||||
self.history_table.setItem(row_idx, 4, QTableWidgetItem(duration_str))
|
||||
|
||||
# 影响行数
|
||||
self.history_table.setItem(row_idx, 5, QTableWidgetItem(str(rows_affected or 0)))
|
||||
|
||||
# 更新今日任务卡片
|
||||
self.task_count_card.set_value(str(today_count), "次执行")
|
||||
|
||||
def _on_query_error(self, error: str):
|
||||
"""处理查询错误"""
|
||||
self._current_query = ""
|
||||
# 可能是表不存在,忽略错误继续
|
||||
pass
|
||||
1989
gui/widgets/task_manager.py
Normal file
1989
gui/widgets/task_manager.py
Normal file
File diff suppressed because it is too large
Load Diff
1224
gui/widgets/task_panel.py
Normal file
1224
gui/widgets/task_panel.py
Normal file
File diff suppressed because it is too large
Load Diff
550
gui/widgets/task_selector.py
Normal file
550
gui/widgets/task_selector.py
Normal file
@@ -0,0 +1,550 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""可复用的任务选择组件:按业务域分组显示,支持全选/反选。"""
|
||||
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
|
||||
QCheckBox, QPushButton, QScrollArea, QFrame,
|
||||
QLabel, QSizePolicy
|
||||
)
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
|
||||
from ..models.task_registry import (
|
||||
TaskRegistry, TaskDefinition, BusinessDomain, DOMAIN_LABELS,
|
||||
task_registry, get_fact_ods_task_codes, get_dimension_ods_task_codes,
|
||||
DwdTableDefinition, DWD_TABLE_DEFINITIONS, DWD_TABLE_DOMAIN_ORDER,
|
||||
get_dwd_tables_grouped, get_all_dwd_table_codes,
|
||||
)
|
||||
|
||||
|
||||
class TaskSelectorWidget(QWidget):
|
||||
"""ODS 任务选择组件:按业务域分组显示"""
|
||||
|
||||
# 选择变化信号
|
||||
selection_changed = Signal(list) # 选中的任务编码列表
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
show_dimensions: bool = True,
|
||||
show_facts: bool = True,
|
||||
default_select_facts: bool = True,
|
||||
default_select_dimensions: bool = False,
|
||||
compact: bool = False,
|
||||
max_height: int = 0,
|
||||
):
|
||||
"""
|
||||
初始化任务选择器
|
||||
|
||||
Args:
|
||||
parent: 父组件
|
||||
show_dimensions: 是否显示维度类任务
|
||||
show_facts: 是否显示事实类任务
|
||||
default_select_facts: 默认选中事实类任务
|
||||
default_select_dimensions: 默认选中维度类任务
|
||||
compact: 紧凑模式(更小的间距)
|
||||
max_height: 最大高度(0 表示不限制)
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.show_dimensions = show_dimensions
|
||||
self.show_facts = show_facts
|
||||
self.default_select_facts = default_select_facts
|
||||
self.default_select_dimensions = default_select_dimensions
|
||||
self.compact = compact
|
||||
self.max_height = max_height
|
||||
|
||||
# 任务复选框映射:code -> QCheckBox
|
||||
self._checkboxes: Dict[str, QCheckBox] = {}
|
||||
# 业务域分组框映射:domain -> QGroupBox
|
||||
self._domain_groups: Dict[BusinessDomain, QGroupBox] = {}
|
||||
|
||||
self._init_ui()
|
||||
self._apply_default_selection()
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化界面"""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
spacing = 4 if self.compact else 8
|
||||
layout.setSpacing(spacing)
|
||||
|
||||
# 顶部工具栏
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setSpacing(8)
|
||||
|
||||
self.select_all_btn = QPushButton("全选")
|
||||
self.select_all_btn.setProperty("secondary", True)
|
||||
self.select_all_btn.setFixedWidth(60)
|
||||
self.select_all_btn.clicked.connect(self._select_all)
|
||||
toolbar.addWidget(self.select_all_btn)
|
||||
|
||||
self.deselect_all_btn = QPushButton("全不选")
|
||||
self.deselect_all_btn.setProperty("secondary", True)
|
||||
self.deselect_all_btn.setFixedWidth(60)
|
||||
self.deselect_all_btn.clicked.connect(self._deselect_all)
|
||||
toolbar.addWidget(self.deselect_all_btn)
|
||||
|
||||
self.select_facts_btn = QPushButton("选事实表")
|
||||
self.select_facts_btn.setProperty("secondary", True)
|
||||
self.select_facts_btn.setFixedWidth(70)
|
||||
self.select_facts_btn.setToolTip("选中所有事实类任务(需要时间窗口的任务)")
|
||||
self.select_facts_btn.clicked.connect(self._select_facts_only)
|
||||
toolbar.addWidget(self.select_facts_btn)
|
||||
|
||||
toolbar.addStretch()
|
||||
|
||||
self.selected_count_label = QLabel("已选: 0")
|
||||
self.selected_count_label.setProperty("subheading", True)
|
||||
toolbar.addWidget(self.selected_count_label)
|
||||
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
# 内容容器
|
||||
content_widget = QWidget()
|
||||
content_layout = QVBoxLayout(content_widget)
|
||||
content_layout.setContentsMargins(0, 0, 0, 0)
|
||||
content_layout.setSpacing(spacing)
|
||||
|
||||
# 按业务域分组创建复选框
|
||||
grouped_tasks = task_registry.get_ods_tasks_grouped()
|
||||
|
||||
# 定义业务域显示顺序
|
||||
domain_order = [
|
||||
BusinessDomain.MEMBER,
|
||||
BusinessDomain.SETTLEMENT,
|
||||
BusinessDomain.ASSISTANT,
|
||||
BusinessDomain.GOODS,
|
||||
BusinessDomain.TABLE,
|
||||
BusinessDomain.PROMOTION,
|
||||
BusinessDomain.INVENTORY,
|
||||
]
|
||||
|
||||
for domain in domain_order:
|
||||
if domain not in grouped_tasks:
|
||||
continue
|
||||
|
||||
tasks = grouped_tasks[domain]
|
||||
# 过滤任务
|
||||
filtered_tasks = []
|
||||
for task in tasks:
|
||||
if task.is_dimension and not self.show_dimensions:
|
||||
continue
|
||||
if not task.is_dimension and not self.show_facts:
|
||||
continue
|
||||
filtered_tasks.append(task)
|
||||
|
||||
if not filtered_tasks:
|
||||
continue
|
||||
|
||||
# 创建业务域分组
|
||||
group_box = self._create_domain_group(domain, filtered_tasks)
|
||||
self._domain_groups[domain] = group_box
|
||||
content_layout.addWidget(group_box)
|
||||
|
||||
content_layout.addStretch()
|
||||
|
||||
if self.max_height > 0:
|
||||
# 需要限制高度时启用滚动区域
|
||||
scroll_area = QScrollArea()
|
||||
scroll_area.setWidgetResizable(True)
|
||||
scroll_area.setFrameShape(QFrame.NoFrame)
|
||||
scroll_area.setMaximumHeight(self.max_height)
|
||||
scroll_area.setWidget(content_widget)
|
||||
layout.addWidget(scroll_area, 1)
|
||||
else:
|
||||
# 全量展示,不使用内部滚动
|
||||
layout.addWidget(content_widget)
|
||||
|
||||
def _create_domain_group(self, domain: BusinessDomain, tasks: List[TaskDefinition]) -> QGroupBox:
|
||||
"""创建业务域分组框"""
|
||||
group_box = QGroupBox(DOMAIN_LABELS.get(domain, str(domain.value)))
|
||||
group_layout = QVBoxLayout(group_box)
|
||||
group_layout.setContentsMargins(8, 4, 8, 4)
|
||||
group_layout.setSpacing(2)
|
||||
|
||||
for task in tasks:
|
||||
checkbox = QCheckBox(f"{task.name}")
|
||||
checkbox.setToolTip(f"{task.code}: {task.description}")
|
||||
checkbox.setProperty("task_code", task.code)
|
||||
checkbox.setProperty("is_dimension", task.is_dimension)
|
||||
checkbox.stateChanged.connect(self._on_selection_changed)
|
||||
|
||||
self._checkboxes[task.code] = checkbox
|
||||
group_layout.addWidget(checkbox)
|
||||
|
||||
return group_box
|
||||
|
||||
def _apply_default_selection(self):
|
||||
"""应用默认选择"""
|
||||
for code, checkbox in self._checkboxes.items():
|
||||
is_dimension = checkbox.property("is_dimension")
|
||||
if is_dimension:
|
||||
checkbox.setChecked(self.default_select_dimensions)
|
||||
else:
|
||||
checkbox.setChecked(self.default_select_facts)
|
||||
|
||||
self._update_count_label()
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""选择变化时"""
|
||||
self._update_count_label()
|
||||
self.selection_changed.emit(self.get_selected_codes())
|
||||
|
||||
def _update_count_label(self):
|
||||
"""更新选中计数标签"""
|
||||
count = len(self.get_selected_codes())
|
||||
total = len(self._checkboxes)
|
||||
self.selected_count_label.setText(f"已选: {count}/{total}")
|
||||
|
||||
def _select_all(self):
|
||||
"""全选"""
|
||||
for checkbox in self._checkboxes.values():
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(True)
|
||||
checkbox.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def _deselect_all(self):
|
||||
"""全不选"""
|
||||
for checkbox in self._checkboxes.values():
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(False)
|
||||
checkbox.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def _select_facts_only(self):
|
||||
"""只选事实表任务"""
|
||||
for code, checkbox in self._checkboxes.items():
|
||||
checkbox.blockSignals(True)
|
||||
is_dimension = checkbox.property("is_dimension")
|
||||
checkbox.setChecked(not is_dimension)
|
||||
checkbox.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def get_selected_codes(self) -> List[str]:
|
||||
"""获取选中的任务编码列表"""
|
||||
selected = []
|
||||
for code, checkbox in self._checkboxes.items():
|
||||
if checkbox.isChecked():
|
||||
selected.append(code)
|
||||
return selected
|
||||
|
||||
def set_selected_codes(self, codes: List[str]):
|
||||
"""设置选中的任务编码"""
|
||||
codes_set = set(codes)
|
||||
for code, checkbox in self._checkboxes.items():
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(code in codes_set)
|
||||
checkbox.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def get_all_codes(self) -> List[str]:
|
||||
"""获取所有任务编码"""
|
||||
return list(self._checkboxes.keys())
|
||||
|
||||
def is_any_selected(self) -> bool:
|
||||
"""是否有任何任务被选中"""
|
||||
return len(self.get_selected_codes()) > 0
|
||||
|
||||
|
||||
class CompactTaskSelector(QWidget):
|
||||
"""紧凑型任务选择器:单行显示业务域,点击展开选择"""
|
||||
|
||||
selection_changed = Signal(list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget] = None,
|
||||
show_dimensions: bool = True,
|
||||
show_facts: bool = True,
|
||||
default_select_facts: bool = True,
|
||||
default_select_dimensions: bool = False,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.show_dimensions = show_dimensions
|
||||
self.show_facts = show_facts
|
||||
self.default_select_facts = default_select_facts
|
||||
self.default_select_dimensions = default_select_dimensions
|
||||
|
||||
# 业务域复选框
|
||||
self._domain_checkboxes: Dict[BusinessDomain, QCheckBox] = {}
|
||||
# 业务域下的任务编码
|
||||
self._domain_tasks: Dict[BusinessDomain, List[str]] = {}
|
||||
|
||||
self._init_ui()
|
||||
self._apply_default_selection()
|
||||
|
||||
def _init_ui(self):
|
||||
"""初始化界面"""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
|
||||
# 工具栏
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setSpacing(8)
|
||||
|
||||
self.select_all_btn = QPushButton("全选")
|
||||
self.select_all_btn.setProperty("secondary", True)
|
||||
self.select_all_btn.setFixedWidth(50)
|
||||
self.select_all_btn.clicked.connect(self._select_all)
|
||||
toolbar.addWidget(self.select_all_btn)
|
||||
|
||||
self.deselect_all_btn = QPushButton("清空")
|
||||
self.deselect_all_btn.setProperty("secondary", True)
|
||||
self.deselect_all_btn.setFixedWidth(50)
|
||||
self.deselect_all_btn.clicked.connect(self._deselect_all)
|
||||
toolbar.addWidget(self.deselect_all_btn)
|
||||
|
||||
toolbar.addStretch()
|
||||
|
||||
self.count_label = QLabel("已选: 0")
|
||||
self.count_label.setProperty("subheading", True)
|
||||
toolbar.addWidget(self.count_label)
|
||||
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
# 业务域复选框(横向排列)
|
||||
domains_layout = QHBoxLayout()
|
||||
domains_layout.setSpacing(12)
|
||||
|
||||
grouped_tasks = task_registry.get_ods_tasks_grouped()
|
||||
domain_order = [
|
||||
BusinessDomain.MEMBER,
|
||||
BusinessDomain.SETTLEMENT,
|
||||
BusinessDomain.ASSISTANT,
|
||||
BusinessDomain.GOODS,
|
||||
BusinessDomain.TABLE,
|
||||
BusinessDomain.PROMOTION,
|
||||
BusinessDomain.INVENTORY,
|
||||
]
|
||||
|
||||
for domain in domain_order:
|
||||
if domain not in grouped_tasks:
|
||||
continue
|
||||
|
||||
tasks = grouped_tasks[domain]
|
||||
# 过滤任务
|
||||
task_codes = []
|
||||
for task in tasks:
|
||||
if task.is_dimension and not self.show_dimensions:
|
||||
continue
|
||||
if not task.is_dimension and not self.show_facts:
|
||||
continue
|
||||
task_codes.append(task.code)
|
||||
|
||||
if not task_codes:
|
||||
continue
|
||||
|
||||
self._domain_tasks[domain] = task_codes
|
||||
|
||||
checkbox = QCheckBox(DOMAIN_LABELS.get(domain, str(domain.value)))
|
||||
checkbox.setToolTip(f"包含: {', '.join(task_codes)}")
|
||||
checkbox.stateChanged.connect(self._on_selection_changed)
|
||||
self._domain_checkboxes[domain] = checkbox
|
||||
domains_layout.addWidget(checkbox)
|
||||
|
||||
domains_layout.addStretch()
|
||||
layout.addLayout(domains_layout)
|
||||
|
||||
def _apply_default_selection(self):
|
||||
"""应用默认选择"""
|
||||
# 默认选中所有业务域
|
||||
for domain, checkbox in self._domain_checkboxes.items():
|
||||
checkbox.setChecked(True)
|
||||
self._update_count_label()
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""选择变化时"""
|
||||
self._update_count_label()
|
||||
self.selection_changed.emit(self.get_selected_codes())
|
||||
|
||||
def _update_count_label(self):
|
||||
"""更新计数标签"""
|
||||
count = len(self.get_selected_codes())
|
||||
self.count_label.setText(f"已选: {count} 个任务")
|
||||
|
||||
def _select_all(self):
|
||||
"""全选所有业务域"""
|
||||
for checkbox in self._domain_checkboxes.values():
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(True)
|
||||
checkbox.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def _deselect_all(self):
|
||||
"""取消全选"""
|
||||
for checkbox in self._domain_checkboxes.values():
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(False)
|
||||
checkbox.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def get_selected_codes(self) -> List[str]:
|
||||
"""获取选中的任务编码"""
|
||||
selected = []
|
||||
for domain, checkbox in self._domain_checkboxes.items():
|
||||
if checkbox.isChecked():
|
||||
selected.extend(self._domain_tasks.get(domain, []))
|
||||
return selected
|
||||
|
||||
def set_selected_domains(self, domains: List[BusinessDomain]):
|
||||
"""设置选中的业务域"""
|
||||
domains_set = set(domains)
|
||||
for domain, checkbox in self._domain_checkboxes.items():
|
||||
checkbox.blockSignals(True)
|
||||
checkbox.setChecked(domain in domains_set)
|
||||
checkbox.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def is_any_selected(self) -> bool:
|
||||
"""是否有任何任务被选中"""
|
||||
return len(self.get_selected_codes()) > 0
|
||||
|
||||
|
||||
class DwdTableSelectorWidget(QWidget):
|
||||
"""DWD 表选择组件:按业务域分组显示,类似 ODS 任务选择器。
|
||||
|
||||
每个复选框对应一组 DWD 表(主表 + _ex 扩展表),
|
||||
默认全选,不使用内部滚动。
|
||||
"""
|
||||
|
||||
# 选择变化信号:发射选中的 DWD 表编码列表
|
||||
selection_changed = Signal(list)
|
||||
|
||||
def __init__(self, parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
# code -> QCheckBox
|
||||
self._checkboxes: Dict[str, QCheckBox] = {}
|
||||
# domain -> QGroupBox
|
||||
self._domain_groups: Dict[BusinessDomain, QGroupBox] = {}
|
||||
self._init_ui()
|
||||
# 默认全选
|
||||
self._select_all()
|
||||
|
||||
# ------------------------------------------------------------------ UI
|
||||
def _init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
|
||||
# 工具栏
|
||||
toolbar = QHBoxLayout()
|
||||
toolbar.setSpacing(8)
|
||||
|
||||
self.select_all_btn = QPushButton("全选")
|
||||
self.select_all_btn.setProperty("secondary", True)
|
||||
self.select_all_btn.setFixedWidth(60)
|
||||
self.select_all_btn.clicked.connect(self._select_all)
|
||||
toolbar.addWidget(self.select_all_btn)
|
||||
|
||||
self.deselect_all_btn = QPushButton("全不选")
|
||||
self.deselect_all_btn.setProperty("secondary", True)
|
||||
self.deselect_all_btn.setFixedWidth(60)
|
||||
self.deselect_all_btn.clicked.connect(self._deselect_all)
|
||||
toolbar.addWidget(self.deselect_all_btn)
|
||||
|
||||
self.select_facts_btn = QPushButton("选事实表")
|
||||
self.select_facts_btn.setProperty("secondary", True)
|
||||
self.select_facts_btn.setFixedWidth(70)
|
||||
self.select_facts_btn.setToolTip("仅选中事实表,取消维度表")
|
||||
self.select_facts_btn.clicked.connect(self._select_facts_only)
|
||||
toolbar.addWidget(self.select_facts_btn)
|
||||
|
||||
toolbar.addStretch()
|
||||
|
||||
self.selected_count_label = QLabel("已选: 0")
|
||||
self.selected_count_label.setProperty("subheading", True)
|
||||
toolbar.addWidget(self.selected_count_label)
|
||||
|
||||
layout.addLayout(toolbar)
|
||||
|
||||
# 按业务域分组
|
||||
grouped = get_dwd_tables_grouped()
|
||||
for domain in DWD_TABLE_DOMAIN_ORDER:
|
||||
tables = grouped.get(domain)
|
||||
if not tables:
|
||||
continue
|
||||
group_box = self._create_domain_group(domain, tables)
|
||||
self._domain_groups[domain] = group_box
|
||||
layout.addWidget(group_box)
|
||||
|
||||
def _create_domain_group(
|
||||
self, domain: BusinessDomain, tables: List[DwdTableDefinition]
|
||||
) -> QGroupBox:
|
||||
group_box = QGroupBox(DOMAIN_LABELS.get(domain, str(domain.value)))
|
||||
group_layout = QVBoxLayout(group_box)
|
||||
group_layout.setContentsMargins(8, 4, 8, 4)
|
||||
group_layout.setSpacing(2)
|
||||
|
||||
for tbl in tables:
|
||||
tag = "[维]" if tbl.is_dimension else "[事]"
|
||||
checkbox = QCheckBox(f"{tag} {tbl.name}")
|
||||
checkbox.setToolTip(
|
||||
f"{tbl.code}: {tbl.description}\n表: {', '.join(tbl.tables)}"
|
||||
)
|
||||
checkbox.setProperty("table_code", tbl.code)
|
||||
checkbox.setProperty("is_dimension", tbl.is_dimension)
|
||||
checkbox.stateChanged.connect(self._on_selection_changed)
|
||||
self._checkboxes[tbl.code] = checkbox
|
||||
group_layout.addWidget(checkbox)
|
||||
|
||||
return group_box
|
||||
|
||||
# -------------------------------------------------------------- 交互
|
||||
def _on_selection_changed(self):
|
||||
self._update_count_label()
|
||||
self.selection_changed.emit(self.get_selected_codes())
|
||||
|
||||
def _update_count_label(self):
|
||||
count = len(self.get_selected_codes())
|
||||
total = len(self._checkboxes)
|
||||
self.selected_count_label.setText(f"已选: {count}/{total}")
|
||||
|
||||
def _select_all(self):
|
||||
for cb in self._checkboxes.values():
|
||||
cb.blockSignals(True)
|
||||
cb.setChecked(True)
|
||||
cb.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def _deselect_all(self):
|
||||
for cb in self._checkboxes.values():
|
||||
cb.blockSignals(True)
|
||||
cb.setChecked(False)
|
||||
cb.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def _select_facts_only(self):
|
||||
for cb in self._checkboxes.values():
|
||||
cb.blockSignals(True)
|
||||
cb.setChecked(not cb.property("is_dimension"))
|
||||
cb.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
# -------------------------------------------------------------- API
|
||||
def get_selected_codes(self) -> List[str]:
|
||||
"""返回选中的 DWD 表编码列表(如 ['dim_member', 'dwd_payment', ...])"""
|
||||
return [code for code, cb in self._checkboxes.items() if cb.isChecked()]
|
||||
|
||||
def set_selected_codes(self, codes: List[str]):
|
||||
"""设置选中的 DWD 表编码"""
|
||||
codes_set = set(codes)
|
||||
for code, cb in self._checkboxes.items():
|
||||
cb.blockSignals(True)
|
||||
cb.setChecked(code in codes_set)
|
||||
cb.blockSignals(False)
|
||||
self._on_selection_changed()
|
||||
|
||||
def get_all_codes(self) -> List[str]:
|
||||
"""获取所有 DWD 表编码"""
|
||||
return list(self._checkboxes.keys())
|
||||
|
||||
def is_all_selected(self) -> bool:
|
||||
"""是否全部选中"""
|
||||
return len(self.get_selected_codes()) == len(self._checkboxes)
|
||||
|
||||
def is_any_selected(self) -> bool:
|
||||
"""是否有选中"""
|
||||
return len(self.get_selected_codes()) > 0
|
||||
Reference in New Issue
Block a user