一、缘起
北风呼啸,豆豆看着窗外随风摇摆的大树,若有所思。
“豆豆,想什么呢?”
“我在想,多亏有这么一层窗户啊,别看阳光这么好,其实外面挺冷的。”
“你这句话让我想起一本书,叫《建筑的永恒之道》” 马老师说,“在这本书中,作者克里斯托弗·亚历山大通过整体建筑设计的视角,强调了和谐与无名特质的重要性。书中提到,飘窗可以作为一个过渡空间,消除室内外空间的张力,从而创造和谐。飘窗的设计使得人们既能够享受室内的舒适,又能感受到室外的开阔,就像你现在既可以看窗外的风景,又不受外面寒风的困扰。”
“马老师,您还研究建筑学吗?” 豆豆不解地问。
“不不,你想错了,《建筑的永恒之道》虽然是一本建筑学的书,其实也是软件架构师的案头经典,我们设计软件也像建设设计一样,好的建筑要消除室内外空间的张力,让人们住起来舒适,好的软件也应该消除软件与用户之间的张力,让人用起来顺心呀!”
“软件与用户之间的张力?我不太理解啊!”
“软件与用户之间的张力是指用户在使用软件时感受到的不匹配、不适或冲突,一般是因为软件的设计、功能、性能或用户体验与用户的期望、习惯或需求之间存在差距造成的。简单地说,不能用,不好用,体验差!”
“好像有点明白了……那怎么才能消除软件与用户之间的张力呢?”
“这可不是一两句话能说清楚的。” 马老师沉吟了一下,“比如我们要做用户体验设计、可用性测试、性能优化……举个最简单的例子吧,上节课我们做的【万卷书库】程序,它也就是做到了能用,离好用还差得远呢——比如这个用户交互的方式就不友好,你明明只想修改个分类,还要把其它的字段都过一遍……”
“我明白了马老师,您的意思就是现在应该赶紧用PyQt实现新版本的【万卷书库】程序了呗。”
“哈哈,既然话都说到这了,我们开始吧!”
二、建立程序框架
你可能发现,我们要实现的程序代码量变得越来越多,逻辑也相对之前更复杂了。接下来我们要实现的Windows版本程序,可能还会更复杂一些。为了避免软件复杂性给后边的开发带来困扰,我们有必要先了解一些架构设计的知识。
什么是软件架构设计?
软件的架构设计就像是建造房子之前的蓝图。它定义了软件的骨架和结构,包括软件的各个部分如何组织、如何交互,以及它们如何满足用户的需求。简而言之,软件架构设计就是提前规划软件的构建方式,确保它既稳固又好用。
为什么需要软件架构设计?
因为我们要管理软件的复杂性。举个例子,如果你想给家里的小狗盖一座狗窝,其实不需要专门的建筑师,你自己动手找几块木板订起来就差不多了。如果你懂一些木工,这个狗窝会很漂亮;如果不懂,最多是外观或实用性上有点问题,但至少不会有什么严重的后果。但如果是盖一座大楼呢?没有专门的设计,可能没盖两层就倒塌了。如果复杂一点的软件没有架构设计,后期就会产生许多麻烦的问题,难以修改、维护,甚至连功能和性能都无法保证。
我们现在做的程序还称不上是大楼级别,但是懂一些软件架构设计,会让我们的程序结构清晰,易于理解和修改。
对于本程序来说,我们需要重新建立一个更合适的程序结构,将构建用户操作界面的代码、实现业务逻辑的代码、实际进行数据库操作的代码分开,形成所谓的“三层架构”:
- 表示层,负责实现用户界面,与程序的使用者直接交互,展示数据、接收输入,比如常见的网页、终端、或移动APP界面,这一层的代码不负责业务逻辑实现,它主要是把用户的请求转发到下面的业务逻辑层,并展示业务逻辑层的返回结果
- 业务逻辑层:包含应用程序的核心业务规则和逻辑,负责处理界面请求,执行必要的操作,并与下面的数据访问层交互获得或存储数据;
- 数据访问层:负责与数据库或其它存储数据的系统交互,执行数据增删改查操作,为业务逻辑层提供服务。
使用这种程序架构的好处很多,比如: - 由于各层的职责清晰,所以我们知道在什么情况下去修改哪一层,降低开发和维护的难度; - 可以独立地修改或扩展某一层的功能,不影响其它层; - 可以重用某一层的代码。比如在一个程序中写好的数据访问层,可以在另一个具有相同数据结构的程序中使用。我们之所以要在上一节课把数据访问抽离成单独的类,正是这种考虑。
软件架构设计是一门复杂的学问,目前我们现在只要知道它的目标就是为了让开发的软件结构合理、易于修改维护就行。我们会参考三层架构的设计思想去考虑万卷书库的程序设计,但不会追求实现完整严格的三层架构,那样反而成了“过度设计”,毕竟这还只是一个简单的图书管理程序嘛。
现在新建立一个文件夹book_manager_win,使用VSCode打开,新建一个文件dal.py,这就是我们的“数据访问层”,负责和数据库的交互。
我们可以直接把上一个版本中books_new.py里BookDatabase类复制过来,注意import sqlite3也是需要的:
import sqlite3class BookDatabase: def __init__(self, db_name): self.conn = sqlite3.connect(db_name) self.cursor = self.conn.cursor() self.create_table() def create_table(self): self.cursor.execute( """ CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, ISBN TEXT NOT NULL, title TEXT NOT NULL, author TEXT, category TEXT, purchase_date DATE, is_read BOOLEAN, completion_date DATE, notes TEXT ) """ ) self.conn.commit() def add_book( self, ISBN, title, author, category, purchase_date, is_read, completion_date, notes, ): query = """ INSERT INTO books (ISBN, title, author, category, purchase_date, is_read, completion_date, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """ self.cursor.execute( query, ( ISBN, title, author, category, purchase_date, is_read, completion_date, notes, ), ) self.conn.commit() print("图书添加成功!") def update_book( self, book_id, new_ISBN, new_title, new_author, new_category, new_purchase_date, new_is_read, new_completion_date, new_notes, ): query = """ UPDATE books SET ISBN = ?, title = ?, author = ?, category = ?, purchase_date = ?, is_read = ?, completion_date = ?, notes = ? WHERE id = ? """ self.cursor.execute( query, ( new_ISBN, new_title, new_author, new_category, new_purchase_date, new_is_read, new_completion_date, new_notes, book_id, ), ) self.conn.commit() print("图书信息更新成功!") def delete_book(self, book_id): query = "DELETE FROM books WHERE id = ?" self.cursor.execute(query, (book_id,)) self.conn.commit() print("图书删除成功!") def list_books(self): query = "SELECT * FROM books" self.cursor.execute(query) return self.cursor.fetchall() def search_book_by_title(self, title): query = "SELECT * FROM books WHERE title LIKE ?" self.cursor.execute(query, ("%" + title + "%",)) return self.cursor.fetchall() def search_book_by_id(self, book_id): query = "SELECT * FROM books WHERE id = ?" self.cursor.execute(query, (book_id,)) return self.cursor.fetchone() def close(self): self.conn.close()
在实际开发中,我们会把与数据访问有关的类都放在dal.py中。它已经实现了我们需要的大部分数据访问功能,不过为了更好地实现后面的开发,我们对它做一些小小的改造:
修改__init__方法,让db_name这个参数带有默认值books.db。这样在调用类的时候,我们不必要从业务逻辑层再传递数据库名称过来,因为这不是业务逻辑层应该考虑的问题,它只要初始化数据类就能直接使用。
def __init__(self, db_name="books.db"): self.conn = sqlite3.connect(db_name) self.cursor = self.conn.cursor() self.create_table()
更好的做法是用配置文件保存要连接的数据库名称,在初始化的时候加载配置文件。不过目前这种方式已经实现了数据访问层和业务逻辑层的职责独立,以后我们再学习配置文件的用法。
我们对表结构进行小调整,在purchase_date(采购日期)下增加location字段,用于保存图书的存放位置(比如你家有个大书架,你可以给不同的位置编号便于检索,这也是马老师的做法)。再把原来的is_read替换为status,用文本类型保存图书的已读或未读状态更便于查看。
def create_table(self): self.cursor.execute( """ CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, ISBN TEXT NOT NULL, title TEXT NOT NULL, author TEXT, category TEXT, purchase_date DATE, location TEXT, status TEXT, completion_date DATE, notes TEXT ) """ ) self.conn.commit()
仔细查看,add_book()和update_book()这两个方法传递的参数都特别多,因为数据库中字段每个都要作为参数传递过来。我们可以简化一下,把后面的数据库中字段统一放到一个字典类型的参数中,这样传递的时候就不用写太长的参数列表,在函数内部我们直接按字典的关键字取数即可。这样做还有一个好处就是当数据库中的字段发生变化时,函数参数不用变,修改调用函数的代码和函数中有关字段的代码就可以。
修改后的add_book()和update_book()方法如下,我们顺便修改了方法最后的print语句打印内容,这样打印的信息可以作为程序日志记录使用:
def add_book(self,book_data): query = """ INSERT INTO books (ISBN, title, author, category, purchase_date, location, status, completion_date, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """ self.cursor.execute( query, ( book_data["ISBN"], book_data["title"], book_data["author"], book_data["category"], book_data["purchase_date"], book_data["location"], book_data["status"], book_data["completion_date"], book_data["notes"], ), ) self.conn.commit() print(f"图书添加成功:{book_data}") def update_book(self,book_id,updated_data): query = """ UPDATE books SET ISBN = ?, title = ?, author = ?, category = ?, purchase_date = ?, location = ?, status = ?, completion_date = ?, notes = ? WHERE id = ? """ self.cursor.execute( query, ( updated_data["ISBN"], updated_data["title"], updated_data["author"], updated_data["category"], updated_data["purchase_date"], updated_data["location"], updated_data["status"], updated_data["completion_date"], updated_data["notes"], book_id, ), ) self.conn.commit() print(f"编号为{book_id}的图书信息更新成功:{updated_data}")
delete()方法修改就是最后的打印语句:
def delete_book(self, book_id): query = "DELETE FROM books WHERE id = ?" self.cursor.execute(query, (book_id,)) self.conn.commit() print(f"编号为{book_id}的图书删除成功!")
由于我们要实现的是windows版本程序,而且用三层架构,list_books()、search_book_by_title()和search_book_by_id()这三个方法的名称不太合适了,数据访问层应该只“返回”数据而不是“显示”数据。因此,我们把函数名称修改如下:
def get_all_books(self): query = "SELECT * FROM books" self.cursor.execute(query) return self.cursor.fetchall() def get_book_by_title(self, title): query = "SELECT * FROM books WHERE title LIKE ?" self.cursor.execute(query, ("%" + title + "%",)) return self.cursor.fetchall() def get_book_by_id(self, book_id): query = "SELECT * FROM books WHERE id = ?" self.cursor.execute(query, (book_id,)) return self.cursor.fetchone()
其它方面基本上不用修改了。整体代码如下:
import sqlite3class BookDatabase: def __init__(self, db_name="books.db"): self.conn = sqlite3.connect(db_name) self.cursor = self.conn.cursor() self.create_table() def create_table(self): self.cursor.execute( """ CREATE TABLE IF NOT EXISTS books ( id INTEGER PRIMARY KEY AUTOINCREMENT, ISBN TEXT NOT NULL, title TEXT NOT NULL, author TEXT, category TEXT, purchase_date DATE, location TEXT, status TEXT, completion_date DATE, notes TEXT ) """ ) self.conn.commit() def add_book(self, book_data): query = """ INSERT INTO books (ISBN, title, author, category, purchase_date, location, status, completion_date, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """ self.cursor.execute( query, ( book_data["ISBN"], book_data["title"], book_data["author"], book_data["category"], book_data["purchase_date"], book_data["location"], book_data["status"], book_data["completion_date"], book_data["notes"], ), ) self.conn.commit() print(f"图书添加成功:{book_data}") def update_book(self, book_id, updated_data): query = """ UPDATE books SET ISBN = ?, title = ?, author = ?, category = ?, purchase_date = ?, location = ?, status = ?, completion_date = ?, notes = ? WHERE id = ? """ self.cursor.execute( query, ( updated_data["ISBN"], updated_data["title"], updated_data["author"], updated_data["category"], updated_data["purchase_date"], updated_data["location"], updated_data["status"], updated_data["completion_date"], updated_data["notes"], book_id, ), ) self.conn.commit() print(f"编号为{book_id}的图书信息更新成功:{updated_data}") def delete_book(self, book_id): query = "DELETE FROM books WHERE id = ?" self.cursor.execute(query, (book_id,)) self.conn.commit() print(f"编号为{book_id}的图书删除成功!") def get_all_books(self): query = "SELECT * FROM books" self.cursor.execute(query) return self.cursor.fetchall() def get_book_by_title(self, title): query = "SELECT * FROM books WHERE title LIKE ?" self.cursor.execute(query, ("%" + title + "%",)) return self.cursor.fetchall() def get_book_by_id(self, book_id): query = "SELECT * FROM books WHERE id = ?" self.cursor.execute(query, (book_id,)) return self.cursor.fetchone() def close(self): self.conn.close()
你可能会问,我们对数据访问的类修改这么多,这还是“重用”吗?实际上,之所以要修改这么多,是因为我们在开始做第一个版本程序的时候并没有考虑将它作为windows版本程序也可以使用的数据访问层,而是从最基本的增删改查函数一步步修改而来。如果我们有了三层架构的设计经验,以后设计的东西复用性就会更强,甚至可以不加修改地重用。
即使如此,我们一步步演化的代码也节约了大量的精力,这也是我们将数据访问代码独立成类的好处。
现在,该实现用户界面了,现在我们知道,它属于“表示层”
三、UI实现
使用PyQt Designer设计一个窗口main_window:
具体的窗口设计过程我们就不详述了,毕竟你应该已经熟悉了QtDesigner的可视化操作。这个窗口整体采用垂直布局,对界面上的组件自上而下包括:
- 最上方的
QLabel组件显示了一个Banner图片,图片使用资源文件加载; - horizontalSpacer:这个控件像一个弹簧,用于把查找和后面的按钮分开,它会随着窗口尺寸伸展或压缩,使后面的按钮始终位于窗口右侧。
- 最后一行是booksTable:用于显示图书信息。
上面的组件中,唯一你可能不太熟悉的就是booksTable,它是一个QTableWidget控件:
QTableWidget控件提供了一个基于表格的界面,允许我们以行和列的形式展示数据,使用起来比较简单。我们一般会先设置好表格中的行和列,再使用setItem方法来修改数据即可,表格也可能通过连接信号和槽实现用户交互(比如双击某一行数据弹出修改对话框)。
要修改表中的列,你可以在QTableWidget控件上点击鼠标右键,选择“编辑项目”,弹出对话框:
通过这个列表可以添加表格中的列,点击“属性”按钮可以显示选中列的详细属性,进行更进一步的修改。这里我们只需添加与数据库中字段对应的列即可,不需要单独设置每列的属性。
完成控件设置后需要设置相关的信号和槽函数,如下图所示:
完成上述设置,返回VSCode,编辑窗口文件和对应的资源。现在我们的项目文件应该是下面这样:
下面可以开始实现业务逻辑层了。
四、实现业务逻辑
新建book_manager.py,利用我们前面所学的PyQt知识,初始化应用,显示窗口。其中添加、删除、修改、查询按钮的实现都是空函数,相当于前一节课我们搭建的初步程序结构。
import sysimport Ui_main_windowfrom PyQt5.QtWidgets import ( QApplication, QMainWindow, QTableWidgetItem, QTableWidget, QMessageBox, QDialog,)from dal import BookDatabaseclass BookManager(QMainWindow, Ui_main_window.Ui_MainWindow): def __init__(self): super(BookManager, self).__init__() self.setupUi(self) self.db = BookDatabase() # 实例化数据库对象 # 添加书籍 def add_book(self): pass # 删除书籍 def delete_book(self): pass # 更新书籍 def update_book(self): pass # 搜索书籍 def search_book(self): passif __name__ == "__main__": app = QApplication(sys.argv) manager = BookManager() manager.show() sys.exit(app.exec_())
此时运行程序,可以显示一个空白表格的主界面:
4.1 实现新增方法
要实现新增,我们不能再使用控制台的input()函数输入数据,而是会显示一个新增的窗口,接受用户输入,再把输入的信息传递给BookManager对象处理。要实现这个新增窗口,可以使用PyQtDesigner新建,也可以使用代码创建对话框。这里我们选择后一种方法,因为它的界面相对简单,就是一系列编辑控件。
新建一个dialogs.py文件,并添加以下内容:
from PyQt5.QtWidgets import ( QPushButton, QVBoxLayout, QLineEdit, QDateEdit, QComboBox, QDialog, QLabel, QApplication,)from PyQt5.QtCore import QDate# 添加书籍对话框类class AddBookDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("添加书籍") self.setGeometry(150, 150, 400, 300) layout = QVBoxLayout() self.isbnEdit = QLineEdit(self) self.isbnEdit.setPlaceholderText("ISBN") layout.addWidget(self.isbnEdit) self.titleEdit = QLineEdit(self) self.titleEdit.setPlaceholderText("书名") layout.addWidget(self.titleEdit) self.authorEdit = QLineEdit(self) self.authorEdit.setPlaceholderText("作者") layout.addWidget(self.authorEdit) self.categoryEdit = QLineEdit(self) self.categoryEdit.setPlaceholderText("分类") layout.addWidget(self.categoryEdit) self.purchaseDateEdit = QDateEdit(self) layout.addWidget(self.purchaseDateEdit) self.locationCombo = QComboBox(self) self.locationCombo.addItems(["1-1", "1-2", "1-3", "2-1", "2-2", "2-3"]) layout.addWidget(self.locationCombo) self.readStatusCombo = QComboBox(self) self.readStatusCombo.addItems(["未读", "已读"]) layout.addWidget(self.readStatusCombo) self.completionDateEdit = QDateEdit(self) layout.addWidget(self.completionDateEdit) self.notesEdit = QLineEdit(self) self.notesEdit.setPlaceholderText("备注") layout.addWidget(self.notesEdit) self.addButton = QPushButton("添加书籍") self.addButton.clicked.connect(self.accept) layout.addWidget(self.addButton) self.setLayout(layout) self.center() def center(self): # 获取主窗口的位置和大小 qr = self.parent().geometry() # 获取屏幕的分辨率 screen = QApplication.desktop().screenNumber(self) centerPoint = QApplication.desktop().screenGeometry(screen).center() # 计算对话框的位置 x = qr.x() + qr.width() / 2 - self.width() / 2 y = qr.y() + qr.height() / 2 - self.height() / 2 self.move( centerPoint.x() - self.width() / 2, centerPoint.y() - self.height() / 2 ) def get_book_data(self): return { "ISBN": self.isbnEdit.text(), "title": self.titleEdit.text(), "author": self.authorEdit.text(), "category": self.categoryEdit.text(), "purchase_date": self.purchaseDateEdit.date().toString("yyyy-MM-dd"), "location": self.locationCombo.currentText(), "status": self.readStatusCombo.currentText(), "completion_date": ( self.completionDateEdit.date().toString("yyyy-MM-dd") if self.completionDateEdit.date().isValid() else "" ), "notes": self.notesEdit.text(), }
这段代码很长,但很易理解。__init__()方法创建了一系列与数据库中字段对应的编辑组件,并按垂直布局显示在窗口中。调用center()方法让窗口居中。最后一个get_book_data()方法则是将组件上的信息组合成字典返回。
日期信息返回的时候稍微做了一下处理,如果日期组件的日期有效就返回日期,否则返回空字符串。
接下来,我们在book_manager.py中导入添加对话框:
from dialogs import AddBookDialog
为add_book()方法编写代码:
# 添加书籍 def add_book(self): dialog = AddBookDialog(self) if dialog.exec_() == QDialog.Accepted: book_data = dialog.get_book_data() self.db.add_book(book_data) # 提示添加成功 QMessageBox.information(self, "提示", "图书添加成功!")
现在运行程序,点击新增按钮,会显示对话框,供用户输入图书信息,完成后按“添加书籍”按钮会提示图书添加成功。
4.2 查询和显示图书
新增的图书并没有显示在表格中,因为我们没有将数据加载到表格。现在在BookManager类添加一个方法
def load_books(self, title=""): self.booksTable.setRowCount(0) books = self.db.get_book_by_title(title) # 从数据库获取书籍 for row in books: rowPosition = self.booksTable.rowCount() self.booksTable.insertRow(rowPosition) for column, item in enumerate(row): self.booksTable.setItem( rowPosition, column, QTableWidgetItem(str(item)) )
这个方法先设置表格行数为0,用于清空表格,然后调用数据访问层的search_book_by_title()方法获得数据。如果title参数为空相当于获得全部数据,否则就是查询书名包含title参数的图书,然后通过遍历向表格控件插入数据。
这样,搜索按钮的代码就很简单了,直接使用关键字输入控件的内容来调用load_books()方法即可:
# 搜索书籍 def search_book(self): self.load_books(self.keywordEdit.text())
为了让窗口显示的时候就加载数据,可以在__init()__方法中调用一次load_books()方法:
def __init__(self): super(BookManager, self).__init__() self.setupUi(self) self.db = BookDatabase() # 实例化数据库对象 self.load_books()
同样,新增完成后也要调用一次load_books()方法:
# 添加书籍 def add_book(self): dialog = AddBookDialog(self) if dialog.exec_() == QDialog.Accepted: book_data = dialog.get_book_data() self.db.add_book(book_data) self.load_books(self.keywordEdit.text()) # 提示添加成功 QMessageBox.information(self, "提示", "图书添加成功!")
再次运行程序,会加载全部图书。通过书名也可以查询:
4.3 修改图书
修改图书和新增是相似的。在dialogs.py中再添加一个类UpdateBookDialog:
class UpdateBookDialog(QDialog): def __init__(self, book_data, parent=None): super().__init__(parent) self.setWindowTitle("更新书籍信息") self.setGeometry(150, 150, 400, 350) # 增加窗口高度,以适应标签和输入框 layout = QVBoxLayout() # ISBN self.isbnLabel = QLabel("ISBN:", self) layout.addWidget(self.isbnLabel) self.isbnEdit = QLineEdit(self) self.isbnEdit.setText(book_data["ISBN"]) layout.addWidget(self.isbnEdit) # 书名 self.titleLabel = QLabel("书名:", self) layout.addWidget(self.titleLabel) self.titleEdit = QLineEdit(self) self.titleEdit.setText(book_data["title"]) layout.addWidget(self.titleEdit) # 作者 self.authorLabel = QLabel("作者:", self) layout.addWidget(self.authorLabel) self.authorEdit = QLineEdit(self) self.authorEdit.setText(book_data["author"]) layout.addWidget(self.authorEdit) # 分类 self.categoryLabel = QLabel("分类:", self) layout.addWidget(self.categoryLabel) self.categoryEdit = QLineEdit(self) self.categoryEdit.setText(book_data["category"]) layout.addWidget(self.categoryEdit) # 购买日期 self.purchaseDateLabel = QLabel("购买日期:", self) layout.addWidget(self.purchaseDateLabel) self.purchaseDateEdit = QDateEdit(self) self.purchaseDateEdit.setDate( QDate.fromString(book_data["purchase_date"], "yyyy-MM-dd") ) layout.addWidget(self.purchaseDateEdit) # 位置 self.locationLabel = QLabel("位置:", self) layout.addWidget(self.locationLabel) self.locationCombo = QComboBox(self) self.locationCombo.addItems(["1-1", "1-2", "1-3", "2-1", "2-2", "2-3"]) self.locationCombo.setCurrentText(book_data["location"]) layout.addWidget(self.locationCombo) # 是否已读 self.readStatusLabel = QLabel("是否已读:", self) layout.addWidget(self.readStatusLabel) self.readStatusCombo = QComboBox(self) self.readStatusCombo.addItems(["未读", "已读"]) self.readStatusCombo.setCurrentText(book_data["status"]) layout.addWidget(self.readStatusCombo) # 完成阅读时间 self.completionDateLabel = QLabel("完成阅读时间:", self) layout.addWidget(self.completionDateLabel) self.completionDateEdit = QDateEdit(self) finish_date = book_data["completion_date"] if finish_date: self.completionDateEdit.setDate(QDate.fromString(finish_date, "yyyy-MM-dd")) layout.addWidget(self.completionDateEdit) # 备注 self.notesLabel = QLabel("备注:", self) layout.addWidget(self.notesLabel) self.notesEdit = QLineEdit(self) self.notesEdit.setText(book_data["notes"]) layout.addWidget(self.notesEdit) # 更新按钮 self.updateButton = QPushButton("更新书籍") self.updateButton.clicked.connect(self.accept) layout.addWidget(self.updateButton) self.setLayout(layout) self.center() def center(self): # 获取主窗口的位置和大小 qr = self.parent().geometry() # 获取屏幕的分辨率 screen = QApplication.desktop().screenNumber(self) centerPoint = QApplication.desktop().screenGeometry(screen).center() # 计算对话框的位置 x = qr.x() + qr.width() / 2 - self.width() / 2 y = qr.y() + qr.height() / 2 - self.height() / 2 self.move( centerPoint.x() - self.width() / 2, centerPoint.y() - self.height() / 2 ) def get_updated_data(self): return { "ISBN": self.isbnEdit.text(), "title": self.titleEdit.text(), "author": self.authorEdit.text(), "category": self.categoryEdit.text(), "purchase_date": self.purchaseDateEdit.date().toString("yyyy-MM-dd"), "location": self.locationCombo.currentText(), "status": self.readStatusCombo.currentText(), "completion_date": ( self.completionDateEdit.date().toString("yyyy-MM-dd") if self.completionDateEdit.date().isValid() else "" ), "notes": self.notesEdit.text(), }
这个类和AddBookDialog类的区别就是在__init__()方法中要加载book_data,这是一个字典类型的图书数据,其它返回数据的操作都一样。
在book_manager.py中再添加对UpdateBookDialog的引用:
from dialogs import AddBookDialog, UpdateBookDialog
然后实现update_book方法 :
# 更新书籍 def update_book(self): selected_row = self.booksTable.currentRow() if selected_row >= 0: book_id = self.booksTable.item(selected_row, 0).text() book_data = self.db.get_book_by_id(book_id) # 通过 book_id 获取书籍信息 update_dialog = UpdateBookDialog( { "ISBN": book_data[1], "title": book_data[2], "author": book_data[3], "category": book_data[4], "purchase_date": book_data[5], "location": book_data[6], "status": book_data[7], "completion_date": book_data[8], "notes": book_data[9], }, self, ) if update_dialog.exec_() == QDialog.Accepted: updated_data = update_dialog.get_updated_data() self.db.update_book(book_id, updated_data) self.load_books(self.keywordEdit.text()) # 刷新书籍列表 else: QMessageBox.warning(self, "错误", "请选择要更新的书籍")
这个方法要我们首先判断表格中有没有选中的行,如果有,获得当前选中行的图书id,用这个id调用数据访问层获得图书信息,将获得的信息填充到update_dialog中,并处理窗口返回的信息,调用数据访问层实现数据保存。最后再使用load_books()方法重新检索数据。
运行后点击“修改”按钮,即可修改当前图书。由于我们还在QtDesigner中关联了表格的双击信号到update_book,所以双击表格中的行也可以调出这个对话框:
输入新的数据后点击“更新书籍”按钮保存数据,表格随之刷新。
4.4 删除图书
删除的代码相对简单,如下 :
def delete_book(self): selected_row = self.booksTable.currentRow() if selected_row >= 0: book_id = self.booksTable.item(selected_row, 0).text() book_title = self.booksTable.item(selected_row, 2).text() try: book_id = int(book_id) if ( QMessageBox.question( self, "确认删除", "确认删除书籍 {}?".format(book_title), QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) == QMessageBox.Yes ): self.db.delete_book(book_id) # 从数据库删除书籍 self.load_books(self.keywordEdit.text()) # 刷新书籍列表 except ValueError: QMessageBox.warning(self, "错误", "无效的书籍编号") else: QMessageBox.warning(self, "提示", "请选择要删除的书籍")
删除之前我们先去数据库中检索相应编号的图书,并在删除之前让用户确认一下(这是为了防止用户不小心点击删除按钮,给用户一次后悔的机会),对删除操作来说这是很有必要的。之后调用数据访问层删除图书。
4.5 显示效果调整
现在通过添加功能多增加几条数据,你可能会发现显示效果有一些不尽人意的地方:
- 首先是表格每行显示了一个行号,似乎与我们的图书编号冲突,不美观;
- 最后一列备注是固定的,不会拉长,如果能随表格拉伸更好
- 如果你双击某个单元格,在弹出修改框后如果你不修改,这个单元格会变成可编辑状态,但这个编辑并不起什么作用。
我们需要一些代码来调整表格控件的显示效果,编写一个set_table方法:
def set_table(self): # 禁止编辑单元格 self.booksTable.setEditTriggers(QTableWidget.NoEditTriggers) # 隐藏垂直标题 self.booksTable.verticalHeader().setVisible(False) # 设置表头宽度 self.booksTable.setColumnWidth(1, 150) self.booksTable.setColumnWidth(2, 200) self.booksTable.setColumnWidth(3, 200) # 设置最后一列自动伸缩 self.booksTable.horizontalHeader().setStretchLastSection(True) # 设置隔行变色 self.booksTable.setAlternatingRowColors(True)
在窗口__init__()方法中添加对set_table()方法的调用:
def __init__(self): super(BookManager, self).__init__() self.setupUi(self) self.set_table() # 设置表格显示效果 self.db = BookDatabase() # 实例化数据库对象 self.load_books()
经过调整再次运行,显示效果是不是好多了?
五、小结
“豆豆,你觉得这个版本的【万卷书库】,是不是好用多了?” 马老师问。
“是的,至少在更新的时候我不用把不需要修改的字段也过一遍了,显示的效果也更好了。” 豆豆说。
“在控制台中我们的操作更像是线性的,所以你只能一个属性一个属性地过一遍。” 马老师说,“现在用windows版本,你可以跳出来可以直接选择自己关注的信息处理,感觉自然不同——这就是在一定程度上消除了软件与用户之间的【张力】,用起来不那么难受了。”
“看来做软件不能只考虑功能实现,还得考虑许多易用性方面的问题呀 ” 豆豆若有所思地说。
“甚至说,你的软件好用会产生很强的【粘性】,从而留住用户”,马老师说,“比如苹果公司的IOS系统,很多用户习惯了它的用户体验,再用其它一些体验不那么优秀的系统,就感觉很不舒服,自然就成了忠实的【果粉】”。
“怪不得我妈现在除了苹果手机别的都不喜欢用了!” 豆豆恍然大悟地说。
马老师笑着说:“是的。当然用户体验可不仅仅是界面设计的问题,它还包括了软件的性能、稳定性、安全性等多个方面。程序员要不断地去了解用户,满足他们的需求,而不是将程序作为展示技术水平的试验品。有时候,我经常说有些新手做的程序,充满了一种实验室产品的味道,根本不考虑使用者的感觉,这就是一种将就,这样的作品,包括程序员自己在内,都是不愿意用的。”
“做软件,还是要讲究一点啊,但凡有了一点从用户角度出发去设计的意识,程序做出来就会明显不一样。”
“明白了马老师”,豆豆说,“总结下来七个字——要讲究,不要将就!”