init: 项目初始提交 - NeoZQYY Monorepo 完整代码

This commit is contained in:
Neo
2026-02-15 14:58:14 +08:00
commit ded6dfb9d8
769 changed files with 182616 additions and 0 deletions

21
gui/widgets/__init__.py Normal file
View 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
View 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
View 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/Shanghai",
"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
View 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}")

View 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"])

View 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
View 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

File diff suppressed because it is too large Load Diff

1218
gui/widgets/task_panel.py Normal file

File diff suppressed because it is too large Load Diff

View 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