第 14 讲 | 数据库与 Excel 双向同步
本讲目标 :把数据库和 Excel 打通------"给我个表我导进去"、"导出来发给领导"。
同时讲清一个关键判断:什么时候用 QtSql,什么时候用 pandas。
配套代码 :
cases/c14_excel_sync.py,下载地址在底部
一、为什么这一讲换用 pandas + sqlite3
前两讲用的都是 Qt 的 QtSql 模块。这一讲换成 Python 标准库的 sqlite3 加 pandas。
操作的是同一个 .db 文件,只是访问路径不同。判断标准只有一条:
| 需求 | 用什么 | 原因 |
|---|---|---|
| 界面表格实时联动数据库 | QtSql + Model | Qt 的模型能直接喂给 QTableView |
| 批量导入导出 Excel、统计计算 | pandas + sqlite3 | read_sql / to_sql / groupby 一行搞定 |
具体对比一下"导出 5000 条记录到 Excel"这件事:
用 QtSql 的写法:
python
query = QSqlQuery(db)
query.exec("SELECT student, class, subject, score FROM scores")
rows = []
while query.next():
rows.append([query.value(0), query.value(1), query.value(2), query.value(3)])
# 然后还要用 openpyxl 逐行写入、设格式......
用 pandas 的写法:
python
conn = sqlite3.connect(db_path)
df = pd.read_sql("SELECT * FROM scores", conn)
df.to_excel("成绩导出.xlsx", index=False)
四行。 这就是选 pandas 的理由。
二、sqlite3 的基本用法
python
import sqlite3
from utils.db import db_path
conn = sqlite3.connect(db_path("demo.db"))
# 查询
rows = conn.execute("SELECT student, class, score FROM scores WHERE class = ?",
("1班",)).fetchall()
# 返回 [(('张伟', '1班', 88.0), ...]
# 带参数(用元组或列表,不要用 f-string 拼接)
cur = conn.execute("INSERT INTO scores(student, class, subject, score) VALUES (?,?,?,?)",
("李雷", "1班", "语文", 92.0))
print(cur.lastrowid) # 刚插入行的自增 ID
# 提交
conn.commit()
# 或者用 with 自动提交(推荐)
with conn:
conn.execute("DELETE FROM scores WHERE id = ?", (1,))
conn.close()
with conn: 是自动提交/回滚 :块内没异常就 commit,有异常就 rollback。
它不会关闭连接 (和文件操作的 with open() 不同),所以还是要 conn.close()。
让查询结果能用列名访问
python
conn.row_factory = sqlite3.Row
cur = conn.execute("SELECT id, name FROM contacts")
row = cur.fetchone()
print(row["name"], row[0]) # 两种方式都可以
sqlite3.Row 让结果既能按索引取,也能按列名取,比纯元组方便很多。
本工程的 utils/db.py 里的 connect() 就默认开了这个。
打开外键约束
python
conn.execute("PRAGMA foreign_keys = ON")
SQLite 默认不校验外键 ,这是历史遗留设计(为了兼容旧版本)。
如果你的表之间有关联,一定要手动打开。
三、Excel → 数据库:列名映射是第一个坎
Excel 里的列名是中文("学生""班级""科目""成绩"),数据库字段是英文
(student/class/subject/score)。导入时要转换:
python
def import_task(report, excel_file: str, db_file: str) -> dict:
import pandas as pd
report(10, f"读取 {Path(excel_file).name}")
df = pd.read_excel(excel_file)
# ★ 列名兼容映射:同一含义的不同写法都支持
rename = {"学生": "student", "姓名": "student", "班级": "class",
"科目": "subject", "成绩": "score", "分数": "score"}
df = df.rename(columns={k: v for k, v in rename.items() if k in df.columns})
missing = [c for c in SCORE_COLS if c not in df.columns]
if missing:
raise ValueError(f"Excel 缺少必要的列:{missing}\n需要:学生 / 班级 / 科目 / 成绩")
...
三个设计要点:
① 一个含义支持多个写法
用户给你的表可能写"学生"也可能写"姓名",写"成绩"也可能写"分数"。
rename 字典里都列上,兼容性大大提升。这是"给同事用的工具"必须考虑的。
② 缺列时给出明确提示
python
raise ValueError(f"Excel 缺少必要的列:{missing}\n需要:学生 / 班级 / 科目 / 成绩")
不能只说"导入失败",要告诉用户缺什么、应该有什么。
③ 后面的代码只跟英文列名打交道
统一转成英文后,后面的处理逻辑就稳定了,不用每处都判断"到底是学生还是姓名"。
数据清洗和写入
python
df = df[SCORE_COLS].copy() # 只取需要的列,且固定顺序
df["score"] = pd.to_numeric(df["score"], errors="coerce") # 转不了就变 NaN
bad = int(df["score"].isna().sum())
df = df.dropna(subset=["score"]) # 丢掉 NaN
report(55, f"共 {len(df)} 条有效记录(丢弃无法解析的成绩 {bad} 条)")
conn = sqlite3.connect(db_path(db_file))
with conn:
conn.execute("DELETE FROM scores") # 全量导入:先清空
df.to_sql("scores", conn, if_exists="append", index=False)
conn.close()
四个要点:
① errors="coerce" :把转换失败的变成 NaN 而不是抛异常。之后统计有多少个 NaN,
报给用户,而不是悄悄丢掉。
② 先 DELETE 再 append :这是"全量覆盖"的语义。
如果期望的是"追加",就删掉 DELETE 那行,并考虑如何处理重复。
③ if_exists="append" :其他选项是 "fail"(表存在就报错)和 "replace"(删表重建)。
用 "append" 先 DELETE 再插入,能保留表的索引和约束。
④ index=False :不加的话,pandas 会把行号当成一列写进数据库,
多出一个叫 index 的字段。这是 to_sql 最经典的坑。
四、数据库 → Excel:多 Sheet 是加分项
python
def export_task(report, db_file: str, out_file: str, only_class: str = "") -> dict:
import pandas as pd
conn = sqlite3.connect(db_path(db_file))
if only_class:
df = pd.read_sql("SELECT student, class, subject, score FROM scores WHERE class = ?",
conn, params=(only_class,))
else:
df = pd.read_sql("SELECT student, class, subject, score FROM scores", conn)
conn.close()
if df.empty:
raise ValueError("没有查询到数据,无法导出")
df = df.rename(columns={"student": "学生", "class": "班级",
"subject": "科目", "score": "成绩"})
# ★ 同一个文件写两个 Sheet:明细 + 统计
summary = (df.groupby(["班级", "科目"])["成绩"]
.agg(["count", "mean", "max", "min"]).round(1).reset_index()
.rename(columns={"count": "人数", "mean": "平均分",
"max": "最高分", "min": "最低分"}))
Path(out_file).parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(out_file, engine="xlsxwriter") as writer:
df.to_excel(writer, sheet_name="成绩明细", index=False)
summary.to_excel(writer, sheet_name="班级统计", index=False)
关键点
① params= 传参数
python
pd.read_sql("... WHERE class = ?", conn, params=("1班",))
和手写 SQL 一样要参数化,不要 f-string 拼接。
② 一个文件多个 Sheet 用 ExcelWriter
python
with pd.ExcelWriter(out_file, engine="xlsxwriter") as writer:
df1.to_excel(writer, sheet_name="明细", index=False)
df2.to_excel(writer, sheet_name="统计", index=False)
engine="xlsxwriter" 是必须指定的(本工程 requirements 里装了 XlsxWriter)。
不写的话 pandas 会用 openpyxl,功能上也能用,但 xlsxwriter 在样式和性能上更好。
③ 导出的列名换回中文
数据库里是英文,导出给人看的 Excel 要中文。这个转换在导出时做。
④ 附一个统计 Sheet
领导拿到明细表往往还想看汇总。顺手多带一个统计 Sheet,是让工具显得专业的小细节。
五、用 pandas 做统计:三行出一张表
python
# ① 各科统计
by_subject = (df.groupby("subject")["score"].agg(["count", "mean", "max", "min"])
.round(1).sort_values("mean", ascending=False))
by_subject.columns = ["人数", "平均分", "最高分", "最低分"]
# ② 各班排名
by_class = (df.groupby("class")["score"].agg(["count", "sum", "mean"])
.round(1).sort_values("sum", ascending=False))
by_class.columns = ["记录数", "总分", "平均分"]
# ③ 学生总分 Top10
by_student = (df.groupby("student")["score"].sum().round(1)
.sort_values(ascending=False).head(10).to_frame("总分"))
groupby 的链式写法在数据分析里极其常见,值得熟悉:
df.groupby(分组列)[要计算的列].agg([函数列表]).round(小数位).sort_values(排序).head(n)
把结果贴到界面上
不需要额外的表格控件,pandas 的 DataFrame 转成字符串直接扔进 QTextEdit:
python
self.txt.append("【各科统计】\n" + str(info["subject"]) + "\n")
输出长这样:
【各科统计】
人数 平均分 最高分 最低分
subject
数学 40 86.3 99.8 58.2
语文 40 82.1 98.5 55.0
英语 40 79.6 96.3 52.4
【班级排名(按总分)】
记录数 总分 平均分
class
2班 120 9964.0 83.0
1班 120 9812.5 81.8
3班 120 9765.3 81.4
pandas 默认的对齐输出在等宽字体下非常整齐 ,
所以那个 QTextEdit 一定要设等宽字体:
python
self.txt.setStyleSheet("font-family:Consolas,'Microsoft YaHei',monospace; font-size:12px;")
中文字符宽度和英文不一样,对齐会略微错位 ------这是等宽字体的局限,
但整体可读性没问题。想要完美对齐可以用 tabulate 库,属于锦上添花。

六、界面:四个按钮的流水线
python
self.btn_demo = QPushButton("① 生成示例 Excel")
self.btn_import = QPushButton("② 从 Excel 导入")
self.btn_export = QPushButton("③ 导出到 Excel")
self.btn_stats = QPushButton("④ 计算统计")
self.btn_refresh = QPushButton("刷新表格")
按钮上带序号 ①②③④ ,用户一眼知道操作顺序。这是个小细节,
但对于"给不熟悉电脑的同事用"的工具,效果很明显。
任务复用上一讲的 TaskThread:
python
def _run_task(self, fn, *args) -> None:
if self.task and self.task.isRunning():
return
self.task = TaskThread(fn, *args)
self.task.progress.connect(lambda p, m: m and self.txt.append(m))
self.task.finished.connect(self.on_finished)
self.task.failed.connect(lambda e: QMessageBox.critical(self, "失败", e))
self.task.start()
一个包装方法管四个任务 ,因为它们签名都是 fn(report, *args) 且返回 dict。
这就是第 8 讲把 TaskThread 设计成通用工具的收益。
七、三个坑
坑 1:to_sql 忘了 index=False
数据库会多出一个 index 字段,下次导出时它又会被当成一列导到 Excel。
记住:所有 to_excel / to_sql 都加 index=False。
坑 2:Excel 里的数字被读成字符串
单元格格式是"文本"时,成绩 88 读进来是 "88"(字符串),
直接入库后排序会出现 "100" < "88" 这种诡异结果。
用 pd.to_numeric(..., errors="coerce") 统一转一遍,转不了的就丢掉并报告。
坑 3:中文列名读取时的空格
Excel 表头是 "学生 "(带个尾部空格)时,rename 里的 "学生" 匹配不上。
稳妥做法是先清洗列名:
python
df.columns = [c.strip() for c in df.columns]
这一行几乎应该写进每个读 Excel 的脚本里。
八、小结
- 两条路各有分工:界面联动用 QtSql(第 12、13 讲),批量处理用 pandas(本讲)
- 导入的重点是列名映射:支持多种写法、缺列给明确提示
- 导出的加分项是统计 Sheet:领导拿到明细还想看汇总
- 三条保命规则 :
index=False、to_numeric(errors="coerce")、批量删 NaN 前先报告数量
数据库部分到此结束。你现在能做出"数据能存、能查、能和 Excel 互导"的完整应用。
下一讲 :数据可视化。前面所有工具都在处理"数据",但从下一讲开始,
我们让数据"画"出来------柱状图、饼图、折线图,用 Qt 自带的 QtCharts 模块。
好处是和界面同一个渲染引擎,缩放不糊、动画原生,不用把 matplotlib 塞进 Qt 里折腾。
本讲是《PyQt6 客户端编程从入门到实战》专栏的第 14 讲。专栏共 22 讲、22 个可运行案例。
**下载地址:git clone https://gitee.com/wuyikui555/pyqt6-client.git**。