我在随笔《利用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.<strong>create_tree_panels</strong>()
- 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 in<strong> tree_panels.items()</strong>:
- 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(<strong>Qt.DockWidgetArea.LeftDockWidgetArea</strong>, 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 <strong>create_tree_panels</strong>(self) -> dict[str, QWidget]:
- """子类重写该方法,创建左侧树列表面板-可以多个树列表"""
- dict = {}
- self.tree_dept =<strong> ctrl.MyTreePanel</strong>(
- self,
- on_tree_selected_handler=self.OnDeptTreeSelected,
- expand_all=True,
- on_menu_handler=self.OnDeptTreeMenu,
- )
- self.tree_role =<strong> ctrl.MyTreePanel</strong>(
- 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.<strong>_create_content_panel</strong>()
- self.setCentralWidget(content_panel)
复制代码 其中的_create_content_panel 是我们构建主查询面板内容的,其中包罗输入条件展示、常见按钮显示、以及列表、分页栏目等。- def _create_content_panel(self) -> QWidget:
- """创建右侧主要内容面板"""
- panel = QWidget(self)
- # 创建一个垂直布局
- main_layout = QVBoxLayout()
- # 创建一个折叠的查询条件框
- search_bar = self.<strong>_create_search_bar</strong>(panel)
- main_layout.addWidget(search_bar)
- # 创建显示数据的表格
- table_widget = self.<strong>_create_grid</strong>(panel)
- main_layout.addWidget(table_widget, 1) # 拉伸占用全部高度
- # 创建一个分页控件
- self.pager_bar = ctrl.<strong>MyPager</strong>(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 <strong>CreateConditions</strong>(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.<strong>_CreateCommonButtons</strong>(panel)
- # 自定义按钮
- self.<strong>CreateCustomButtons</strong>(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.<strong>_create_grid</strong>(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) -><strong> QTableView</strong>:
- """创建显示数据的表格"""
- self.total_count: int = 0
- self.table_model = ctrl.<strong>MyTableModel</strong>(
- 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 =<strong> ctrl.MyPager</strong>(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.<strong>_create_content</strong>()# 调度异步任务, 使用@asyncSlot()装饰器后,你可以像同步函数一样调用异步方法
- self.<strong>init_ui</strong>()
- @asyncSlot()
- async def<strong> init_ui</strong>(self):
- """初始化界面"""
- # 使用 @asyncSlot 装饰器后,你可以像同步函数一样调用异步方法,Qt 会自动管理异步任务的调度和执行,
- # 不需要显式使用 await 或者 asyncio.create_task 来启动异步任务。
- # 如果你在子类中重写了 init_ui,你仍然需要在子类中显式地添加 @asyncSlot() 装饰器。
- # 在子类中,Python 会将其视为新的方法定义,因此你必须在子类中的方法上再次应用 @asyncSlot() 装饰器来确保它仍然被处理为异步槽。
- await self.<strong>init_dict_items</strong>()
- await self.<strong>init_treedata</strong>()
- await self.<strong>update_grid</strong>()
- 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.<strong>OnQuery</strong>()
- # 获取当前用户有权限查看的列
- 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)
复制代码 以上就是我们对于基类列表界面的抽象,和具体子类的一些个性化函数重写的处置惩罚,以便实现更好的逻辑抽象并保证具体个性化页面内容的处置惩罚。
对于差别的页面,我们可以公用同一个列表界面的基类,可以简化子类的许多操作,并可以或许统一整体的界面效果,提供更多通用的功能入口,是一种比较好的设计模式。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |