From 748f7267240523a1cae568e7411c44938306f641 Mon Sep 17 00:00:00 2001 From: lijingzhi Date: Sun, 10 May 2026 18:43:05 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=AB=98?= =?UTF-8?q?=E5=88=86=E5=B1=8F=E5=B9=B6=E9=9A=94=E7=A6=BB=E4=B8=8E=E4=B8=BB?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E7=9A=84DPI=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/plugin_manager/__main__.py | 5 +++++ src/plugin_manager/_run.py | 5 +++++ src/plugin_manager/app_paths.py | 4 ++++ 3 files changed, 14 insertions(+) diff --git a/src/plugin_manager/__main__.py b/src/plugin_manager/__main__.py index d836ced..0db483e 100644 --- a/src/plugin_manager/__main__.py +++ b/src/plugin_manager/__main__.py @@ -12,8 +12,13 @@ """ import argparse +import os import sys +# 高分屏支持:必须在导入 PyQt 之前设置 +os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") +os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") + def main() -> int: parser = argparse.ArgumentParser(description="Solvable-Minesweeper 插件管理器") diff --git a/src/plugin_manager/_run.py b/src/plugin_manager/_run.py index 1ee171d..aea93eb 100644 --- a/src/plugin_manager/_run.py +++ b/src/plugin_manager/_run.py @@ -7,8 +7,13 @@ """ import argparse +import os import sys +# 高分屏支持:必须在导入 PyQt 之前设置 +os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1") +os.environ.setdefault("QT_SCALE_FACTOR_ROUNDING_POLICY", "PassThrough") + def main() -> int: parser = argparse.ArgumentParser(description="Solvable-Minesweeper 插件管理器") diff --git a/src/plugin_manager/app_paths.py b/src/plugin_manager/app_paths.py index 4f37b6c..36b8c27 100644 --- a/src/plugin_manager/app_paths.py +++ b/src/plugin_manager/app_paths.py @@ -161,10 +161,14 @@ def get_env_for_subprocess(env: dict | None = None) -> dict: 为启动插件管理器子进程构建环境变量 确保 PYTHONPATH 包含正确的路径,使子进程中的动态导入正常工作。 + 移除 QT_FONT_DPI 以让插件管理器使用独立的高分屏设置。 """ if env is None: env = dict(os.environ) + # 移除主程序的 DPI 设置,让插件管理器使用自己的高分屏配置 + env.pop("QT_FONT_DPI", None) + bundle = str(get_bundle_dir()) exec_dir = str(get_executable_dir()) From 272d3cfd02240a5de7250a1dd799afed68514172 Mon Sep 17 00:00:00 2001 From: lijingzhi Date: Mon, 11 May 2026 18:07:30 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=88=97?= =?UTF-8?q?=E6=8E=92=E5=BA=8F=E5=B9=B6=E9=87=8D=E6=9E=84=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86=E4=B8=BA=E6=8C=89=E9=9C=80=E5=88=9B=E5=BB=BA=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8E=86=E5=8F=B2=E6=9C=8D=E5=8A=A1=E7=9A=84?= =?UTF-8?q?=E5=8E=9F=E5=A7=8Bsql=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构列设置、过滤和排序对话框,改为继承 ConfirmDialog 并在使用时按需创建实例,而非长期持有。 同时为列设置对话框新增右键菜单和快捷键排序列功能,支持用户自定义列显示顺序, 并将过滤和排序的 SQL 生成逻辑移至主组件中统一管理。 --- src/plugins/history/columns_dialog.py | 192 +++++++++--- src/plugins/history/filter_dialog.py | 17 +- src/plugins/history/main_widget.py | 322 +++++++++++++++------ src/plugins/history/plugin.py | 30 ++ src/plugins/history/sort_dialog.py | 15 +- src/plugins/history/table_model.py | 6 +- src/plugins/services/history.py | 50 +++- src/shared_types/widgets/__init__.py | 2 + src/shared_types/widgets/confirm_dialog.py | 109 +++++++ 9 files changed, 609 insertions(+), 134 deletions(-) create mode 100644 src/shared_types/widgets/confirm_dialog.py diff --git a/src/plugins/history/columns_dialog.py b/src/plugins/history/columns_dialog.py index c45b026..8bd1960 100644 --- a/src/plugins/history/columns_dialog.py +++ b/src/plugins/history/columns_dialog.py @@ -1,33 +1,68 @@ """ -列显示设置对话框 +列显示设置对话框(支持上下移动排序) """ from __future__ import annotations -from PyQt5.QtCore import QCoreApplication +from PyQt5.QtCore import QCoreApplication, Qt +from PyQt5.QtGui import QKeyEvent from PyQt5.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QPushButton, - QScrollArea, - QCheckBox, - QDialog, + QListWidget, + QListWidgetItem, + QDialogButtonBox, + QMenu, + QWidget, ) +from shared_types.widgets import ConfirmDialog + _translate = QCoreApplication.translate -class ColumnsDialog(QDialog): +class ColumnListWidget(QListWidget): + """自定义列表控件,处理移动快捷键""" + + def __init__(self, parent=None): + super().__init__(parent) + self._move_up_callback = None + self._move_down_callback = None + + def set_move_callbacks(self, move_up, move_down): + """设置移动回调函数""" + self._move_up_callback = move_up + self._move_down_callback = move_down + + def keyPressEvent(self, event: QKeyEvent): + """键盘事件:Ctrl+Shift+↑↓ 移动选中项""" + if (event.modifiers() & Qt.ControlModifier) and (event.modifiers() & Qt.ShiftModifier): # type: ignore + if event.key() == Qt.Key_Up and self._move_up_callback: + self._move_up_callback() + return + elif event.key() == Qt.Key_Down and self._move_down_callback: + self._move_down_callback() + return + super().keyPressEvent(event) + + +class ColumnsDialog(ConfirmDialog): """列显示设置对话框""" def __init__(self, headers: list[str], show_fields: list[str], parent=None): - super().__init__(parent) - self.setWindowTitle(_translate("Form", "列设置")) - self.resize(300, 500) self._headers = headers + self._show_fields = show_fields + super().__init__( + parent, + title=_translate("Form", "列设置(右键/Ctrl+Shift+↑↓ 排序)"), + ) + self.resize(300, 500) - layout = QVBoxLayout(self) + def _create_content(self): + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) # 全选/取消全选 select_layout = QHBoxLayout() @@ -37,40 +72,123 @@ def __init__(self, headers: list[str], show_fields: list[str], parent=None): select_layout.addWidget(self.deselect_all_btn) layout.addLayout(select_layout) - # 勾选列表 - scroll = QScrollArea(self) - scroll.setWidgetResizable(True) - scroll_widget = QWidget() - scroll_layout = QVBoxLayout(scroll_widget) - scroll_layout.setContentsMargins(4, 4, 4, 4) + # 列表(支持多选) + self.list_widget = ColumnListWidget() + self.list_widget.setSelectionMode(QListWidget.ExtendedSelection) + self.list_widget.setContextMenuPolicy(Qt.CustomContextMenu) + self.list_widget.customContextMenuRequested.connect( + self._show_context_menu) + self.list_widget.set_move_callbacks(self._move_up, self._move_down) - self.checks: dict[str, QCheckBox] = {} - for field in headers: - cb = QCheckBox(field) - cb.setChecked(field in show_fields) - scroll_layout.addWidget(cb) - self.checks[field] = cb + # 初始化列表 + self._init_list() - scroll_layout.addStretch() - scroll.setWidget(scroll_widget) - layout.addWidget(scroll) - - # 确定按钮 - self.ok_button = QPushButton(_translate("Form", "确定")) - layout.addWidget(self.ok_button) + layout.addWidget(self.list_widget) self.select_all_btn.clicked.connect(self._select_all) self.deselect_all_btn.clicked.connect(self._deselect_all) - self.ok_button.clicked.connect(self.accept) + + return widget + + def _init_list(self): + """初始化列表内容""" + self.list_widget.clear() + + # 按 show_fields 顺序排列 + ordered_fields = [f for f in self._show_fields if f in self._headers] + remaining_fields = [ + f for f in self._headers if f not in self._show_fields] + + for field in ordered_fields + remaining_fields: + item = QListWidgetItem(field) + item.setCheckState( + Qt.Checked if field in self._show_fields else Qt.Unchecked) + self.list_widget.addItem(item) + + def set_show_fields(self, show_fields: list[str]): + """设置当前显示的字段列表(下次打开时使用)""" + self._show_fields = show_fields + + def item(self, row: int) -> QListWidgetItem: + return self.list_widget.item(row) # type: ignore def _select_all(self): - for cb in self.checks.values(): - cb.setChecked(True) + for i in range(self.list_widget.count()): + self.item(i).setCheckState(Qt.Checked) def _deselect_all(self): - for cb in self.checks.values(): - cb.setChecked(False) + for i in range(self.list_widget.count()): + self.item(i).setCheckState(Qt.Unchecked) + + def _show_context_menu(self, pos): + """显示右键菜单""" + menu = QMenu(self) + menu.addAction(_translate("Form", "上移 (Ctrl+Shift+↑)"), self._move_up) + menu.addAction(_translate( + "Form", "下移 (Ctrl+Shift+↓)"), self._move_down) + menu.exec_(self.list_widget.mapToGlobal(pos)) + + def _move_up(self): + """上移选中的项目""" + selected = self.list_widget.selectedItems() + if not selected: + return + + # 按行号升序排列 + for item in sorted(selected, key=lambda x: self.list_widget.row(x)): + row = self.list_widget.row(item) + if row > 0: + # 检查上一行是否也在选中列表中 + prev_item = self.list_widget.item(row - 1) + if prev_item not in selected: + self.list_widget.takeItem(row) + self.list_widget.insertItem(row - 1, item) + item.setSelected(True) + + # 滚动到选中的第一行 + self._scroll_to_first_selected() + + def _move_down(self): + """下移选中的项目""" + selected = self.list_widget.selectedItems() + if not selected: + return + + count = self.list_widget.count() + # 按行号降序排列(从下往上处理) + for item in sorted(selected, key=lambda x: self.list_widget.row(x), reverse=True): + row = self.list_widget.row(item) + if row < count - 1: + # 检查下一行是否也在选中列表中 + next_item = self.list_widget.item(row + 1) + if next_item not in selected: + self.list_widget.takeItem(row) + self.list_widget.insertItem(row + 1, item) + item.setSelected(True) + + # 滚动到选中的第一行 + self._scroll_to_first_selected() + + def _scroll_to_first_selected(self): + """滚动到选中的第一行""" + selected = self.list_widget.selectedItems() + if selected: + first_row = min(self.list_widget.row(item) for item in selected) + self.list_widget.scrollToItem(self.list_widget.item(first_row)) def get_show_fields(self) -> list[str]: - """获取当前勾选的字段集合""" - return [field for field, cb in self.checks.items() if cb.isChecked()] + """获取当前勾选的字段列表(按显示顺序)""" + result = [] + for i in range(self.list_widget.count()): + item = self.item(i) + if item.checkState() == Qt.Checked: + result.append(item.text()) + return result + + def set_field_checked(self, field: str, checked: bool): + """设置指定字段的勾选状态""" + for i in range(self.list_widget.count()): + item = self.item(i) + if item.text() == field: + item.setCheckState(Qt.Checked if checked else Qt.Unchecked) + break diff --git a/src/plugins/history/filter_dialog.py b/src/plugins/history/filter_dialog.py index 3e3ac81..1a6ef9d 100644 --- a/src/plugins/history/filter_dialog.py +++ b/src/plugins/history/filter_dialog.py @@ -15,9 +15,10 @@ QMessageBox, QSizePolicy, QHeaderView, - QDialog, + QWidget, ) +from shared_types.widgets import ConfirmDialog from .delegates import ComboBoxDelegate, EditableComboBoxDelegate, FilterValueDelegate from .models import HistoryData, LogicSymbol, CompareSymbol from .table_views import AutoEditTableView, FilterModel @@ -25,16 +26,18 @@ _translate = QCoreApplication.translate -class FilterDialog(QDialog): +class FilterDialog(ConfirmDialog): """过滤条件对话框""" def __init__(self, float_decimals: int = 2, parent=None): - super().__init__(parent) - self.setWindowTitle(_translate("Form", "过滤条件")) - self.resize(700, 300) self._float_decimals = float_decimals + super().__init__(parent, title=_translate("Form", "过滤条件")) + self.resize(700, 300) - layout = QVBoxLayout(self) + def _create_content(self): + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) self.table = AutoEditTableView() self.table.setModel(FilterModel(self)) @@ -55,6 +58,8 @@ def __init__(self, float_decimals: int = 2, parent=None): self._setup_delegates() self._connect_field_change_signal() + return widget + def _connect_field_change_signal(self): """当字段列改变时,更新值列的默认值""" self.table.model().dataChanged.connect(self._on_field_changed) diff --git a/src/plugins/history/main_widget.py b/src/plugins/history/main_widget.py index 83ff100..434bd1d 100644 --- a/src/plugins/history/main_widget.py +++ b/src/plugins/history/main_widget.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import cast -from PyQt5.QtCore import Qt, QCoreApplication, pyqtSignal +from PyQt5.QtCore import QCoreApplication, pyqtSignal from PyQt5.QtGui import QCloseEvent as _QCloseEvent from PyQt5.QtWidgets import ( QWidget, @@ -33,6 +33,7 @@ from .table_views import SortModel from .sort_dialog import SortDialog from .table_views import FilterModel +from shared_types.enums import BaseDiaPlayEnum _translate = QCoreApplication.translate @@ -56,6 +57,11 @@ def __init__( super().__init__(parent) self._db_path = db_path self._config_path = config_path + self._float_decimals = float_decimals + + # 存储过滤和排序条件数据(每次对话框确认后更新) + self._filter_rows: list[dict] = [] + self._sort_rows: list[dict] = [] self.setWindowTitle(_translate("Form", "历史记录")) self.resize(800, 600) @@ -76,10 +82,6 @@ def __init__( QSpacerItem(10, 10, QSizePolicy.Expanding, QSizePolicy.Minimum) ) - # 过滤和排序对话框 - self.filter_dialog = FilterDialog(float_decimals, self) - self.sort_dialog = SortDialog(self) - # 当前过滤/排序条件显示 self.filter_label = QLabel("") self.filter_label.setWordWrap(True) @@ -89,10 +91,6 @@ def __init__( # 表格 self.table = HistoryTable(self._get_show_fields(), db_path, self) - # 列设置对话框(需要在 table 创建之后) - self.columns_dialog = ColumnsDialog( - HistoryTable.HEADERS, self.table.showFields, self) - # 分页 limit_layout = QHBoxLayout() self.previous_button = QPushButton(_translate("Form", "上一页")) @@ -133,14 +131,14 @@ def set_filter_sort_state(self, filter_json: str, sort_json: str) -> None: try: filter_rows = json.loads(filter_json) if filter_rows: - self._set_filter_rows(filter_rows) + self._filter_rows = filter_rows except (json.JSONDecodeError, TypeError): pass try: sort_rows = json.loads(sort_json) if sort_rows: - self._set_sort_rows(sort_rows) + self._sort_rows = sort_rows except (json.JSONDecodeError, TypeError): pass @@ -149,12 +147,9 @@ def set_filter_sort_state(self, filter_json: str, sort_json: str) -> None: def _connect_signals(self): self.query_button.clicked.connect(self._on_query) - self.filter_button.clicked.connect(self.filter_dialog.show) - self.sort_button.clicked.connect(self.sort_dialog.show) - self.columns_button.clicked.connect(self.columns_dialog.show) - self.filter_dialog.finished.connect(lambda: self._on_query()) - self.sort_dialog.finished.connect(lambda: self._on_query()) - self.columns_dialog.finished.connect(self._on_columns_changed) + self.filter_button.clicked.connect(self._show_filter_dialog) + self.sort_button.clicked.connect(self._show_sort_dialog) + self.columns_button.clicked.connect(self._show_columns_dialog) self.previous_button.clicked.connect( lambda: self.page_spin.setValue(self.page_spin.value() - 1) ) @@ -165,6 +160,63 @@ def _connect_signals(self): self.page_spin.valueChanged.connect(self.load_data) self.table.show_fields_changed.connect(self.show_fields_changed) + def _show_filter_dialog(self): + """显示过滤对话框,确认后执行查询""" + filter_dialog = FilterDialog(self._float_decimals, self) + # 从已有条件数据恢复到对话框 + if self._filter_rows: + model = cast(FilterModel, filter_dialog.table.model()) + for row_data in self._filter_rows: + row = model.rowCount() + model.insertRow(row) + model.setData(model.index(row, FilterModel.COL_LBRACKET), + row_data.get("left_bracket")) + model.setData(model.index(row, FilterModel.COL_FIELD), + row_data.get("field")) + model.setData(model.index(row, FilterModel.COL_COMPARE), + row_data.get("compare")) + model.setData(model.index(row, FilterModel.COL_VALUE), + row_data.get("value")) + model.setData(model.index(row, FilterModel.COL_RBRACKET), + row_data.get("right_bracket")) + model.setData(model.index(row, FilterModel.COL_LOGIC), + row_data.get("logic")) + if filter_dialog.exec_(): + # 保存过滤条件数据 + model = cast(FilterModel, filter_dialog.table.model()) + self._filter_rows = [model.get_row_data(row) for row in range(model.rowCount())] + self._on_query() + + def _show_sort_dialog(self): + """显示排序对话框,确认后执行查询""" + sort_dialog = SortDialog(self) + # 从已有条件数据恢复到对话框 + if self._sort_rows: + model = cast(SortModel, sort_dialog.sort_table.model()) + for row_data in self._sort_rows: + row = model.rowCount() + model.insertRow(row) + model.setData(model.index(row, SortModel.COL_FIELD), + row_data.get("field")) + model.setData(model.index(row, SortModel.COL_ORDER), + row_data.get("order")) + if sort_dialog.exec_(): + # 保存排序条件数据 + model = cast(SortModel, sort_dialog.sort_table.model()) + self._sort_rows = [model.get_row_data(row) for row in range(model.rowCount())] + self._on_query() + + def _show_columns_dialog(self): + """显示列设置对话框,确认后应用更改""" + columns_dialog = ColumnsDialog( + HistoryTable.HEADERS, self.table.showFields, self) + if columns_dialog.exec_(): + new_fields = columns_dialog.get_show_fields() + self.table.showFields = new_fields + self.table.model.update_show_fields(new_fields) + self.show_fields_changed.emit(json.dumps( + list(new_fields), ensure_ascii=False)) + def _on_query(self): if self.page_spin.value() > 1: self.page_spin.setValue(1) @@ -182,14 +234,6 @@ def _get_show_fields(self) -> list[str]: with open(self._config_path, "r") as f: return list(json.load(f)) - def _on_columns_changed(self): - """列设置对话框关闭后应用更改""" - new_fields = self.columns_dialog.get_show_fields() - self.table.showFields = new_fields - self.table.model.update_show_fields(new_fields) - self.show_fields_changed.emit(json.dumps( - list(new_fields), ensure_ascii=False)) - def load_data(self): if not self._db_path.exists(): QMessageBox.warning(self, "错误", "历史记录数据库不存在") @@ -199,8 +243,8 @@ def load_data(self): conn = sqlite3.connect(self._db_path) conn.row_factory = sqlite3.Row cursor = conn.cursor() - filter_str = self.filter_dialog.gen_filter_str() - order_str = self.sort_dialog.gen_order_str() + filter_str = self._gen_filter_str() + order_str = self._gen_order_str() sql = "SELECT *, COUNT(*) OVER() AS total_count FROM history" if filter_str: sql += " WHERE " + filter_str @@ -291,70 +335,185 @@ def _format_sort_display(self, sort_rows: list[dict]) -> str: parts.append(f"{field} {order}") return ", ".join(parts) - def _get_filter_rows(self) -> list[dict]: - """获取过滤表格的所有行数据""" - model = cast(FilterModel, self.filter_dialog.table.model()) - rows = [] - for row in range(model.rowCount()): - rows.append(model.get_row_data(row)) - return rows - - def _get_sort_rows(self) -> list[dict]: - """获取排序表格的所有行数据""" - model = cast(SortModel, self.sort_dialog.sort_table.model()) - rows = [] - for row in range(model.rowCount()): - rows.append(model.get_row_data(row)) - return rows - - def _set_filter_rows(self, rows: list[dict]) -> None: - """恢复过滤表格的行数据""" - model = self.filter_dialog.table.model() - model.removeRows(0, model.rowCount()) - for row_data in rows: - row = model.rowCount() - model.insertRow(row) - model.setData(model.index(row, FilterModel.COL_LBRACKET), - row_data.get("left_bracket")) - model.setData(model.index(row, FilterModel.COL_FIELD), - row_data.get("field")) - model.setData(model.index(row, FilterModel.COL_COMPARE), - row_data.get("compare")) - model.setData(model.index(row, FilterModel.COL_VALUE), - row_data.get("value"), Qt.EditRole) - model.setData(model.index(row, FilterModel.COL_RBRACKET), - row_data.get("right_bracket")) - model.setData(model.index(row, FilterModel.COL_LOGIC), - row_data.get("logic")) - - def _set_sort_rows(self, rows: list[dict]) -> None: - """恢复排序表格的行数据""" - model = self.sort_dialog.sort_table.model() - model.removeRows(0, model.rowCount()) - for row_data in rows: - row = model.rowCount() - model.insertRow(row) - model.setData(model.index(row, SortModel.COL_FIELD), - row_data.get("field")) - model.setData(model.index(row, SortModel.COL_ORDER), - row_data.get("order")) + def _gen_filter_str(self) -> str | None: + """根据 _filter_rows 生成过滤 SQL 语句""" + if not self._filter_rows: + return "" + + filter_str = "" + left_count = 0 + right_count = 0 + + for row, data in enumerate(self._filter_rows): + field_value_type = HistoryData.get_field_value(data.get("field") or "") + + left_bracket = data.get("left_bracket") or "" + field = data.get("field") or "" + compare_text = data.get("compare") or "" + value = data.get("value") or "" + right_bracket = data.get("right_bracket") or "" + logic_text = data.get("logic") or "" + + if not field or not compare_text: + continue + + compare = CompareSymbol.from_display_name(compare_text) + logic = LogicSymbol.from_display_name(logic_text).to_sql + + if left_bracket == "(": + left_count += 1 + elif left_bracket == "((": + left_count += 2 + if right_bracket == ")": + right_count += 1 + elif right_bracket == "))": + right_count += 2 + + if right_count > left_count: + QMessageBox.warning( + self, "错误", f"第{row}行 右括号数量大于左括号数量,请检查" + ) + return None + + # 处理值 + if isinstance(field_value_type, BaseDiaPlayEnum) and compare.value not in (CompareSymbol.Contains, CompareSymbol.NotContains): + enum_cls = field_value_type.__class__ + for e in enum_cls: + if e.display_name == value: + value = str(e.value) + break + elif compare.value in (CompareSymbol.Contains, CompareSymbol.NotContains): + if isinstance(field_value_type, (int, float)): + values = value.split(",") + for v in values: + if not v.replace("-", "").replace(".", "").isdigit(): + QMessageBox.warning( + self, "错误", f"第{row}行 {v} 不是数字" + ) + return None + value = ",".join(v for v in values) + elif isinstance(field_value_type, datetime): + values = value.split(",") + parsed_values = [] + for v in values: + v = v.strip() + if not v: + continue + try: + ts = int(float(v)) + parsed_values.append(str(ts)) + except ValueError: + try: + for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"): + try: + dt = datetime.strptime(v, fmt) + parsed_values.append( + str(int(dt.timestamp() * 1_000_000))) + break + except ValueError: + continue + else: + raise ValueError(f"无法解析日期: {v}") + except ValueError as e: + QMessageBox.warning( + self, "错误", f"第{row}行 {v} 不是合法的日期时间" + ) + return None + value = ",".join(parsed_values) if parsed_values else "" + elif isinstance(field_value_type, BaseDiaPlayEnum): + enum_cls = field_value_type.__class__ + values = value.split(",") + parsed_values = [] + for v in values: + v = v.strip() + if not v: + continue + for e in enum_cls: + if e.display_name == v: + parsed_values.append(str(e.value)) + break + else: + QMessageBox.warning( + self, "错误", f"第{row}行 {v} 不是合法的枚举选项" + ) + return None + value = ",".join(parsed_values) if parsed_values else "" + else: + value = ",".join( + f"'{v}'" for v in value.split(",") if v.strip()) + value = f"({value})" if value else "()" + elif isinstance(field_value_type, datetime) and value: + try: + ts = int(float(value)) + value = str(ts) + except ValueError: + try: + for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"): + try: + dt = datetime.strptime(value, fmt) + value = str(int(dt.timestamp() * 1_000_000)) + break + except ValueError: + continue + else: + QMessageBox.warning( + self, "错误", f"第{row}行 {value} 不是合法的日期时间" + ) + return None + except ValueError: + QMessageBox.warning( + self, "错误", f"第{row}行 {value} 不是合法的日期时间" + ) + return None + elif value and not value.startswith("'"): + value = f"'{value}'" + + is_last = row == len(self._filter_rows) - 1 + filter_str += ( + f" {left_bracket} {field} {compare.to_sql} {value} {right_bracket} " + ) + if not is_last: + filter_str += logic + + if left_count != right_count: + QMessageBox.warning(self, "错误", "左括号数量和右括号数量不匹配,请检查") + return None + return filter_str + + def _gen_order_str(self) -> str: + """根据 _sort_rows 生成排序 SQL 语句""" + if not self._sort_rows: + return "" + + orders = [] + for row_data in self._sort_rows: + field = row_data.get("field") or "" + order_text = row_data.get("order") or "" + if not field: + continue + order_sql = "ASC" if order_text == _translate("Form", "升序") else "DESC" + orders.append(f"{field} {order_sql}") + + if orders: + return " ORDER BY " + ", ".join(orders) + return "" def _save_filter_sort_state(self, filter_str: str = "", order_str: str = "") -> None: """发射排序和过滤状态变化信号""" - filter_rows = self._get_filter_rows() - sort_rows = self._get_sort_rows() self.filter_sort_state_changed.emit( - json.dumps(filter_rows, ensure_ascii=False), json.dumps(sort_rows, ensure_ascii=False)) + json.dumps(self._filter_rows, ensure_ascii=False), + json.dumps(self._sort_rows, ensure_ascii=False) + ) # 更新过滤条件标签(易读格式) - filter_display = self._format_filter_display(filter_rows) + filter_display = self._format_filter_display(self._filter_rows) if filter_display: self.filter_label.setText(f"过滤: {filter_display}") else: self.filter_label.setText("过滤: 无") # 更新排序条件标签(易读格式) - sort_display = self._format_sort_display(sort_rows) + sort_display = self._format_sort_display(self._sort_rows) if sort_display: self.sort_label.setText(f"排序: {sort_display}") else: @@ -366,7 +525,7 @@ def closeEvent(self, event: _QCloseEvent): def set_float_decimals(self, decimals: int) -> None: """动态设置小数位数""" - self.filter_dialog.set_float_decimals(decimals) + self._float_decimals = decimals def restore_show_fields(self, show_fields_json: str) -> None: """恢复列显示配置""" @@ -376,8 +535,5 @@ def restore_show_fields(self, show_fields_json: str) -> None: fields = self.table.HEADERS self.table.showFields = list(fields) self.table.model.update_show_fields(self.table.showFields) - # 同步更新列设置对话框的勾选状态 - for field, cb in self.columns_dialog.checks.items(): - cb.setChecked(field in self.table.showFields) except (json.JSONDecodeError, TypeError): pass diff --git a/src/plugins/history/plugin.py b/src/plugins/history/plugin.py index e058a14..a24568d 100644 --- a/src/plugins/history/plugin.py +++ b/src/plugins/history/plugin.py @@ -335,6 +335,36 @@ def delete_record(self, record_id: int) -> bool: finally: conn.close() + def raw_query(self, sql: str, params: tuple = ()) -> list[dict[str, Any]]: + """ + 直接执行 SQL 查询 + + Args: + sql: SQL 查询语句(使用 ? 作为参数占位符) + params: 参数元组 + + Returns: + 字典列表 + """ + db_path = self.data_dir / "history.db" + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + try: + cursor.execute(sql, params) + rows = cursor.fetchall() + return [dict(row) for row in rows] + finally: + conn.close() + + def raw_query_one(self, sql: str, params: tuple = ()) -> dict[str, Any] | None: + """ + 执行 SQL 查询并返回单条结果 + """ + results = self.raw_query(sql, params) + return results[0] if results else None + def _on_config_changed(self, name: str, value: Any) -> None: if name == "float_decimals": self._widget.set_float_decimals(value) diff --git a/src/plugins/history/sort_dialog.py b/src/plugins/history/sort_dialog.py index 5b7d8b1..37d4b2d 100644 --- a/src/plugins/history/sort_dialog.py +++ b/src/plugins/history/sort_dialog.py @@ -11,9 +11,10 @@ QTableView, QSizePolicy, QHeaderView, - QDialog, + QWidget, ) +from shared_types.widgets import ConfirmDialog from .delegates import ComboBoxDelegate, EditableComboBoxDelegate from .models import HistoryData from .table_views import AutoEditTableView, SortModel @@ -21,15 +22,17 @@ _translate = QCoreApplication.translate -class SortDialog(QDialog): +class SortDialog(ConfirmDialog): """排序条件对话框""" def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle(_translate("Form", "排序条件")) + super().__init__(parent, title=_translate("Form", "排序条件")) self.resize(400, 300) - layout = QVBoxLayout(self) + def _create_content(self): + widget = QWidget() + layout = QVBoxLayout(widget) + layout.setContentsMargins(0, 0, 0, 0) self.sort_table = AutoEditTableView() self.sort_table.setModel(SortModel(self)) @@ -50,6 +53,8 @@ def __init__(self, parent=None): self._setup_delegates() + return widget + def _setup_delegates(self): """设置列代理""" self.sort_table.setItemDelegateForColumn( diff --git a/src/plugins/history/table_model.py b/src/plugins/history/table_model.py index 1ae9d1c..f243721 100644 --- a/src/plugins/history/table_model.py +++ b/src/plugins/history/table_model.py @@ -24,7 +24,8 @@ def __init__( self._data = data self._headers = headers self._show_fields = show_fields - self._visible_headers = [h for h in headers if h in show_fields] + # 直接使用 show_fields 的顺序,支持用户自定义列顺序 + self._visible_headers = [h for h in show_fields if h in headers] def rowCount(self, parent=None): return len(self._data) @@ -76,5 +77,6 @@ def update_data(self, data: list[HistoryData]): def update_show_fields(self, show_fields: list[str]): self.beginResetModel() self._show_fields = show_fields - self._visible_headers = [h for h in self._headers if h in show_fields] + # 直接使用 show_fields 的顺序 + self._visible_headers = [h for h in show_fields if h in self._headers] self.endResetModel() diff --git a/src/plugins/services/history.py b/src/plugins/services/history.py index 7aca270..efce91f 100644 --- a/src/plugins/services/history.py +++ b/src/plugins/services/history.py @@ -22,11 +22,17 @@ def _update(self): records = self._history.query_records(limit=100) for r in records: print(r.rtime, r.level, r.bbbv) # IDE 完整补全 + + # 直接执行 SQL(灵活查询) + stats = self._history.raw_query_one( + "SELECT COUNT(*) as count, AVG(rtime) as avg_time FROM history WHERE level = ?", + (1,) + ) """ from __future__ import annotations from dataclasses import dataclass -from typing import Protocol, runtime_checkable +from typing import Any, Protocol, runtime_checkable @dataclass(frozen=True, slots=True) @@ -94,3 +100,45 @@ def get_record_count(self, level: int | None = None) -> int: def get_last_record(self) -> GameRecord | None: """获取最近一条记录""" ... + + def raw_query(self, sql: str, params: tuple = ()) -> list[dict[str, Any]]: + """ + 直接执行 SQL 查询(灵活查询) + + Args: + sql: SQL 查询语句(使用 ? 作为参数占位符) + params: 参数元组 + + Returns: + 字典列表,每行一个字典 + + Example: + records = history.raw_query( + "SELECT * FROM history WHERE level = ? AND rtime < ? ORDER BY rtime LIMIT 10", + (1, 60.0) + ) + for r in records: + print(r["rtime"], r["level"]) + """ + ... + + def raw_query_one(self, sql: str, params: tuple = ()) -> dict[str, Any] | None: + """ + 执行 SQL 查询并返回单条结果 + + Args: + sql: SQL 查询语句 + params: 参数元组 + + Returns: + 单条记录字典,或 None + + Example: + stats = history.raw_query_one( + "SELECT COUNT(*) as count, AVG(rtime) as avg_time FROM history WHERE level = ?", + (1,) + ) + if stats: + print(f"初级: {stats['count']}局, 平均{stats['avg_time']:.2f}s") + """ + ... diff --git a/src/shared_types/widgets/__init__.py b/src/shared_types/widgets/__init__.py index 4594118..56f614d 100644 --- a/src/shared_types/widgets/__init__.py +++ b/src/shared_types/widgets/__init__.py @@ -4,7 +4,9 @@ 存放项目中共用的自定义控件 """ from .editable_combo_box import EditableComboBox +from .confirm_dialog import ConfirmDialog __all__ = [ "EditableComboBox", + "ConfirmDialog", ] diff --git a/src/shared_types/widgets/confirm_dialog.py b/src/shared_types/widgets/confirm_dialog.py new file mode 100644 index 0000000..aa1138e --- /dev/null +++ b/src/shared_types/widgets/confirm_dialog.py @@ -0,0 +1,109 @@ +""" +通用对话框基类 + +提供确认/取消按钮组合的对话框基类,使用 QDialogButtonBox +""" + +from __future__ import annotations + +from PyQt5.QtCore import QCoreApplication, Qt +from PyQt5.QtWidgets import ( + QDialog, + QVBoxLayout, + QDialogButtonBox, + QWidget, +) + +_translate = QCoreApplication.translate + + +class ConfirmDialog(QDialog): + """ + 带确认/取消按钮的对话框基类 + + 使用 Qt 内置的 QDialogButtonBox 提供标准按钮。 + + Usage: + class MyDialog(ConfirmDialog): + def _create_content(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + # 添加自定义控件... + return widget + + def _on_accepted(self): + # 处理确认逻辑 + pass + """ + + def __init__( + self, + parent=None, + title: str = "", + buttons: QDialogButtonBox.StandardButtons = QDialogButtonBox.Ok | QDialogButtonBox.Cancel, + ): + """ + Args: + parent: 父控件 + title: 对话框标题 + buttons: 标准按钮组合(默认 Ok | Cancel) + """ + super().__init__(parent) + self.setWindowTitle(title or _translate("Dialog", "对话框")) + self._buttons = buttons + + self._setup_ui() + + def _setup_ui(self): + """设置 UI 布局""" + layout = QVBoxLayout(self) + + # 内容区域(子类实现) + content = self._create_content() + if content: + layout.addWidget(content) + + # 使用 QDialogButtonBox(Qt 内置标准按钮框) + self.button_box = QDialogButtonBox(self._buttons) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + layout.addWidget(self.button_box) + + def _create_content(self) -> QWidget | None: + """ + 创建内容区域(子类重写) + + Returns: + 内容控件,或 None + """ + return None + + def _on_accepted(self): + """确认时调用(子类重写)""" + pass + + def _on_rejected(self): + """取消时调用(子类重写)""" + pass + + def accept(self): + """确认""" + self._on_accepted() + super().accept() + + def reject(self): + """取消""" + self._on_rejected() + super().reject() + + def button(self, standard_button: QDialogButtonBox.StandardButton) -> QWidget | None: + """ + 获取指定标准按钮 + + Args: + standard_button: 标准按钮类型 + + Returns: + 按钮控件,或 None + """ + return self.button_box.button(standard_button)