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

391 lines
13 KiB
Python

# -*- 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)