使用PySide6/PyQt6实现Python跨平台通用列表页面的基类设计

我在随笔《使用PySide6/PyQt6实现Python跨平台GUI框架的开发》中介绍过PySide6/PyQt6 框架架构的整体设计,本篇随笔继续深入探讨框架的设计开发工作,主要针对通用列表页面的基类设计进行介绍,分析基类的各个模块的功能,以及介绍如何抽象一些公用的逻辑,实现对子类页面的简化处理。

1、通用列表界面的设计

大多数情况下,界面的表现逻辑可以使用不同的规则进行抽象,如自定义控件、列表界面、弹出对话框界面等,我们把它抽象出来进行不同的处理。子类界面进行一定程度的扩张即可获得更好的使用、更简化的代码。

对于列表和对话框界面的封装,能够简化对泛型模型数据的统一处理,因此可以简化继承子类的代码,提供代码维护开发和维护的效率。

其中用户管理界面的列表界面如下所示。

树列表或者表格控件,右键可以弹出相关的右键菜单

列表包含有有树形列表、条件查询框、通用条件(查询、新增、编辑、删除、导出)等、列表展示、分页导航、右键菜单等内容。这些都是在基类中进行了统一的抽象处理,子类根据需要调整属性或重写相关函数即可实现个性化的界面定义。

2、通用列表界面的分析处理

如果我们需要设计通用列表界面窗体的基类,那么我们需要尽可能的减少子类的代码,把常用的功能封装在基类里面,以及特殊的内容,可以通过封装逻辑,下发具体实现给子类进行重写实现即可。

前面我们介绍过,常用列表包含有有树形列表、条件查询框、通用条件(查询、新增、编辑、删除、导出)等、列表展示、分页导航、右键菜单等内容,另外还有详细的需要接受一些子类的列表字段显示和中文参考,以及表格处理的功能按钮的权限控制等方面。

由于我们需要子类传入的相关DTO类型,因此我们定义泛型类型来传入处理。

基类定义如下所示。

复制代码
ModelType = TypeVar("ModelType")  # 定义泛型基类

# 创建泛型基类 BaseListFrame ,并继承 QMainWindow
class BaseListFrame(QMainWindow, Generic[ModelType]):

另外我们初始化函数,需要接受子类的一些信息,用于对显示内容进行精准的控制处理,因此构造函数__init__里面定义好相关的参数,如下所示。

复制代码
# 创建泛型基类 BaseListFrame ,并继承 QMainWindow
class BaseListFrame(QMainWindow, Generic[ModelType]): 
   def __init__(
        self,
        parent,
        model: Optional[ModelType] = None,
        display_columns: str = display_columns,
        column_mapping: dict = column_mapping,
        items_per_page: int = items_per_page,
        EVT_FLAGS: EventFlags = EVT_FLAGS,
        show_menu_tips: bool = show_menu_tips,
        menu_tips: str = DEFAULT_MENU_TIPS,
        use_left_panel: bool = False,
        column_widths={"id": 50},
        plugins=None,
    ):
        """初始化窗体

        :param parent: 父窗口
        :param model: 实体类
        :param display_columns: 显示的字段名称,逗号分隔,如:id,name,customid,authorize,note
        :param column_mapping: 列名映射(字段名到显示名的映射)dict格式:{"name": "显示名称"}
        :param items_per_page: 每页显示的行数
        :param EVT_FLAGS: 设置可以显示的操作按钮
        :param show_menu_tips: 是否显示提示信息
        :param menu_tips: 设置菜单提示信息
        :param use_left_panel: 是否使用树控件
        :param column_widths: Grid列的宽度设置
        """

1)树列表的控制和实现

我们在init函数里面,主要通过_create_content()函数进行创建界面元素。

复制代码
    def _create_content(self):
        """创建主要内容面板"""

        # 创建左侧树控件
        if self.use_left_panel:
            self._merge_tree_panel()

        # "创建右侧主要内容面板
        content_panel = self._create_content_panel()
        self.setCentralWidget(content_panel)

它负责判断是否需要展示树列表,如果打开显示树的开关,就根据树形列表的集合进行构建左侧的树列表显示。

复制代码
    def _merge_tree_panel(self):
        """合并左侧树控件"""
        tree_panels = self.create_tree_panels()
        if tree_panels is None or len(tree_panels.keys()) == 0:
            return

        self.dock_widget = dock_widget = QDockWidget(self)
        dock_widget.setWindowTitle("")  # 左侧树控件

        # 创建 QTabWidget,并存储self.tree_tab_widget
        self.tree_tab_widget = tree_tab_widget = QTabWidget()
        tree_tab_widget.setTabPosition(QTabWidget.TabPosition.South)

        # 添加树控件到 QTabWidget
        for name, panel intree_panels.items():
            tree_tab_widget.addTab(panel, name)

        dock_widget.setWidget(tree_tab_widget)

        # 防止面板浮动
        dock_widget.setFloating(False)
        # 禁止关闭按钮
        dock_widget.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures)

        # 将 QDockWidget 添加到主窗口的左侧
        self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dock_widget)

上面代码就是在左侧构建一个 QDockWidget 的停靠区域,我们把所有树列表的集合放到其中容器的 QTabWidget 里面即可。

在抽象的父类里面,我们只需要给出一个默认的 create_tree_panels 实现函数即可,如下所示。

复制代码
    def create_tree_panels(self) -> dict[str, QWidget]:
        """子类重写该方法,创建左侧树列表面板-可以多个树列表"""

        tree_panels: dict[str, QWidget] = {}
        # 创建树控件
        # tree_panels["Tab 1"] = QLabel(self)
        # tree_panels["Tab 2"] = QLabel(self)
        return tree_panels

create_tree_panels 具体的实现我们是留给子类进行重写的,因为我们不清楚具体的显示,但是我们可以把它们逻辑上组合起来即可。

如对于上面展示的用户列表界面,这部分create_tree_panels 的代码实现如下所示。

复制代码
    def create_tree_panels(self) -> dict[str, QWidget]:
        """子类重写该方法,创建左侧树列表面板-可以多个树列表"""
        dict = {}

        self.tree_dept =ctrl.MyTreePanel(
            self,
            on_tree_selected_handler=self.OnDeptTreeSelected,
            expand_all=True,
            on_menu_handler=self.OnDeptTreeMenu,
        )
        self.tree_role =ctrl.MyTreePanel(
            self,
            on_tree_selected_handler=self.OnRoleTreeSelected,
            expand_all=True,
            on_menu_handler=self.OnRoleTreeMenu,
        )
        dict["按组织机构查看"] = self.tree_dept
        dict["按角色查看"] = self.tree_role

        return dict

其中ctrl.MyTreePanel的控件是我们自定义的一个树列表控件,用于减少重复性的代码,抽象一个树列表的展示,有利于我们保持更好的控制,统一界面效果的处理。

在子类的构造函数处理上,我们只需要设置参数 use_left_panel = True,并且实现 create_tree_panels 函数即可。

2)查询条件控件内容

介绍完毕树列表的处理,我们再次来到基类的界面构建处理函数上。

复制代码
    def _create_content(self):
        """创建主要内容面板"""

        # 创建左侧树控件
        if self.use_left_panel:
            self._merge_tree_panel()

        # "创建右侧主要内容面板
        content_panel = self._create_content_panel()
        self.setCentralWidget(content_panel)

其中的**_create_content_panel**是我们构建主查询面板内容的,其中包括输入条件展示、常见按钮显示、以及列表、分页栏目等。

复制代码
    def _create_content_panel(self) -> QWidget:
        """创建右侧主要内容面板"""
        panel = QWidget(self)
        # 创建一个垂直布局
        main_layout = QVBoxLayout()

        # 创建一个折叠的查询条件框
        search_bar = self._create_search_bar(panel)
        main_layout.addWidget(search_bar)

        # 创建显示数据的表格
        table_widget = self._create_grid(panel)
        main_layout.addWidget(table_widget, 1)  # 拉伸占用全部高度

        # 创建一个分页控件
        self.pager_bar = ctrl.MyPager(panel, self.items_per_page, self.update_grid)
        main_layout.addWidget(self.pager_bar)

        # 设置布局
        panel.setLayout(main_layout)
        return panel

上面标注特殊的代码,就是对不同模块的逻辑进行分离实现,从而让我们关注点集中一些。其中的create_search_bar里面,主要封装了查询条件框、常规按钮、自定义按钮等内容。

复制代码
    def _create_search_bar(self, parent: QWidget = None) -> QWidget:
        """创建折叠的查询条件框,包含查询条件输入框和常规按钮"""
        panel = QWidget(parent)
        # 创建一个垂直布局
        layout = QVBoxLayout()
        panel.setLayout(layout)

        # 添加查询条件控件
        input_sizer = self.CreateConditionsWithSizer(panel)
        layout.addLayout(input_sizer, 0)
        layout.addSpacing(5)  # 增加间距

        # 添加常规按钮
        btns_sizer = self._CreateCommonButtons(panel)
        # 自定义按钮
        self.CreateCustomButtons(panel, btns_sizer)
        layout.addLayout(btns_sizer, 0)

        return panel

我在基类窗体的抽象类里面,定义了默认的布局规则,如下代码所示。

复制代码
    def CreateConditionsWithSizer(self, parent: QWidget = None) -> QGridLayout:
        """子类可重写该方法,创建折叠面板中的查询条件,包括布局 QGridLayout"""
        layout = QGridLayout()
        layout.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
        layout.setSpacing(5)  # 增加间距

        # 统一处理查询条件控件的添加,使用默认的布局方式
        cols = 4 * 2
        list = self.CreateConditions(parent)

        for i in range(len(list)):
            control: QWidget = list[i]
            control.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
            layout.addWidget(control, i // cols, i % cols)

        return layout

    def CreateConditions(self, parent: QWidget = None) -> list[QWidget]:
        """子类可重写该方法,创建折叠面板中的查询条件输入框控件,不包括布局,使用默认的布局方式 QGridLayout"""
        list = [QWidget]
        # 示例代码:
        lblName = QLabel("名称:")
        self.txtName = ctrl.MyTextCtrl(parent, "请输入名称")

        list.append(lblName)
        list.append(self.txtName)

        return list

如果我们不改变布局,那么我们主要实现 CreateConditions函数即可。这个函数也是比较简单的,构建所需的输入几个条件即可。

如对于简单的客户信息界面,它的条件输入框里面就几个条件。

我们根据上面的界面效果,可以看到客户窗体子类实现 CreateConditions 函数的代码如下所示。

复制代码
    def CreateConditions(self, parent: QWidget = None) -> list[QWidget]:
        """创建折叠面板中的查询条件输入框控件"""
        # 创建控件,不用管布局,交给CreateConditionsWithSizer控制逻辑
        # 默认的QGridLayout 为4*2=8列,每列间隔5px
        self.txtName = ctrl.MyTextCtrl(parent)
        self.txtAge = ctrl.MyNumericRange(parent)
        self.txtCustomerType = ctrl.MyComboBox(parent)

        # ControlUtil 可以方便的创建文本标签和控件的组合,并返回所有的控件列表
        util = ControlUtil(parent)
        util.add_control("姓名:", self.txtName)
        util.add_control("年龄:", self.txtAge)
        util.add_control("客户类型:", self.txtCustomerType)

        return util.get_controls()

这样,具体实现部分,对于WxPython和PySide6/PyQt6来说,代码都是差不多的,因为我们用了自定义用户控件类,并使用辅助函数,让它们和标签更好的粘合起来。

对于自定义控件,我们对其封装,使之能够在开发使用习惯上更一致,下面是我们根据需要对常见的原生控件进行一些自定义控件的封装列表。

对于常规的按钮,我们根据权限集合进行判断是否显示即可,自定义按钮则留给子类进一步实现。

复制代码
        # 添加常规按钮
        btns_sizer = self._CreateCommonButtons(panel)
        # 自定义按钮
        self.CreateCustomButtons(panel, btns_sizer)

对于常规的按钮,代码如下所示。

而自定义按钮的处理,我们留给子类实现,父类给出一个默认的函数即可。

复制代码
    def CreateCustomButtons(self, parent: QWidget, btns_sizer: QHBoxLayout) -> None:
        """子类可重写该方法,创建折叠面板中的自定义按钮"""
        # 增加按钮
        pass

3)表格数据显示

我们回到前面介绍的代码。

复制代码
    def _create_content_panel(self) -> QWidget:
        """创建右侧主要内容面板"""
        panel = QWidget(self)
        # 创建一个垂直布局
        main_layout = QVBoxLayout()

        # 创建一个折叠的查询条件框
        search_bar = self._create_search_bar(panel)
        main_layout.addWidget(search_bar)

        # 创建显示数据的表格
        table_widget = self._create_grid(panel)
        main_layout.addWidget(table_widget, 1)  # 拉伸占用全部高度

        # 创建一个分页控件
        self.pager_bar = ctrl.MyPager(panel, self.items_per_page, self.update_grid)
        main_layout.addWidget(self.pager_bar)

        # 设置布局
        panel.setLayout(main_layout)
        return panel

其中 _create_grid就是我们创建表格内容的逻辑函数了,它负责创建一个QTableView 元素进行展示,表格数据的绑定,通过只定义模型MyTableModel 来绑定界面显示的。

复制代码
    def _create_grid(self, parent: QWidget) ->QTableView:
        """创建显示数据的表格"""

        self.total_count: int = 0

        self.table_model = ctrl.MyTableModel(
            self.data,
            self.display_columns,
            self.column_mapping,
            primary_key="id",
            column_widths=self.column_widths,
            replace_values_handler=self.replace_values,  # 替换内容函数
            forground_color_handler=self.paint_foreground,  # 前景色渲染函数
        )
        self.table_view = QTableView(parent)
        self.table_view.setModel(self.table_model)

        self._set_grid_options()

        # 绑定行选中事件
        self.table_view.selectionModel().selectionChanged.connect(self.on_row_selected)

        # 异步绑定双击行事件
        if self.has_edit or self.has_view:
            self.table_view.doubleClicked.connect(self.on_row_double_clicked)

表格头部排序、右键菜单、表格特殊的选中和内容转义、背景色处理、导出Excel、导出PDF、打印预览等,我能都可以通过对表格的一些属性或者方法进行跟踪处理即可实现。这里由于篇幅原因,不在深入探讨。

4)分页信息展示

对于分页内容,表格显示是不负责的,因此我们需要根据模型对象,构建一个分页控件来显示,把它剥离基类列表的主界面,有利于减少我们的关注点分散,也有利于重用控件。

前面的逻辑代码中。

复制代码
    def _create_content_panel(self) -> QWidget:
        """创建右侧主要内容面板"""
        panel = QWidget(self)
        # 创建一个垂直布局
        main_layout = QVBoxLayout()

        # 创建一个折叠的查询条件框
        search_bar = self._create_search_bar(panel)
        main_layout.addWidget(search_bar)

        # 创建显示数据的表格
        table_widget = self._create_grid(panel)
        main_layout.addWidget(table_widget, 1)  # 拉伸占用全部高度

        # 创建一个分页控件
        self.pager_bar =ctrl.MyPager(panel, self.items_per_page, self.update_grid)
        main_layout.addWidget(self.pager_bar)

        # 设置布局
        panel.setLayout(main_layout)
        return panel

分页控件是独立的一个用户控件。

复制代码
class MyPager(QWidget):
    """列表的分页控件"""

    def __init__(self, parent=None, items_per_page=10, on_update=None, total_count=0):
        """初始化

        :param parent: 父控件
        :param items_per_page: 每页的行数
        :param on_update: 查询数据的回调函数,为异步函数
        :param total_count: 总记录数
        """

        self.items_per_page = items_per_page
        self.total_count = total_count
        self.total_pages = (total_count + items_per_page - 1) // items_per_page
        self.current_page = 0
        self.on_update = on_update

        super().__init__(parent)

通过有效的隔离,使得我们每次只需要关注特定部分的处理,而具体的逻辑由基类统一控制,特殊的具体实现交给子类重写基类函数即可。

完成了上面的处理后,我们发现业务模块的子类需要实现的内容比较少了,大多数交给抽象父类实现了。

5)数据的初始化处理

完成了界面元素的创建后,我们还需要再基类中统一一些数据初始化的函数,如我们在构造函数里面创建好内容后,调用了init_ui的函数初始化界面元素。

复制代码
# 创建泛型基类 BaseListFrame ,并继承 QMainWindow
class BaseListFrame(QMainWindow, Generic[ModelType]):
    """列表窗口的基类定义"""def __init__(
        self,
        parent,
        model: Optional[ModelType] = None,
        display_columns: str = display_columns,
        column_mapping: dict = column_mapping,
        items_per_page: int = items_per_page,
        EVT_FLAGS: EventFlags = EVT_FLAGS,
        show_menu_tips: bool = show_menu_tips,
        menu_tips: str = DEFAULT_MENU_TIPS,
        use_left_panel: bool = False,
        column_widths={"id": 50},
        plugins=None,
    ):
        """初始化窗体

        :param parent: 父窗口
        :param model: 实体类
        :param display_columns: 显示的字段名称,逗号分隔,如:id,name,customid,authorize,note
        :param column_mapping: 列名映射(字段名到显示名的映射)dict格式:{"name": "显示名称"}
        :param items_per_page: 每页显示的行数
        :param EVT_FLAGS: 设置可以显示的操作按钮
        :param show_menu_tips: 是否显示提示信息
        :param menu_tips: 设置菜单提示信息
        :param use_left_panel: 是否使用树控件
        :param column_widths: Grid列的宽度设置
        """
        super().__init__(parent)
        # 日志对象
        self.log = settings.log.get_logger()

        # 初始化属性
        self.model = model
        self.display_columns = display_columns  # 显示的字段名称,逗号分隔,如:id,name
        self.column_mapping = column_mapping  # 列名映射
        self.items_per_page = items_per_page  # 每页显示的行数
        self.EVT_FLAGS = EVT_FLAGS  # 设置可以显示的操作按钮
        self.show_menu_tips = show_menu_tips  # 是否显示提示信息
        self.menu_tips = menu_tips  # 设置菜单提示信息
        self.use_left_panel = use_left_panel  # 是否使用树控件
        self.column_widths = column_widths  # Grid列的宽度设置
        self.plugins = plugins or {}  # 单元格的渲染列表,格式:{"列名称": 插件实例}
        self.columns_permit = {}  # 字段权限
        self.total_count = 0  # 记录总数
# 创建主要内容面板
        self._create_content()# 调度异步任务, 使用@asyncSlot()装饰器后,你可以像同步函数一样调用异步方法
        self.init_ui()

    @asyncSlot()
    async definit_ui(self):
        """初始化界面"""
        # 使用 @asyncSlot 装饰器后,你可以像同步函数一样调用异步方法,Qt 会自动管理异步任务的调度和执行,
        # 不需要显式使用 await 或者 asyncio.create_task 来启动异步任务。
        # 如果你在子类中重写了 init_ui,你仍然需要在子类中显式地添加 @asyncSlot() 装饰器。
        # 在子类中,Python 会将其视为新的方法定义,因此你必须在子类中的方法上再次应用 @asyncSlot() 装饰器来确保它仍然被处理为异步槽。

        await self.init_dict_items()
        await self.init_treedata()
        await self.update_grid()

    async def init_dict_items(self):
        """初始化字典数据-子类可重写"""
        # await self.txtCustomerType.bind_dictType("客户类型")
        pass

    async def init_treedata(self):
        """初始化树控件数据-子类可重写"""
        pass

    async def update_grid(self) -> None:
        """更新表格的内容"""

        # 查询数据
        await self.OnQuery()

        # 获取当前用户有权限查看的列
        self.columns_permit = await self.get_columns_permit()
        # 更新表格数据
        self.table_model.UpdateData(self.data, self.columns_permit)
        # 更新页码信息
        self._update_pager()

而各个子类负责各自模块内容的初始化即可。

3、子类列表界面代码分析

由于父类已经抽象了很多相关的元素创建、数据初始化的逻辑函数,因此子类根据需要重写函数实现即可。

如对于简单的业务表,客户信息表,它的子类只需要实现下面几个函数即可。


CreateConditions函数负责查询条件的构建,前面介绍过。

复制代码
    def CreateConditions(self, parent: QWidget = None) -> list[QWidget]:
        """创建折叠面板中的查询条件输入框控件"""
        # 创建控件,不用管布局,交给CreateConditionsWithSizer控制逻辑
        # 默认的QGridLayout 为4*2=8列,每列间隔5px

        self.txtName = ctrl.MyTextCtrl(parent)
        self.txtAge = ctrl.MyNumericRange(parent)
        self.txtCustomerType = ctrl.MyComboBox(parent)

        # ControlUtil 可以方便的创建文本标签和控件的组合,并返回所有的控件列表
        util = ControlUtil(parent)
        util.add_control("姓名:", self.txtName)
        util.add_control("年龄:", self.txtAge)
        util.add_control("客户类型:", self.txtCustomerType)

        return util.get_controls()

OnQuery函数负责提取输入条件,并提交服务端获取数据返回。

复制代码
    async def OnQuery(self):
        """子类实现-发送查询请求, 需设置self.data,self.total_count"""

        # 获取默认查询参数,包括skipCount,maxResultCount,sorting
        params = self.GetDefaultParams()
        # 新的数据,可以从控件获取,也可以是动态生成的
        search_params = { "name": self.txtName.GetValue()}
        ****#其他条件# 将 search_params 合并到 params 中
        params.update(search_params)

        # 发送查询请求
        data = await api.GetList(params)
        if data.success:
            result = data.result
            self.data = result.items
            self.total_count = result.totalCount

而OnAdd用于打开新增对话框。

复制代码
    def OnAdd(self) -> None:
        """子类重写-打开新增对话框"""
        dlg = FrmCustomerEdit(self, columns_permit=self.columns_permit)
        if dlg.exec() == QDialog.DialogCode.Accepted:
            # 新增成功,刷新表格
            asyncio.run(self.update_grid())
        dlg.deleteLater()

而 OnEditById 用于编辑对话框的打开

复制代码
    def OnEditById(self, entity_id: Any | str):
        """子类重写-根据主键值打开编辑对话框"""
        # 使用列表窗体获得的字段权限
        dlg = FrmCustomerEdit(self, entity_id, columns_permit=self.columns_permit)
        # 获取对话框结果
        if dlg.exec() == QDialog.DialogCode.Accepted:
            # 编辑成功,刷新表格
            asyncio.run(self.update_grid())
        dlg.deleteLater()

而删除对话框的处理,如下函数所示。

复制代码
    async def OnDeleteByIdList(self, id_list: List[Any | str]):
        """子类重写-根据主键值删除记录"""

        # 发送删除请求
        result = await api.DeleteByIds(id_list)
        # print(result)
        if result.success:
            # 删除成功,刷新表格
            await self.update_grid()
        else:
            error = result.errorInfo.message if result.errorInfo else "未知错误"
            MessageUtil.show_error("删除失败:%s" % error)

以上就是我们对于基类列表界面的抽象,和具体子类的一些个性化函数重写的处理,以便实现更好的逻辑抽象并保证具体个性化页面内容的处理。

对于不同的页面,我们可以公用同一个列表界面的基类,可以简化子类的很多操作,并能够统一整体的界面效果,提供更多通用的功能入口,是一种比较好的设计模式。

相关推荐
伍华聪1 个月前
使用PySide6/PyQt6实现Python跨平台表格数据分页打印预览处理
python开发
伍华聪2 个月前
使用PySide6/PyQt6实现Python跨平台GUI框架的开发
python开发
伍华聪3 个月前
一问一答学习PyQT6,对比WxPython和PyQt6的差异
python开发
伍华聪3 个月前
WxPython跨平台开发框架之使用PyInstaller 进行打包处理
python开发
伍华聪3 个月前
WxPython跨平台开发框架之模块字段权限的管理
python开发
伍华聪3 个月前
WxPython跨平台开发框架之动态菜单的管理和功能权限的控制
python开发
伍华聪3 个月前
WxPython跨平台开发框架之前后端结合实现附件信息的上传及管理
python开发
伍华聪3 个月前
WxPython跨平台开发框架之图标选择界面
python开发
伍华聪3 个月前
WxPython跨平台开发框架之列表数据的通用打印处理
python开发