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