diff --git a/docs/teaching/ASSIGNMENTS.md b/docs/teaching/ASSIGNMENTS.md
new file mode 100644
index 0000000..c0a0294
--- /dev/null
+++ b/docs/teaching/ASSIGNMENTS.md
@@ -0,0 +1,140 @@
+# Day2–4 课堂练习 + 课后作业(分层设计)
+
+> 配套 [`OUTLINE_4DAY.md`](OUTLINE_4DAY.md) 的任务卡、`slides/day2.html`…`day4.html`
+> 里的「练/作业」小节。本文给出可直接打印/投屏的作业单:每项作业拆成
+> **基础版(必做,验收线)** + **挑战版(选做,见能力发挥)** 两层,时间预算按
+> 「基础版 1 小时内可完成、挑战版累计不超过 2 小时」控制,照顾基础不理想的
+> 学员——**先保基础版全员过关,挑战版不做不扣分**。
+
+## 分层设计原则
+
+1. **基础版是及格线,不是引导线**:题面本身已经是当天知识点的最小闭环,做完
+ 基础版=当天目标达成;工程师答疑优先保基础版全员通过。
+2. **挑战版是同一个程序的自然延伸**,不引入新知识点,只要求「更完整/更健壮/
+ 更贴近真实使用」——强度落在「把已学的东西用得更彻底」,不是提前预习明天内容。
+3. **验收用现场提问代替翻源码**:给几组输入让学生当场演示结果(下表「验收用例」),
+ 比对照代码更能测出是真理解还是照抄。
+4. 每天的作业首尾相接(计算器 → 计算器 QMainWindow 化 + 美化 → 数据存储工具),
+ 挑战版做不完不影响第二天用基础版继续。
+
+---
+
+## Day2 下午 · 简易计算器
+
+对应任务卡 2(`OUTLINE_4DAY.md` Day2 下午作业)。教师参考实现:
+[`examples/calculator/`](examples/calculator/README.md)(代码布局版)、
+[`examples/calculator_designer/`](examples/calculator_designer/README.md)(Qt Designer 对照版)。
+
+| | 要求 | 时间预算 |
+|---|---|---|
+| **基础版(必做)** | 数字键 0–9 + 至少一种运算符(+/-/×/÷ 任选其一)+ 等号,能正确显示两个数的运算结果;界面不重叠、按钮可点击;`connect` 全部用新语法且写在构造函数里 | ≈1h |
+| **挑战版(选做)** | 补全四则运算全部支持;小数点;清空键 `C`;退格键 `⌫`;除零不崩溃、显示 `Error` 且下一次输入自动清空;连续按运算符实现链式计算(如 `2+3+4=` 得 `9`) | 累计 ≤2h |
+
+**验收用例**(现场让学生当场演示,不看代码):
+
+- 基础版:`7 + 3 =` → `10`;`9 - 4 =` → `5`(任选一种运算符即可)
+- 挑战版:`6 ÷ 0 =` → `Error`;`4.5 + 0.5 =` → `5`;连续输入 `12` 后按退格 → `1`;
+ `2 + 3 + 4 =` → `9`
+
+**给基础不理想同学的提示**:卡在"点击没反应"先查 `connect` 有没有漏写、按钮有没有
+真的加到布局里;卡在"数字显示不对"先只处理个位数,两位数以上的拼接逻辑
+(`display->text() + 新数字`)留到基础版跑通个位数运算之后再加。
+
+---
+
+## Day3 上午 · 计算器 QMainWindow 化
+
+对应 `OUTLINE_4DAY.md` Day3 上午课堂练习(11:00–12:00)。
+
+参考实现:[`examples/calculator_mainwindow/`](examples/calculator_mainwindow/README.md)
+(以 Day2 增强版 [`calculator_designer_ext/`](examples/calculator_designer_ext/README.md)
+为起点;`CalculatorDesigner` 是独立 `QWidget`,`setCentralWidget` 装进 `QMainWindow`,
+核心求值逻辑不动,仅加 `evaluated` 信号供历史停靠窗订阅)。
+
+| | 要求 | 时间预算 |
+|---|---|---|
+| **基础版(必做)** | 把 Day2 计算器的键盘 `setCentralWidget` 装进 `QMainWindow`;加菜单栏「帮助→关于」,点击弹出 `QMessageBox::about` 显示一句自我介绍 | ≈40min |
+| **挑战版(选做)** | 加一个「历史」停靠窗(`QDockWidget` + `QListWidget`):每次按等号,把这次的算式和结果(如 `7+3=10`)追加成一条历史记录;停靠窗可拖动/浮动/关闭(`QDockWidget` 默认行为,不用额外写代码) | ≤1h |
+
+**验收用例**:点菜单「帮助→关于」能弹框;挑战版——连续算 3 次不同的算式,历史
+停靠窗里能看到 3 条记录且内容正确。
+
+**给基础不理想同学的提示**:`QMainWindow` 不是 `QWidget` 的替代品,是新建一个
+`QMainWindow` 子类,把原来计算器的键盘部分(`QWidget` 或其内容)通过
+`setCentralWidget()` 塞进去;菜单点了没反应先检查 `QAction` 是不是忘了 `connect`
+(Day3 上午刚讲的坑卡)。挑战版做不完,历史停靠窗留空 `QLabel` 占位即可,
+不影响 Day3 下午继续。
+
+---
+
+## Day3 下午 · 计算器美化(QSS)
+
+对应任务卡(`OUTLINE_4DAY.md` Day3 下午作业)。
+
+参考实现:[`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md)
+(Day3 上午版 + QSS 双主题;与上午版仅 `mainwindow.{h,cpp}` 差异,演示「美化与逻辑正交」)。
+
+| | 要求 | 时间预算 |
+|---|---|---|
+| **基础版(必做)** | 数字键 / 运算键 / 等号键至少用 3 种不同颜色区分(`setObjectName` + QSS ID 选择器 `#objectName{...}`);整体布局随窗口缩放不重叠、不错位 | ≈1h |
+| **挑战版(选做)** | 至少 1 个按钮有 `:hover` 或 `:pressed` 视觉反馈;给结果显示框也套样式(圆角边框/字号);额外做一个"深色主题"QSS 字符串,加一个按钮或菜单项用 `setStyleSheet()` 在两套样式间切换 | ≤1h |
+
+**验收用例**:缩放窗口到很小和很大两种尺寸,按钮不重叠不错位;挑战版——点切换
+主题按钮,界面颜色整体改变。
+
+**给基础不理想同学的提示**:QSS 选择器认的是 `objectName`(`setObjectName("btnEquals")`
+之后才能用 `#btnEquals{...}`),忘设置 `objectName` 是"样式写了但没生效"的头号原因;
+先做好 1 种颜色区分再扩展到 3 种,不要一次性套一大段 QSS 上去调不出问题在哪。
+
+---
+
+## Day4 上午 · 事件过滤器 / 自定义绘图
+
+对应 `OUTLINE_4DAY.md` Day4 上午课堂练习(11:00–12:00)。
+
+| | 要求 | 时间预算 |
+|---|---|---|
+| **基础版(必做,二选一)** | ① 给某个控件装 `installEventFilter()`,鼠标按下时在控制台打印坐标;或 ② 重写 `paintEvent`,用 `QPainter` 画一个组合图形(棋盘格、简单进度条、或几条交叉线均可) | ≈40min |
+| **挑战版(选做,两项整合)** | 在一个自定义 `QWidget` 上:鼠标点击时记录点击坐标(`mousePressEvent` 或事件过滤器都行)到一个 `QVector`,每次点击后 `update()` 触发重绘,`paintEvent` 里把历史上所有点击过的坐标都画出来(比如画小圆点)——点越点越多,图案累积不丢失 | ≤1h |
+
+**验收用例**:基础版①——点击窗口能在控制台看到坐标打印;基础版②——窗口能看到
+一个图形;挑战版——连续点击 5 个不同位置,5 个点都保留在画面上(不是只显示
+最后一个)。
+
+**给基础不理想同学的提示**:基础版明确「二选一」,不要求两项都做——事件拦截和
+自定义绘图是两条独立线索,先吃透一条。忘调用 `update()` 是"画了但界面没刷新"
+的头号原因(`paintEvent` 只在 Qt 认为需要重绘时才会被调用,手动改了数据必须
+自己触发 `update()`)。
+
+---
+
+## Day4 下午 · 本地数据存储小工具
+
+对应任务卡(`OUTLINE_4DAY.md` Day4 下午作业)。这是 Day5 综合案例
+[`examples/student_manager/`](examples/student_manager/README.md) 的直接前身,
+教师验收时可参考其数据层设计(不要求学生做到那个完整度)。
+
+| | 要求 | 时间预算 |
+|---|---|---|
+| **基础版(必做)** | 「学员信息」表单(姓名/成绩/标签,3 个 `QLineEdit` + 「添加」按钮)把记录追加到列表控件(`QListWidget`/`QTableWidget` 均可);「保存」按钮把全部记录写成 JSON 文件;重启程序后「读取」按钮能把之前保存的记录读回、字段不丢、顺序不乱(至少 2 条) | ≈1.5h |
+| **挑战版(选做)** | ① 加「删除选中行」;② 数据文件路径用 `QCoreApplication::applicationDirPath()` 拼接,不用裸相对路径(当天★坑卡的实操版);③ 读取时文件不存在/JSON 格式损坏要给出友好提示而不是崩溃或静默清空(检查 `open()` 返回值和 `QJsonParseError`) | ≤1h |
+
+**验收用例**:添加 2 条记录 → 保存 → 关闭程序 → 重新打开 → 点「读取」→ 2 条记录
+都在、字段值正确;挑战版——把 JSON 文件手动改坏一个字符再读取,程序不崩溃。
+
+**给基础不理想同学的提示**:先固定用一个写死的文件名(比如 `data.json`)把
+「写入→读回」跑通,再考虑路径是否规范(挑战版①);`QJsonDocument::toJson()`/
+`fromJson()` 是配对的两端,写的时候用什么结构(数组套对象),读的时候就按同样
+结构解析回来,两边字段名要完全一致(区分大小写)。
+
+---
+
+## 给工程师/助教的验收操作提示
+
+- 11:00–12:00、16:00–17:00 两个答疑窗口,**优先确认基础版验收用例**,挑战版
+ 时间不够可以留到当晚或第二天早上补交,不阻塞后续课程进度。
+- 每天验收合格线 = 基础版全部用例通过;挑战版按完成比例加分,不设满分门槛。
+- 若某学员基础版都吃力,参照各节「给基础不理想同学的提示」拆解成更小步骤,
+ 必要时可对照教师参考实现(`examples/calculator/`、`examples/calculator_designer/`)
+ 逐段讲解,但**不要直接给学生抄源码**——抄一次治标不治本,下一天同类问题会
+ 重复出现。
diff --git a/docs/teaching/OUTLINE_4DAY.md b/docs/teaching/OUTLINE_4DAY.md
index 60eeb8d..5849a84 100644
--- a/docs/teaching/OUTLINE_4DAY.md
+++ b/docs/teaching/OUTLINE_4DAY.md
@@ -7,6 +7,8 @@
> 因此本文即新基准下的正式教案大纲;此前对齐旧方案书的 5 天版
> [`OUTLINE.md`](OUTLINE.md) 保留作历史参照与素材库(其 24 张概念/坑卡在本文
> 全部复用)。幻灯片 `day1.html`…`day5.html` **已按本文重排**(§9 落地状态)。
+> Day2–4 课堂练习/课后作业的分层(基础版+挑战版)作业单见
+> [`ASSIGNMENTS.md`](ASSIGNMENTS.md)。
## 0. 基准与显式假设(供随时纠偏)
@@ -138,7 +140,7 @@
| 环节 | 内容 | 素材 |
|---|---|---|
| 信号槽(50min 讲 + 20min 练) | 观察者模式;内置/自定义/lambda 三种写法;新旧语法对比;连接方式 Direct/Queued/Auto | `p03/ch04/builtin_signal_slot.cpp`、`custom_signal_slot.cpp`、`lambda_signal_slot.cpp` |
-| 计算器案例(40min 带练) | 数字/运算键盘 + 点击信号槽 + 结果显示;**布局用教师提供的 `QGridLayout` 脚手架「先用后学」**(布局原理 Day3 下午讲透,今天只求能排出键盘) | `p03/ch03` + `p03/ch04` + 脚手架片段(授课时现场给出) |
+| 计算器案例(40min 带练) | 数字/运算键盘 + 点击信号槽 + 结果显示;**布局用教师提供的 `QGridLayout` 脚手架「先用后学」**(布局原理 Day3 下午讲透,今天只求能排出键盘) | `p03/ch03` + `p03/ch04` + 教师参考实现 [`examples/calculator/`](examples/calculator/README.md)(另有 [`examples/calculator_designer/`](examples/calculator_designer/README.md) Qt Designer `.ui` 对照版,含「自动连接静默失效」坑点) |
概念/坑卡:**信号槽三种写法[沿用]**(含旧语法静默失败、`QOverload` 消歧义)、外加——
@@ -161,7 +163,7 @@
| 环节 | 内容 | 素材 |
|---|---|---|
-| QMainWindow(45min 讲 + 20min 练) | 主窗口架构:菜单栏/工具栏/状态栏/中心部件/停靠窗 | `p03/ch05/central_widget.cpp`、`dock_widget.cpp` |
+| QMainWindow(45min 讲 + 20min 练) | 主窗口架构:菜单栏/工具栏/状态栏/中心部件/停靠窗 | `p03/ch05/central_widget.cpp`、`dock_widget.cpp`;综合演示 [`examples/mainwindow_showcase/`](examples/mainwindow_showcase/README.md)(极简文本编辑器,.ui 表单 + 自动连接,覆盖 wiki ch05 六大组件) |
| QDialog(45min 讲 + 20min 练) | 模态 vs 非模态(三种非模态写法对比:栈/堆/`WA_DeleteOnClose`);标准对话框 `QMessageBox`/`QFileDialog` | `p03/ch06/modal_dialog.cpp`、`modeless_dialog_stack.cpp`、`modeless_dialog_heap.cpp`、`modeless_dialog_deleteonclose.cpp`、`message_box_question.cpp`、`file_dialog.cpp` |
概念/坑卡(本节两张为本文新写,5 天版未覆盖 ch05/ch06):
@@ -176,8 +178,15 @@
析构,窗口一闪而过**——`p03/ch06` 三个 modeless 版本正是这个坑的对照教材,也是
Day2 栈/堆所有权卡的第三次复现。
-11:00–12:00 课堂练习:给昨天的计算器套上 QMainWindow——加菜单「帮助→关于」弹
-`QMessageBox`;加一个「历史」停靠窗(可先放空 QLabel)。
+11:00–12:00 课堂练习:以 Day2 增强版计算器([`examples/calculator_designer_ext/`](examples/calculator_designer_ext/README.md))为起点,套上 QMainWindow——加菜单「帮助→关于」弹
+`QMessageBox`;加一个「历史」停靠窗(可先放空 QLabel)。参考实现:
+[`examples/calculator_mainwindow/`](examples/calculator_mainwindow/README.md)(纯代码
+建菜单 + 显式 connect,与 `mainwindow_showcase` 的 `.ui` 自动连接形成坑卡对照)。
+
+> 衔接说明:Day2 增强版 `CalculatorDesigner` 已是独立 `QWidget`,Day3 上午只做
+> 「外壳升级」——`setCentralWidget` 把它装进 `QMainWindow`,核心求值逻辑不动。
+> 唯一新增是 `CalculatorDesigner` 加一个 `evaluated` 信号供历史停靠窗订阅(Day2
+> 信号槽知识的复用)。详见参考实现 README。
### Day 3 下午:布局管理器 / 常用控件(+QSS 美化)
@@ -185,7 +194,7 @@
|---|---|---|
| 布局管理器(40min 讲 + 15min 练) | QHBox/QVBox/QGrid;嵌套布局;伸缩因子——回收 Day2 计算器脚手架,讲透当时「先用后学」的部分 | `p03/ch08/custom_widget.cpp` |
| 常用控件(35min 讲 + 15min 练) | QLabel 文本/HTML/图片、QLineEdit、按钮族;资源系统 `.qrc` | `p03/ch08/label_text_html.cpp`、`label_pixmap.cpp`、`label_movie.cpp` |
-| QSS 界面美化(25min 讲 + 20min 练) | 选择器/伪状态/层叠——对应新安排「自适应排版及**美化**」目标 | `docs/teaching/examples/qss_styling/qt_qss_styling.cpp` + [README](examples/qss_styling/README.md) |
+| QSS 界面美化(25min 讲 + 20min 练) | 选择器/伪状态/层叠——对应新安排「自适应排版及**美化**」目标 | `docs/teaching/examples/qss_styling/qt_qss_styling.cpp` + [README](examples/qss_styling/README.md);作业线参考 [`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md)(上午版 + 双主题 QSS) |
概念/坑卡:**布局管理[沿用]**(同控件不能属两个布局;布局后 `setGeometry` 被
覆盖)、**资源系统 .qrc[沿用]**(路径静默失败/AUTORCC)、**QSS 美化[沿用]**
@@ -193,6 +202,8 @@
16:00–17:00 作业:计算器升级——数字键/运算键/等号键用 QSS 区分颜色,至少 1 个按钮
有 `:hover` 或 `:pressed` 效果;整体布局改为可随窗口缩放(验收同 5 天版 QSS 任务卡)。
+参考实现:[`examples/calculator_mainwindow_qss/`](examples/calculator_mainwindow_qss/README.md)(在
+Day3 上午版基础上只加 QSS,演示「美化与逻辑正交」)。
### Day 4 上午:Qt 消息机制和事件 / 绘图事件及表格事件
@@ -395,5 +406,25 @@ QThread 提升环节**——若 Day5 案例讲解超时挤掉它,项目期「
3. ✅ **Day5 综合案例参考实现**:`docs/teaching/examples/student_manager/`
(QMainWindow + QTableWidget + 对话框 + JSON round-trip + QThread 后台导入;
离屏自测模式自动校验「写入→读回」与导入闭环)。
+3.1 ✅ **Day2 案例参考实现**:`docs/teaching/examples/calculator/`(代码
+ `QGridLayout` 版)+ `docs/teaching/examples/calculator_designer/`(Qt Designer
+ `.ui` 对照版,含自动连接 `on__` 静默失效坑点);两版业务
+ 逻辑一致,离屏自测覆盖加减乘除/链式运算/除零/退格 5 组用例。
+3.2 ✅ **Day2 增强版计算器**:`docs/teaching/examples/calculator_designer_ext/`
+ (在 Designer 版基础上把「立即计算」升级为「表达式求值」——独立 `ExpressionEvaluator`
+ 递归下降解析器,支持运算符优先级 + 括号含嵌套/未闭合自动补全;双显示区;离屏自测
+ 9 组用例含 `2+3*4=14`、`(2+3)*4=20`)。作为 Day3 的起点。
+3.3 ✅ **Day3 上午 QMainWindow 组件综合演示**:`docs/teaching/examples/mainwindow_showcase/`
+ (极简文本编辑器形态,`.ui` 表单 + `.qrc` 资源 + `on__` 自动连接,覆盖
+ wiki ch05 的 5.1–5.6 六大组件;离屏退出码 0)。教师讲组件协议时用,与下方
+ `calculator_mainwindow` 形成 `.ui`/自动连接 vs 纯代码/显式 connect 的坑卡对照。
+3.4 ✅ **Day3 上午参考实现**:`docs/teaching/examples/calculator_mainwindow/`
+ (把 Day2 增强版 `CalculatorDesigner` 装进 `QMainWindow`:菜单/状态栏/历史停靠窗;
+ `CalculatorDesigner` 仅加 `evaluated` 信号 + `loadExpression` 方法供历史订阅/回填;
+ 纯代码建菜单 + 显式 connect;离屏自测 9 组计算 + 历史追加/双击回填/清空)。
+3.5 ✅ **Day3 下午参考实现**:`docs/teaching/examples/calculator_mainwindow_qss/`
+ (上午版 + QSS 双主题美化:按键按功能分色 + `:hover`/`:pressed` + 深/浅主题切换;
+ 与上午版仅 `mainwindow.{h,cpp}` 差异,演示「美化与逻辑正交」;离屏自测含主题切换
+ 改变 `styleSheet` 的断言)。
4. ✅ 新 10 天安排正式文本已落入 [`docs/SCHEDULE_2026_10DAY.md`](../SCHEDULE_2026_10DAY.md);
`OUTLINE.md` §0 与幻灯片 `index.html` 已补两版关系说明。
diff --git a/docs/teaching/examples/CMakeLists.txt b/docs/teaching/examples/CMakeLists.txt
index 7482b23..201f416 100644
--- a/docs/teaching/examples/CMakeLists.txt
+++ b/docs/teaching/examples/CMakeLists.txt
@@ -25,9 +25,89 @@ add_teaching_example(qt_pitfall_stack_widget pitfall_stack_widget)
add_teaching_example(qt_pitfall_lambda_capture pitfall_lambda_capture)
add_teaching_example(qt_pitfall_double_connect pitfall_double_connect)
add_teaching_example(qt_table_widget_basics table_widget)
+# Day2 下午案例参考实现(OUTLINE_4DAY.md Day2 下午「简易计算器」)
+add_teaching_example(qt_calculator calculator)
+
+# Day2 案例的 Qt Designer(.ui) 对照版:界面用表单设计、业务逻辑与 qt_calculator
+# 一致。需要 AUTOUIC(add_teaching_example 只开 AUTOMOC),单独写 target。
+add_executable(qt_calculator_designer
+ calculator_designer/qt_calculator_designer.cpp
+ calculator_designer/calculator_designer.ui)
+set_target_properties(qt_calculator_designer PROPERTIES
+ CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
+ AUTOMOC ON AUTOUIC ON)
+target_link_libraries(qt_calculator_designer PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
+target_compile_options(qt_calculator_designer PRIVATE -Wall -Wextra)
+
+# Day2 增强版计算器(calculator_designer_ext):在 Designer 版基础上把「立即计算」
+# 升级为「表达式求值」——独立 ExpressionEvaluator 用递归下降解析器实现运算符优先级
+# 与括号(含嵌套、未闭合自动补全)。多源文件 + .ui 表单,单独写 target。
+# 注意:子目录里那个 Qt Creator 导出的 CMakeLists.txt 不参与仓库构建(C++11 /
+# 目标名 Calculator / ANDROID 分支均不符仓库 C++17 规范),构建一律走本 target。
+add_executable(qt_calculator_designer_ext
+ calculator_designer_ext/main.cpp
+ calculator_designer_ext/calculatordesigner.cpp
+ calculator_designer_ext/expressionevaluator.cpp
+ calculator_designer_ext/widget.ui)
+target_include_directories(qt_calculator_designer_ext PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/calculator_designer_ext)
+set_target_properties(qt_calculator_designer_ext PROPERTIES
+ CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
+ AUTOMOC ON AUTOUIC ON)
+target_link_libraries(qt_calculator_designer_ext PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
+target_compile_options(qt_calculator_designer_ext PRIVATE -Wall -Wextra)
+
# Day5 综合案例参考实现(OUTLINE_4DAY.md §3 / §9.3)
add_teaching_example(qt_student_manager student_manager)
+# QMainWindow 教学综合示例(wiki「5 QMainWindow」pageId 58954529 的 5.1–5.6 全组件):
+# form designer(.ui) 定义主窗体结构、.qrc 资源文件提供应用图标。单独写 target
+# (标准 add_teaching_example 只开 AUTOMOC,本例还需 AUTOUIC+AUTORCC)。
+add_executable(qt_mainwindow_showcase
+ mainwindow_showcase/main.cpp
+ mainwindow_showcase/mainwindow_showcase.cpp
+ mainwindow_showcase/mainwindow_showcase.h
+ mainwindow_showcase/mainwindow.ui
+ mainwindow_showcase/mainwindow.qrc)
+set_target_properties(qt_mainwindow_showcase PROPERTIES
+ CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
+ AUTOMOC ON AUTOUIC ON AUTORCC ON)
+target_link_libraries(qt_mainwindow_showcase PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
+target_compile_options(qt_mainwindow_showcase PRIVATE -Wall -Wextra)
+
+# Day3 上午版参考实现:把 Day2 增强版计算器装进 QMainWindow(菜单/状态栏/历史停靠窗)。
+# 复用 Day2 的 calculatordesigner + expressionevaluator + widget.ui(自包含复制),
+# 新增 mainwindow.{h,cpp} 外壳 + 含 runSelfTest 的 main.cpp。无 QSS(QSS 在下午版)。
+add_executable(qt_calculator_mainwindow
+ calculator_mainwindow/main.cpp
+ calculator_mainwindow/mainwindow.cpp
+ calculator_mainwindow/calculatordesigner.cpp
+ calculator_mainwindow/expressionevaluator.cpp
+ calculator_mainwindow/widget.ui)
+target_include_directories(qt_calculator_mainwindow PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/calculator_mainwindow)
+set_target_properties(qt_calculator_mainwindow PROPERTIES
+ CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
+ AUTOMOC ON AUTOUIC ON)
+target_link_libraries(qt_calculator_mainwindow PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
+target_compile_options(qt_calculator_mainwindow PRIVATE -Wall -Wextra)
+
+# Day3 下午版参考实现:上午版 + QSS 双主题美化。复用上午版同款 5 文件(自包含复制),
+# 仅 mainwindow.{h,cpp} 加双主题 QSS + 视图菜单「深色主题」、main.cpp 自测加主题断言。
+add_executable(qt_calculator_mainwindow_qss
+ calculator_mainwindow_qss/main.cpp
+ calculator_mainwindow_qss/mainwindow.cpp
+ calculator_mainwindow_qss/calculatordesigner.cpp
+ calculator_mainwindow_qss/expressionevaluator.cpp
+ calculator_mainwindow_qss/widget.ui)
+target_include_directories(qt_calculator_mainwindow_qss PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/calculator_mainwindow_qss)
+set_target_properties(qt_calculator_mainwindow_qss PROPERTIES
+ CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON CXX_EXTENSIONS OFF
+ AUTOMOC ON AUTOUIC ON)
+target_link_libraries(qt_calculator_mainwindow_qss PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
+target_compile_options(qt_calculator_mainwindow_qss PRIVATE -Wall -Wextra)
+
add_custom_target(all_teaching_examples DEPENDS
qt_qss_styling
qt_model_view_basics
@@ -39,4 +119,10 @@ add_custom_target(all_teaching_examples DEPENDS
qt_pitfall_lambda_capture
qt_pitfall_double_connect
qt_table_widget_basics
- qt_student_manager)
+ qt_calculator
+ qt_calculator_designer
+ qt_calculator_designer_ext
+ qt_student_manager
+ qt_mainwindow_showcase
+ qt_calculator_mainwindow
+ qt_calculator_mainwindow_qss)
diff --git a/docs/teaching/examples/calculator/README.md b/docs/teaching/examples/calculator/README.md
new file mode 100644
index 0000000..9f1dbcc
--- /dev/null
+++ b/docs/teaching/examples/calculator/README.md
@@ -0,0 +1,51 @@
+# 简易计算器 —— Day2 下午案例参考实现(代码布局版)
+
+> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午「信号槽机制 →
+> 项目案例:简易计算器」。教师侧参考答案,手工维护,非生成产物。
+> Designer 表单对照版见 [`../calculator_designer/`](../calculator_designer/README.md)。
+
+## 覆盖的知识点(讲解时逐条回指)
+
+| 环节 | 知识点 | 首次出现 |
+|---|---|---|
+| 界面骨架 | `QGridLayout` 排数字/运算符键盘 | Day2 下午(脚手架先用后学,Day3 下午讲透原理) |
+| 信号槽写法 1 | lambda + 第五参数 context(数字/运算符键批量绑定) | Day2 下午 |
+| 信号槽写法 2 | 成员函数指针连接(等号/清空/退格键) | Day2 下午 |
+| 坑点规避 | 所有 `connect` 只在构造函数执行一次,不留「重复 connect」隐患 | Day2 ★坑卡 |
+| 编码 | 中文/符号按钮文字 `QStringLiteral` + 源文件 UTF-8 | Day2 ★坑卡 |
+| 状态机 | 数字/小数点/运算符/等号/清空/退格 六类输入的最小状态机;连续按运算符走「链式计算」;除零给出 `Error` 而不崩溃 | 案例本身 |
+
+## 验证方式(自测模式)
+
+```bash
+QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator
+# 自测流程:直接调用与按钮绑定的同一批处理函数(等价于点击),依次验证
+# 「7+3=10」「6÷0=Error」「4.5+0.5=5」「12 退格→1」「2+3+4=9(链式运算)」
+# 全部通过退出码 0,任一步失败退出码 1
+tools/run_qt.sh qt_calculator # 有 X 环境:完整交互
+```
+
+## 课堂讲解建议
+
+1. 先跑成品:现场敲一次「7 + 3 =」,再回头拆代码——项目 = 已学知识点的组装;
+2. 重点讲两种 connect 写法的取舍:数字/运算符键数量多、逻辑相同,用
+ lambda 捕获参数 + 统一槽函数(`onDigit(int)`/`onOperator(QChar)`);
+ 等号/清空/退格各自唯一,直接用成员函数指针更直观;
+3. 现场移除某个按钮的 `connect`,演示"按钮显示正常、点击没反应"——呼应
+ QAction 忘 connect 的坑(Day3 会再见到同一类问题);
+4. 除零处理是刻意设计的教学点:不用 `assert`/崩溃,而是给出可恢复的错误状态
+ (`Error` + 下一次输入自动清空),这是「健壮性」在中初级学员能理解层面的
+ 最小示范。
+
+## 练习方向(对应任务卡 2 · 课后作业)
+
+见 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午「16:00–17:00 作业」与
+`slides/day2.html` 任务卡 2;本参考实现即该作业的教师侧标准答案,**不要在课上
+直接展示源码给学生抄**,讲解思路后学生自己实现。
+
+## 参考
+
+- 布局脚手架来源:`p03/ch08/custom_widget.cpp`(`QHBoxLayout`/`QSlider` 联动)
+- 信号槽写法来源:`p03/ch04/`(`builtin_signal_slot.cpp`、`custom_signal_slot.cpp`、
+ `lambda_signal_slot.cpp`)
+- Designer 表单对照版:[`../calculator_designer/`](../calculator_designer/README.md)
diff --git a/docs/teaching/examples/calculator/qt_calculator.cpp b/docs/teaching/examples/calculator/qt_calculator.cpp
new file mode 100644
index 0000000..11f796d
--- /dev/null
+++ b/docs/teaching/examples/calculator/qt_calculator.cpp
@@ -0,0 +1,234 @@
+// ============================================================================
+// qt_calculator —— Day2 下午案例参考实现:简易计算器
+// 对应教案:OUTLINE_4DAY.md Day2 下午「信号槽机制 → 项目案例:简易计算器」
+// 配套文档:docs/teaching/examples/calculator/README.md
+// 手工维护,非生成产物(同 student_manager/qthread_worker 等教学补充示例)。
+//
+// 教学重点(对应当天知识点,逐条在代码里标注):
+// 1. 信号槽两种写法都用上:数字/运算符键用「lambda + this 作 context」批量
+// 绑定,等号/清空/退格键用「成员函数指针」绑定——当天两种写法都要会认;
+// 2. 所有 connect 只在构造函数里执行一次,不给「重复 connect 导致槽触发
+// 两次」的坑(当天★坑卡)留生长空间;
+// 3. QGridLayout 排键盘(布局原理 Day3 下午讲透,今天先用后学);
+// 4. 中文/符号按钮文字用 QStringLiteral + 源文件 UTF-8,呼应当天编码坑卡。
+//
+// 离屏验证(自测模式):QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator
+// 自测流程:直接调用与按钮绑定的同一批处理函数(等价于点击),依次验证
+// 「7+3=」「6÷0=」「4.5+0.5=」「12 退格」「2+3+4= 链式运算」,全部通过退出码 0,
+// 任一步失败退出码 1。
+// ============================================================================
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+class Calculator : public QWidget {
+ Q_OBJECT
+public:
+ explicit Calculator(QWidget* parent = nullptr) : QWidget(parent) {
+ setWindowTitle(QStringLiteral("简易计算器"));
+
+ m_display = new QLineEdit(QStringLiteral("0"), this);
+ m_display->setReadOnly(true); // 只显示结果,不接受直接键入
+ m_display->setAlignment(Qt::AlignRight);
+
+ auto* mainLayout = new QVBoxLayout(this);
+ mainLayout->addWidget(m_display);
+ mainLayout->addLayout(buildKeypad());
+ }
+
+ // 供自测直接调用,效果等价于点击对应按钮(同一批处理函数)。
+ void onDigit(int digit) {
+ if (m_error) onClear();
+ if (m_startNew || m_display->text() == QStringLiteral("0")) {
+ m_display->setText(QString::number(digit));
+ m_startNew = false;
+ } else {
+ m_display->setText(m_display->text() + QString::number(digit));
+ }
+ }
+
+ void onDot() {
+ if (m_error) onClear();
+ if (m_startNew) {
+ m_display->setText(QStringLiteral("0."));
+ m_startNew = false;
+ return;
+ }
+ if (!m_display->text().contains(QLatin1Char('.')))
+ m_display->setText(m_display->text() + QStringLiteral("."));
+ }
+
+ void onOperator(QChar op) {
+ if (m_error) return;
+ if (!m_startNew) compute(); // 连续按运算符:先把上一次算掉,再开始下一次(链式计算)
+ m_pendingValue = m_display->text().toDouble();
+ m_pendingOp = op;
+ m_startNew = true;
+ }
+
+ void onEquals() {
+ if (m_error) return;
+ compute();
+ m_pendingOp = QChar(); // 等号后清空待运算符:再按数字视为全新一次输入
+ }
+
+ void onClear() {
+ m_display->setText(QStringLiteral("0"));
+ m_pendingValue = 0.0;
+ m_pendingOp = QChar();
+ m_startNew = false;
+ m_error = false;
+ }
+
+ void onBackspace() {
+ if (m_error) { onClear(); return; }
+ QString text = m_display->text();
+ text.chop(1);
+ m_display->setText(text.isEmpty() ? QStringLiteral("0") : text);
+ }
+
+ QString displayText() const { return m_display->text(); }
+
+private:
+ QGridLayout* buildKeypad() {
+ auto* grid = new QGridLayout; // 布局原理 Day3 下午讲透,今天先会用:行列坐标摆键盘
+
+ addDigit(grid, 7, 0, 0); addDigit(grid, 8, 0, 1); addDigit(grid, 9, 0, 2);
+ addOperator(grid, QStringLiteral("÷"), QLatin1Char('/'), 0, 3);
+
+ addDigit(grid, 4, 1, 0); addDigit(grid, 5, 1, 1); addDigit(grid, 6, 1, 2);
+ addOperator(grid, QStringLiteral("×"), QLatin1Char('*'), 1, 3);
+
+ addDigit(grid, 1, 2, 0); addDigit(grid, 2, 2, 1); addDigit(grid, 3, 2, 2);
+ addOperator(grid, QStringLiteral("-"), QLatin1Char('-'), 2, 3);
+
+ addDigit(grid, 0, 3, 0);
+
+ auto* btnDot = new QPushButton(QStringLiteral("."), this);
+ grid->addWidget(btnDot, 3, 1);
+ connect(btnDot, &QPushButton::clicked, this, [this] { onDot(); });
+
+ // 等号/清空/退格各自唯一,用成员函数指针连接——信号槽的另一种常见写法。
+ auto* btnEquals = new QPushButton(QStringLiteral("="), this);
+ grid->addWidget(btnEquals, 3, 2);
+ connect(btnEquals, &QPushButton::clicked, this, &Calculator::onEquals);
+
+ addOperator(grid, QStringLiteral("+"), QLatin1Char('+'), 3, 3);
+
+ auto* btnClear = new QPushButton(QStringLiteral("C"), this);
+ grid->addWidget(btnClear, 4, 0);
+ connect(btnClear, &QPushButton::clicked, this, &Calculator::onClear);
+
+ auto* btnBackspace = new QPushButton(QStringLiteral("⌫"), this);
+ grid->addWidget(btnBackspace, 4, 1);
+ connect(btnBackspace, &QPushButton::clicked, this, &Calculator::onBackspace);
+
+ return grid;
+ }
+
+ // 数字键:同一个槽 onDigit(int) 处理全部按钮,lambda 捕获各自的数字值,
+ // 第五参数传 this 作 context——calculator 销毁时这些连接自动断开(当天坑卡)。
+ void addDigit(QGridLayout* grid, int digit, int row, int col) {
+ auto* btn = new QPushButton(QString::number(digit), this);
+ grid->addWidget(btn, row, col);
+ connect(btn, &QPushButton::clicked, this, [this, digit] { onDigit(digit); });
+ }
+
+ // 运算符键:同理,lambda + context,一个槽 onOperator(QChar) 处理全部。
+ void addOperator(QGridLayout* grid, const QString& label, QChar op, int row, int col) {
+ auto* btn = new QPushButton(label, this);
+ grid->addWidget(btn, row, col);
+ connect(btn, &QPushButton::clicked, this, [this, op] { onOperator(op); });
+ }
+
+ void compute() {
+ if (m_pendingOp.isNull()) return; // 还没有待运算符(本次是第一个运算符)
+ const double rhs = m_display->text().toDouble();
+ double result = 0.0;
+ if (m_pendingOp == QLatin1Char('+')) result = m_pendingValue + rhs;
+ else if (m_pendingOp == QLatin1Char('-')) result = m_pendingValue - rhs;
+ else if (m_pendingOp == QLatin1Char('*')) result = m_pendingValue * rhs;
+ else if (m_pendingOp == QLatin1Char('/')) {
+ if (rhs == 0.0) { showError(); return; } // 除零:不崩,给出明确的错误状态
+ result = m_pendingValue / rhs;
+ }
+ m_display->setText(QString::number(result, 'g', 12));
+ m_pendingValue = result;
+ m_startNew = true;
+ }
+
+ void showError() {
+ m_display->setText(QStringLiteral("Error"));
+ m_error = true;
+ m_startNew = true;
+ }
+
+ QLineEdit* m_display = nullptr;
+ double m_pendingValue = 0.0;
+ QChar m_pendingOp;
+ bool m_startNew = false;
+ bool m_error = false;
+};
+
+// ------------------------------------------------------------ 自测入口 ----
+
+static bool expectEqual(const char* label, const QString& actual, const QString& expected) {
+ if (actual != expected) {
+ qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
+ return false;
+ }
+ qInfo() << "自测通过:" << label << "=>" << actual;
+ return true;
+}
+
+static bool runSelfTest(Calculator* calc) {
+ calc->onClear();
+ calc->onDigit(7); calc->onOperator(QLatin1Char('+')); calc->onDigit(3); calc->onEquals();
+ if (!expectEqual("7 + 3 =", calc->displayText(), QStringLiteral("10"))) return false;
+
+ calc->onClear();
+ calc->onDigit(6); calc->onOperator(QLatin1Char('/')); calc->onDigit(0); calc->onEquals();
+ if (!expectEqual("6 ÷ 0 =", calc->displayText(), QStringLiteral("Error"))) return false;
+
+ calc->onClear();
+ calc->onDigit(4); calc->onDot(); calc->onDigit(5);
+ calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(0); calc->onDot(); calc->onDigit(5);
+ calc->onEquals();
+ if (!expectEqual("4.5 + 0.5 =", calc->displayText(), QStringLiteral("5"))) return false;
+
+ calc->onClear();
+ calc->onDigit(1); calc->onDigit(2); calc->onBackspace();
+ if (!expectEqual("12 退格", calc->displayText(), QStringLiteral("1"))) return false;
+
+ calc->onClear();
+ calc->onDigit(2); calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(3); calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(4); calc->onEquals();
+ if (!expectEqual("2 + 3 + 4 =(链式运算)", calc->displayText(), QStringLiteral("9"))) return false;
+
+ return true;
+}
+
+int main(int argc, char* argv[]) {
+ QApplication app(argc, argv);
+ Calculator calc;
+ calc.resize(240, 320);
+ calc.show();
+
+ if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
+ QTimer::singleShot(0, &calc, [&calc] {
+ const bool ok = runSelfTest(&calc);
+ QCoreApplication::exit(ok ? 0 : 1);
+ });
+ }
+ return app.exec();
+}
+
+#include "qt_calculator.moc"
diff --git a/docs/teaching/examples/calculator_designer/README.md b/docs/teaching/examples/calculator_designer/README.md
new file mode 100644
index 0000000..58a1e6b
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer/README.md
@@ -0,0 +1,59 @@
+# 简易计算器 —— Qt Designer 表单对照版
+
+> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day2 下午「项目案例:简易
+> 计算器」。与 [`../calculator/`](../calculator/README.md)(纯代码 `QGridLayout`
+> 版)对照阅读:**界面搭建方式换了,业务逻辑一字不差**——`onDigit`/`onOperator`/
+> `onEquals`/`onClear`/`onBackspace`/`compute` 全部照搬。手工维护,非生成产物。
+
+## 与代码版的唯一区别:界面从哪来
+
+| | 代码版 `qt_calculator.cpp` | Designer 版 `qt_calculator_designer.cpp` |
+|---|---|---|
+| 控件树+布局 | `buildKeypad()` 手写 `new QPushButton` + `QGridLayout::addWidget` | `calculator_designer.ui`(Designer 表单 XML)+ `uic` 编译期生成 `ui_calculator_designer.h` + `setupUi(this)` 一次建好 |
+| 找控件 | 局部变量/成员指针 | `ui->btn7`、`ui->display` ……(objectName 对应成员名) |
+| 构建开关 | `AUTOMOC ON` | `AUTOMOC ON` **+ `AUTOUIC ON`**(CMake 自动跑 `uic`) |
+| 业务逻辑 | 状态机 | 完全相同 |
+
+## ★ 新增知识点:Designer 表单的「自动连接」
+
+`ui->setupUi(this)` 内部会调用 `QMetaObject::connectSlotsByName(this)`:只要
+类里有名为 `on__()` 的槽(比如 `on_btnClear_clicked()`),
+Qt 会**自动**把表单里 `objectName` 为 `btnClear` 的控件的 `clicked()` 信号连上去,
+不需要手写 `connect()`。这是信号槽的第三种写法(前两种见 `../calculator/`)。
+
+**本示例故意不用这个自动连接**,全部改成显式 `connect()`——原因是当天的坑卡:
+
+- 坑:表单里把按钮 `objectName` 从 `btnClear` 改成 `btnReset`,`on_btnClear_clicked()`
+ 这个槽**不报编译错误、不报运行时错误**,只是从此再也不会被调用——`connectSlotsByName`
+ 找不到匹配的 `objectName` 时只是静默跳过。这是「静默失败」坑点家族的第三个成员
+ (前两个是旧 `SIGNAL()/SLOT()` 字符串语法、重复 `connect`)。
+- 结论:显式 `connect()` 虽然多写几行,但改名/删控件时编译器/IDE 能帮你追踪引用,
+ 排查成本远低于依赖命名约定的隐式连接。项目里**推荐显式 connect**,`on_xxx_yyy()`
+ 自动连接更适合 Qt Designer 快速原型阶段。
+
+## 验证方式(自测模式)
+
+```bash
+QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator_designer
+# 自测用例与 ../calculator/ 完全一致:「7+3=10」「6÷0=Error」「4.5+0.5=5」
+# 「12 退格→1」「2+3+4=9(链式运算)」,全部通过退出码 0
+tools/run_qt.sh qt_calculator_designer # 有 X 环境:完整交互
+```
+
+## 课堂讲解建议
+
+1. 先展示 `.ui` 文件本身是纯 XML(`…`)——
+ Qt Designer 只是这份 XML 的图形化编辑器,不是什么黑魔法;
+2. 对照 `../calculator/qt_calculator.cpp` 的 `buildKeypad()`,逐行说明
+ `uic` 把这份 XML 翻译成了等价的 C++ 建控件代码(`build/` 里能翻出真实生成的
+ `ui_calculator_designer.h` 验证);
+3. 演示自动连接的坑:把 `.ui` 里某个按钮的 `name` 改掉,重新构建,点击按钮
+ 没反应——现场复现「改名静默失效」;
+4. 讨论:两种方式怎么选?—— 快速原型/美术同学调界面用 Designer 更直观;
+ 逻辑复杂、控件数量随数据动态变化(比如 Day5 的表格行)就必须手写代码搭建,
+ `.ui` 表单描述不了「运行时循环创建 N 个控件」。
+
+## 参考
+
+- 代码布局对照版:[`../calculator/`](../calculator/README.md)
+- Qt 官方 Widgets 表单机制:`uic`(User Interface Compiler)+ `QMetaObject::connectSlotsByName`
diff --git a/docs/teaching/examples/calculator_designer/calculator_designer.ui b/docs/teaching/examples/calculator_designer/calculator_designer.ui
new file mode 100644
index 0000000..7dcb8c7
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer/calculator_designer.ui
@@ -0,0 +1,164 @@
+
+
+ CalculatorForm
+
+
+
+ 0
+ 0
+ 358
+ 320
+
+
+
+ 简易计算器(Designer 版)
+
+
+ -
+
+
+ 0
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ true
+
+
+
+ -
+
+
-
+
+
+ 7
+
+
+
+ -
+
+
+ 8
+
+
+
+ -
+
+
+ 9
+
+
+
+ -
+
+
+ ÷
+
+
+
+ -
+
+
+ 4
+
+
+
+ -
+
+
+ 5
+
+
+
+ -
+
+
+ 6
+
+
+
+ -
+
+
+ ×
+
+
+
+ -
+
+
+ 1
+
+
+
+ -
+
+
+ 2
+
+
+
+ -
+
+
+ 3
+
+
+
+ -
+
+
+ -
+
+
+
+ -
+
+
+ 0
+
+
+
+ -
+
+
+ .
+
+
+
+ -
+
+
+ =
+
+
+
+ -
+
+
+ +
+
+
+
+ -
+
+
+ C
+
+
+
+ -
+
+
+ ⌫
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/teaching/examples/calculator_designer/qt_calculator_designer.cpp b/docs/teaching/examples/calculator_designer/qt_calculator_designer.cpp
new file mode 100644
index 0000000..fed60a6
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer/qt_calculator_designer.cpp
@@ -0,0 +1,202 @@
+// ============================================================================
+// qt_calculator_designer —— Day2 案例参考实现(Qt Designer 表单版)
+// 对应教案:OUTLINE_4DAY.md Day2 下午「项目案例:简易计算器」
+// 配套文档:docs/teaching/examples/calculator_designer/README.md
+// 手工维护,非生成产物。
+//
+// 与 ../calculator/qt_calculator.cpp(纯代码 QGridLayout 版)对照阅读:
+// 界面结构完全一致,唯一区别是键盘布局用 Qt Designer 表单
+// (calculator_designer.ui)设计——编译期由 uic 生成 ui_calculator_designer.h,
+// setupUi(this) 一次性把控件树 + 布局全部建好;业务逻辑(onDigit/onOperator/
+// onEquals/...)与代码版完全相同、原样照搬,刻意保持一致以突出「界面搭建方式
+// 变了,逻辑代码不用变」。
+//
+// 教学重点(对照代码版新增的一条):
+// Designer 表单里控件的 objectName 决定「自动连接」——setupUi() 内部会调用
+// QMetaObject::connectSlotsByName(this),自动把名为 on__()
+// 的槽连到对应控件的信号。本例改用显式 connect()(更贴近代码版、也更利于
+// 团队协作排查),但这个自动连接特性是 Designer 工作流特有的第三种信号槽写法,
+// 坑点见 README:控件改名后自动连接悄悄失效,不报编译错误也不报运行时错误。
+//
+// 离屏验证(自测模式):QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_calculator_designer
+// 自测用例与 ../calculator/ 完全一致,用于验证「界面来源不同、行为一致」。
+// ============================================================================
+
+#include
+#include
+#include
+#include
+
+#include "ui_calculator_designer.h"
+
+class CalculatorDesigner : public QWidget {
+ Q_OBJECT
+public:
+ explicit CalculatorDesigner(QWidget* parent = nullptr)
+ : QWidget(parent), ui(new Ui::CalculatorForm) {
+ ui->setupUi(this); // 表单里的控件树 + QGridLayout 全部由 Designer XML 建好
+
+ connect(ui->btn0, &QPushButton::clicked, this, [this] { onDigit(0); });
+ connect(ui->btn1, &QPushButton::clicked, this, [this] { onDigit(1); });
+ connect(ui->btn2, &QPushButton::clicked, this, [this] { onDigit(2); });
+ connect(ui->btn3, &QPushButton::clicked, this, [this] { onDigit(3); });
+ connect(ui->btn4, &QPushButton::clicked, this, [this] { onDigit(4); });
+ connect(ui->btn5, &QPushButton::clicked, this, [this] { onDigit(5); });
+ connect(ui->btn6, &QPushButton::clicked, this, [this] { onDigit(6); });
+ connect(ui->btn7, &QPushButton::clicked, this, [this] { onDigit(7); });
+ connect(ui->btn8, &QPushButton::clicked, this, [this] { onDigit(8); });
+ connect(ui->btn9, &QPushButton::clicked, this, [this] { onDigit(9); });
+ connect(ui->btnDot, &QPushButton::clicked, this, [this] { onDot(); });
+
+ connect(ui->btnPlus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('+')); });
+ connect(ui->btnMinus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('-')); });
+ connect(ui->btnMul, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('*')); });
+ connect(ui->btnDiv, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('/')); });
+
+ connect(ui->btnEquals, &QPushButton::clicked, this, &CalculatorDesigner::onEquals);
+ connect(ui->btnClear, &QPushButton::clicked, this, &CalculatorDesigner::onClear);
+ connect(ui->btnBackspace, &QPushButton::clicked, this, &CalculatorDesigner::onBackspace);
+ }
+
+ ~CalculatorDesigner() override { delete ui; }
+
+ // 供自测直接调用,效果等价于点击对应按钮;逻辑与 ../calculator/ 版一字不差。
+ void onDigit(int digit) {
+ if (m_error) onClear();
+ if (m_startNew || ui->display->text() == QStringLiteral("0")) {
+ ui->display->setText(QString::number(digit));
+ m_startNew = false;
+ } else {
+ ui->display->setText(ui->display->text() + QString::number(digit));
+ }
+ }
+
+ void onDot() {
+ if (m_error) onClear();
+ if (m_startNew) {
+ ui->display->setText(QStringLiteral("0."));
+ m_startNew = false;
+ return;
+ }
+ if (!ui->display->text().contains(QLatin1Char('.')))
+ ui->display->setText(ui->display->text() + QStringLiteral("."));
+ }
+
+ void onOperator(QChar op) {
+ if (m_error) return;
+ if (!m_startNew) compute();
+ m_pendingValue = ui->display->text().toDouble();
+ m_pendingOp = op;
+ m_startNew = true;
+ }
+
+ void onEquals() {
+ if (m_error) return;
+ compute();
+ m_pendingOp = QChar();
+ }
+
+ void onClear() {
+ ui->display->setText(QStringLiteral("0"));
+ m_pendingValue = 0.0;
+ m_pendingOp = QChar();
+ m_startNew = false;
+ m_error = false;
+ }
+
+ void onBackspace() {
+ if (m_error) { onClear(); return; }
+ QString text = ui->display->text();
+ text.chop(1);
+ ui->display->setText(text.isEmpty() ? QStringLiteral("0") : text);
+ }
+
+ QString displayText() const { return ui->display->text(); }
+
+private:
+ void compute() {
+ if (m_pendingOp.isNull()) return;
+ const double rhs = ui->display->text().toDouble();
+ double result = 0.0;
+ if (m_pendingOp == QLatin1Char('+')) result = m_pendingValue + rhs;
+ else if (m_pendingOp == QLatin1Char('-')) result = m_pendingValue - rhs;
+ else if (m_pendingOp == QLatin1Char('*')) result = m_pendingValue * rhs;
+ else if (m_pendingOp == QLatin1Char('/')) {
+ if (rhs == 0.0) { showError(); return; }
+ result = m_pendingValue / rhs;
+ }
+ ui->display->setText(QString::number(result, 'g', 12));
+ m_pendingValue = result;
+ m_startNew = true;
+ }
+
+ void showError() {
+ ui->display->setText(QStringLiteral("Error"));
+ m_error = true;
+ m_startNew = true;
+ }
+
+ Ui::CalculatorForm* ui;
+ double m_pendingValue = 0.0;
+ QChar m_pendingOp;
+ bool m_startNew = false;
+ bool m_error = false;
+};
+
+// ------------------------------------------------------------ 自测入口 ----
+// 与 ../calculator/qt_calculator.cpp 完全相同的 5 组用例:验证「界面搭建方式
+// 换了,行为不变」。
+
+static bool expectEqual(const char* label, const QString& actual, const QString& expected) {
+ if (actual != expected) {
+ qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
+ return false;
+ }
+ qInfo() << "自测通过:" << label << "=>" << actual;
+ return true;
+}
+
+static bool runSelfTest(CalculatorDesigner* calc) {
+ calc->onClear();
+ calc->onDigit(7); calc->onOperator(QLatin1Char('+')); calc->onDigit(3); calc->onEquals();
+ if (!expectEqual("7 + 3 =", calc->displayText(), QStringLiteral("10"))) return false;
+
+ calc->onClear();
+ calc->onDigit(6); calc->onOperator(QLatin1Char('/')); calc->onDigit(0); calc->onEquals();
+ if (!expectEqual("6 ÷ 0 =", calc->displayText(), QStringLiteral("Error"))) return false;
+
+ calc->onClear();
+ calc->onDigit(4); calc->onDot(); calc->onDigit(5);
+ calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(0); calc->onDot(); calc->onDigit(5);
+ calc->onEquals();
+ if (!expectEqual("4.5 + 0.5 =", calc->displayText(), QStringLiteral("5"))) return false;
+
+ calc->onClear();
+ calc->onDigit(1); calc->onDigit(2); calc->onBackspace();
+ if (!expectEqual("12 退格", calc->displayText(), QStringLiteral("1"))) return false;
+
+ calc->onClear();
+ calc->onDigit(2); calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(3); calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(4); calc->onEquals();
+ if (!expectEqual("2 + 3 + 4 =(链式运算)", calc->displayText(), QStringLiteral("9"))) return false;
+
+ return true;
+}
+
+int main(int argc, char* argv[]) {
+ QApplication app(argc, argv);
+ CalculatorDesigner calc;
+ calc.show();
+
+ if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
+ QTimer::singleShot(0, &calc, [&calc] {
+ const bool ok = runSelfTest(&calc);
+ QCoreApplication::exit(ok ? 0 : 1);
+ });
+ }
+ return app.exec();
+}
+
+#include "qt_calculator_designer.moc"
diff --git a/docs/teaching/examples/calculator_designer_ext/CMakeLists.txt b/docs/teaching/examples/calculator_designer_ext/CMakeLists.txt
new file mode 100644
index 0000000..625e841
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer_ext/CMakeLists.txt
@@ -0,0 +1,49 @@
+cmake_minimum_required(VERSION 3.5)
+
+project(Calculator LANGUAGES CXX)
+
+set(CMAKE_INCLUDE_CURRENT_DIR ON)
+
+set(CMAKE_AUTOUIC ON)
+set(CMAKE_AUTOMOC ON)
+set(CMAKE_AUTORCC ON)
+
+set(CMAKE_CXX_STANDARD 11)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+# QtCreator supports the following variables for Android, which are identical to qmake Android variables.
+# Check http://doc.qt.io/qt-5/deployment-android.html for more information.
+# They need to be set before the find_package(Qt5 ...) call.
+
+#if(ANDROID)
+# set(ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android")
+# if (ANDROID_ABI STREQUAL "armeabi-v7a")
+# set(ANDROID_EXTRA_LIBS
+# ${CMAKE_CURRENT_SOURCE_DIR}/path/to/libcrypto.so
+# ${CMAKE_CURRENT_SOURCE_DIR}/path/to/libssl.so)
+# endif()
+#endif()
+
+find_package(Qt5 COMPONENTS Widgets REQUIRED)
+
+if(ANDROID)
+ add_library(Calculator SHARED
+ main.cpp
+ calculatordesigner.h
+ calculatordesigner.cpp
+ expressionevaluator.h
+ expressionevaluator.cpp
+ widget.ui
+ )
+else()
+ add_executable(Calculator
+ main.cpp
+ calculatordesigner.h
+ calculatordesigner.cpp
+ expressionevaluator.h
+ expressionevaluator.cpp
+ widget.ui
+ )
+endif()
+
+target_link_libraries(Calculator PRIVATE Qt5::Widgets)
diff --git a/docs/teaching/examples/calculator_designer_ext/calculatordesigner.cpp b/docs/teaching/examples/calculator_designer_ext/calculatordesigner.cpp
new file mode 100644
index 0000000..47197ff
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer_ext/calculatordesigner.cpp
@@ -0,0 +1,229 @@
+#include "calculatordesigner.h"
+
+#include
+
+#include "expressionevaluator.h"
+#include "ui_widget.h"
+
+namespace {
+bool isOperatorChar(QChar ch) {
+ return ch == QLatin1Char('+') || ch == QLatin1Char('-') ||
+ ch == QLatin1Char('*') || ch == QLatin1Char('/');
+}
+
+bool isDigitOrDotChar(QChar ch) {
+ return ch.isDigit() || ch == QLatin1Char('.');
+}
+} // namespace
+
+CalculatorDesigner::CalculatorDesigner(QWidget* parent)
+ : QWidget(parent), ui(new Ui::CalculatorForm) {
+ ui->setupUi(this); // 表单里的控件树 + QGridLayout 全部由 Designer XML 建好
+
+ connect(ui->btn0, &QPushButton::clicked, this, [this] { onDigit(0); });
+ connect(ui->btn1, &QPushButton::clicked, this, [this] { onDigit(1); });
+ connect(ui->btn2, &QPushButton::clicked, this, [this] { onDigit(2); });
+ connect(ui->btn3, &QPushButton::clicked, this, [this] { onDigit(3); });
+ connect(ui->btn4, &QPushButton::clicked, this, [this] { onDigit(4); });
+ connect(ui->btn5, &QPushButton::clicked, this, [this] { onDigit(5); });
+ connect(ui->btn6, &QPushButton::clicked, this, [this] { onDigit(6); });
+ connect(ui->btn7, &QPushButton::clicked, this, [this] { onDigit(7); });
+ connect(ui->btn8, &QPushButton::clicked, this, [this] { onDigit(8); });
+ connect(ui->btn9, &QPushButton::clicked, this, [this] { onDigit(9); });
+ connect(ui->btnDot, &QPushButton::clicked, this, [this] { onDot(); });
+
+ connect(ui->btnPlus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('+')); });
+ connect(ui->btnMinus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('-')); });
+ connect(ui->btnMul, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('*')); });
+ connect(ui->btnDiv, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('/')); });
+
+ connect(ui->btnLeftParen, &QPushButton::clicked, this, &CalculatorDesigner::onLeftParen);
+ connect(ui->btnRightParen, &QPushButton::clicked, this, &CalculatorDesigner::onRightParen);
+
+ connect(ui->btnEquals, &QPushButton::clicked, this, &CalculatorDesigner::onEquals);
+ connect(ui->btnClear, &QPushButton::clicked, this, &CalculatorDesigner::onClear);
+ connect(ui->btnBackspace, &QPushButton::clicked, this, &CalculatorDesigner::onBackspace);
+
+ onClear();
+}
+
+CalculatorDesigner::~CalculatorDesigner() { delete ui; }
+
+void CalculatorDesigner::onDigit(int digit) {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ const QString token = currentOperandToken();
+ if (token == QStringLiteral("0")) {
+ m_expression.chop(1); // 用新数字替换孤立的前导 0,避免出现 "007"
+ } else if (currentOperandDigitCount() >= kMaxOperandLength) {
+ return; // 达到单个运算数的长度上限,忽略后续数字
+ }
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QString::number(digit));
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onDot() {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ const QString token = currentOperandToken();
+ if (token.contains(QLatin1Char('.'))) return; // 当前运算数已有小数点
+
+ const int appended = token.isEmpty() ? 2 : 1; // 空token时需要补一个前导 0
+ if (m_expression.size() + appended > kMaxExpressionLength) return;
+
+ if (token.isEmpty()) m_expression.append(QLatin1Char('0'));
+ m_expression.append(QLatin1Char('.'));
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onOperator(QChar op) {
+ if (m_error) return;
+ if (m_resultShown) {
+ m_expression = m_lastResultText;
+ m_resultShown = false;
+ }
+
+ if (m_expression.isEmpty()) {
+ if (op != QLatin1Char('-')) return; // 表达式开头只允许负号(单目负号)
+ } else {
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (last == QLatin1Char('(')) {
+ if (op != QLatin1Char('-')) return; // 左括号后只允许负号
+ } else if (isOperatorChar(last)) {
+ m_expression.chop(1); // 连续按运算符时,用新的运算符替换上一个
+ }
+ }
+
+ if (m_expression.size() >= kMaxExpressionLength) return;
+ m_expression.append(op);
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onLeftParen() {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ if (!m_expression.isEmpty()) {
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (!isOperatorChar(last) && last != QLatin1Char('(')) {
+ return; // 数字或右括号后不支持隐式乘法,忽略
+ }
+ }
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QLatin1Char('('));
+ ++m_openParenCount;
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onRightParen() {
+ if (m_error) return;
+ if (m_resultShown) return;
+ if (m_openParenCount <= 0 || m_expression.isEmpty()) return;
+
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (isOperatorChar(last) || last == QLatin1Char('(')) return; // 括号内需要完整子表达式
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QLatin1Char(')'));
+ --m_openParenCount;
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onEquals() {
+ if (m_error) return;
+ if (m_expression.isEmpty()) return;
+
+ QString toEvaluate = m_expression;
+ for (int i = 0; i < m_openParenCount; ++i) toEvaluate.append(QLatin1Char(')')); // 自动补全未闭合括号
+
+ const ExpressionEvaluator::Result result = ExpressionEvaluator::evaluate(toEvaluate);
+ if (!result.ok) {
+ showError();
+ return;
+ }
+
+ const QString resultText = QString::number(result.value, 'g', ExpressionEvaluator::kResultPrecision);
+ m_expression = toEvaluate;
+ m_lastResultText = resultText;
+ m_openParenCount = 0;
+ m_resultShown = true;
+ ui->expressionEdit->setPlainText(m_expression + QStringLiteral(" = ") + resultText);
+ ui->display->setText(resultText);
+}
+
+void CalculatorDesigner::onClear() {
+ m_expression.clear();
+ m_lastResultText.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ m_error = false;
+ ui->expressionEdit->clear();
+ ui->display->setText(QStringLiteral("0"));
+}
+
+void CalculatorDesigner::onBackspace() {
+ if (m_error) { onClear(); return; }
+ if (m_resultShown) return; // 结果已展示,需先清空或继续输入新表达式
+ if (m_expression.isEmpty()) return;
+
+ const QChar removed = m_expression.at(m_expression.size() - 1);
+ m_expression.chop(1);
+ if (removed == QLatin1Char('(')) --m_openParenCount;
+ else if (removed == QLatin1Char(')')) ++m_openParenCount;
+ refreshDisplays();
+}
+
+QString CalculatorDesigner::displayText() const { return ui->display->text(); }
+
+QString CalculatorDesigner::expressionText() const { return ui->expressionEdit->toPlainText(); }
+
+QString CalculatorDesigner::currentOperandToken() const {
+ int start = m_expression.size();
+ while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
+ return m_expression.mid(start);
+}
+
+int CalculatorDesigner::currentOperandDigitCount() const {
+ const QString token = currentOperandToken();
+ int count = 0;
+ for (const QChar ch : token) {
+ if (ch.isDigit()) ++count;
+ }
+ return count;
+}
+
+QString CalculatorDesigner::lastNumberToken() const {
+ int end = m_expression.size();
+ while (end > 0 && !isDigitOrDotChar(m_expression.at(end - 1))) --end; // 跳过末尾的运算符/括号
+ int start = end;
+ while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
+ return m_expression.mid(start, end - start);
+}
+
+void CalculatorDesigner::refreshDisplays() {
+ ui->expressionEdit->setPlainText(m_expression);
+ const QString token = lastNumberToken();
+ ui->display->setText(token.isEmpty() ? QStringLiteral("0") : token);
+}
+
+void CalculatorDesigner::showError() {
+ ui->display->setText(QStringLiteral("Error"));
+ m_error = true;
+}
diff --git a/docs/teaching/examples/calculator_designer_ext/calculatordesigner.h b/docs/teaching/examples/calculator_designer_ext/calculatordesigner.h
new file mode 100644
index 0000000..efaec27
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer_ext/calculatordesigner.h
@@ -0,0 +1,52 @@
+#pragma once
+
+#include
+#include
+#include
+
+QT_BEGIN_NAMESPACE
+namespace Ui {
+class CalculatorForm;
+}
+QT_END_NAMESPACE
+
+class CalculatorDesigner : public QWidget {
+ Q_OBJECT
+public:
+ explicit CalculatorDesigner(QWidget* parent = nullptr);
+ ~CalculatorDesigner() override;
+
+ // 供自测直接调用,效果等价于点击对应按钮;逻辑与 ../calculator/ 版一字不差。
+ void onDigit(int digit);
+ void onDot();
+ void onOperator(QChar op);
+ void onLeftParen();
+ void onRightParen();
+ void onEquals();
+ void onClear();
+ void onBackspace();
+
+ QString displayText() const;
+ QString expressionText() const;
+
+private:
+ // 单个运算数(数字串)允许的最大长度,要求不得小于 10 位。
+ static constexpr int kMaxOperandLength = 15;
+ static_assert(kMaxOperandLength >= 10, "单个运算数的最大长度不得小于 10 位");
+ // 表达式总长度上限,要求不得低于 100 个字符。
+ static constexpr int kMaxExpressionLength = 200;
+ static_assert(kMaxExpressionLength >= 100, "表达式总长度上限不得低于 100 个字符");
+
+ QString currentOperandToken() const;
+ int currentOperandDigitCount() const;
+ QString lastNumberToken() const;
+ void refreshDisplays();
+ void showError();
+
+ Ui::CalculatorForm* ui;
+ QString m_expression; // 当前正在编辑的表达式,例如 "12+3*(4-2"
+ QString m_lastResultText; // 上一次成功计算得到的结果文本
+ int m_openParenCount = 0; // 尚未闭合的左括号数量
+ bool m_resultShown = false; // 显示区当前展示的是计算结果而非正在输入的数字
+ bool m_error = false;
+};
diff --git a/docs/teaching/examples/calculator_designer_ext/expressionevaluator.cpp b/docs/teaching/examples/calculator_designer_ext/expressionevaluator.cpp
new file mode 100644
index 0000000..85cbe3e
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer_ext/expressionevaluator.cpp
@@ -0,0 +1,144 @@
+#include "expressionevaluator.h"
+
+namespace {
+
+// 递归下降解析器:expression := term (('+'|'-') term)*
+// term := factor (('*'|'/') factor)*
+// factor := ('+'|'-') factor | '(' expression ')' | number
+// 括号会先被完整求值,天然实现"优先级提升";乘除法在 term 层处理,
+// 天然高于 expression 层的加减法。
+class Parser {
+public:
+ explicit Parser(const QString& text) : m_text(text) {}
+
+ ExpressionEvaluator::Result parse() {
+ ExpressionEvaluator::Result result;
+ skipSpaces();
+ if (atEnd()) {
+ result.errorMessage = QStringLiteral("表达式为空");
+ return result;
+ }
+
+ bool ok = true;
+ const double value = parseExpression(ok);
+ skipSpaces();
+ if (!ok) {
+ result.errorMessage = m_error;
+ return result;
+ }
+ if (!atEnd()) {
+ result.errorMessage = QStringLiteral("表达式包含无法识别的字符");
+ return result;
+ }
+
+ result.ok = true;
+ result.value = value;
+ return result;
+ }
+
+private:
+ double parseExpression(bool& ok) {
+ double value = parseTerm(ok);
+ while (ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('+')) {
+ advance();
+ value += parseTerm(ok);
+ } else if (peek() == QLatin1Char('-')) {
+ advance();
+ value -= parseTerm(ok);
+ } else {
+ break;
+ }
+ }
+ return value;
+ }
+
+ double parseTerm(bool& ok) {
+ double value = parseFactor(ok);
+ while (ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('*')) {
+ advance();
+ value *= parseFactor(ok);
+ } else if (peek() == QLatin1Char('/')) {
+ advance();
+ const double rhs = parseFactor(ok);
+ if (!ok) return 0.0;
+ if (rhs == 0.0) {
+ fail(QStringLiteral("除数不能为零"), ok);
+ return 0.0;
+ }
+ value /= rhs;
+ } else {
+ break;
+ }
+ }
+ return value;
+ }
+
+ double parseFactor(bool& ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('+')) {
+ advance();
+ return parseFactor(ok);
+ }
+ if (peek() == QLatin1Char('-')) {
+ advance();
+ return -parseFactor(ok);
+ }
+ if (peek() == QLatin1Char('(')) {
+ advance();
+ const double value = parseExpression(ok);
+ if (!ok) return 0.0;
+ skipSpaces();
+ if (peek() != QLatin1Char(')')) {
+ fail(QStringLiteral("括号不匹配"), ok);
+ return 0.0;
+ }
+ advance();
+ return value;
+ }
+ return parseNumber(ok);
+ }
+
+ double parseNumber(bool& ok) {
+ skipSpaces();
+ const int start = m_pos;
+ bool hasDigits = false;
+ while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
+ if (!atEnd() && peek() == QLatin1Char('.')) {
+ advance();
+ while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
+ }
+ if (!hasDigits) {
+ fail(QStringLiteral("表达式格式错误,缺少数字"), ok);
+ return 0.0;
+ }
+ const QString token = m_text.mid(start, m_pos - start);
+ bool numOk = false;
+ const double value = token.toDouble(&numOk);
+ if (!numOk) {
+ fail(QStringLiteral("数字格式错误"), ok);
+ return 0.0;
+ }
+ return value;
+ }
+
+ void skipSpaces() { while (!atEnd() && peek().isSpace()) advance(); }
+ bool atEnd() const { return m_pos >= m_text.size(); }
+ QChar peek() const { return atEnd() ? QChar() : m_text.at(m_pos); }
+ void advance() { ++m_pos; }
+ void fail(const QString& message, bool& ok) { ok = false; m_error = message; }
+
+ const QString& m_text;
+ int m_pos = 0;
+ QString m_error;
+};
+
+} // namespace
+
+ExpressionEvaluator::Result ExpressionEvaluator::evaluate(const QString& expression) {
+ Parser parser(expression);
+ return parser.parse();
+}
diff --git a/docs/teaching/examples/calculator_designer_ext/expressionevaluator.h b/docs/teaching/examples/calculator_designer_ext/expressionevaluator.h
new file mode 100644
index 0000000..1a42cb4
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer_ext/expressionevaluator.h
@@ -0,0 +1,20 @@
+#pragma once
+
+#include
+
+// 对形如 "1+2*(3-4)" 的中缀表达式求值:
+// 乘除优先级高于加减,括号可提升优先级。
+class ExpressionEvaluator {
+public:
+ struct Result {
+ bool ok = false;
+ double value = 0.0;
+ QString errorMessage;
+ };
+
+ // 结果显示精度(有效数字位数),要求不得小于 10。
+ static constexpr int kResultPrecision = 12;
+ static_assert(kResultPrecision >= 10, "结果显示精度不得小于 10 位有效数字");
+
+ static Result evaluate(const QString& expression);
+};
diff --git a/docs/teaching/examples/calculator_designer_ext/main.cpp b/docs/teaching/examples/calculator_designer_ext/main.cpp
new file mode 100644
index 0000000..a48ad8a
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer_ext/main.cpp
@@ -0,0 +1,93 @@
+#include
+#include
+#include
+
+#include "calculatordesigner.h"
+
+// ------------------------------------------------------------ 自测入口 ----
+// 与 ../calculator/qt_calculator.cpp 完全相同的 5 组用例:验证「界面搭建方式
+// 换了,行为不变」。
+
+static bool expectEqual(const char* label, const QString& actual, const QString& expected) {
+ if (actual != expected) {
+ qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
+ return false;
+ }
+ qInfo() << "自测通过:" << label << "=>" << actual;
+ return true;
+}
+
+static bool runSelfTest(CalculatorDesigner* calc) {
+ calc->onClear();
+ calc->onDigit(7); calc->onOperator(QLatin1Char('+')); calc->onDigit(3); calc->onEquals();
+ if (!expectEqual("7 + 3 =", calc->displayText(), QStringLiteral("10"))) return false;
+
+ calc->onClear();
+ calc->onDigit(6); calc->onOperator(QLatin1Char('/')); calc->onDigit(0); calc->onEquals();
+ if (!expectEqual("6 ÷ 0 =", calc->displayText(), QStringLiteral("Error"))) return false;
+
+ calc->onClear();
+ calc->onDigit(4); calc->onDot(); calc->onDigit(5);
+ calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(0); calc->onDot(); calc->onDigit(5);
+ calc->onEquals();
+ if (!expectEqual("4.5 + 0.5 =", calc->displayText(), QStringLiteral("5"))) return false;
+
+ calc->onClear();
+ calc->onDigit(1); calc->onDigit(2); calc->onBackspace();
+ if (!expectEqual("12 退格", calc->displayText(), QStringLiteral("1"))) return false;
+
+ calc->onClear();
+ calc->onDigit(2); calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(3); calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(4); calc->onEquals();
+ if (!expectEqual("2 + 3 + 4 =(链式运算)", calc->displayText(), QStringLiteral("9"))) return false;
+
+ calc->onClear();
+ calc->onDigit(2); calc->onOperator(QLatin1Char('+'));
+ calc->onDigit(3); calc->onOperator(QLatin1Char('*'));
+ calc->onDigit(4); calc->onEquals();
+ if (!expectEqual("2 + 3 × 4 =(乘法优先于加法)", calc->displayText(), QStringLiteral("14"))) return false;
+
+ calc->onClear();
+ calc->onLeftParen();
+ calc->onDigit(2); calc->onOperator(QLatin1Char('+')); calc->onDigit(3);
+ calc->onRightParen();
+ calc->onOperator(QLatin1Char('*'));
+ calc->onDigit(4); calc->onEquals();
+ if (!expectEqual("(2 + 3) × 4 =(括号提升优先级)", calc->displayText(), QStringLiteral("20"))) return false;
+ if (!expectEqual("(2 + 3) × 4 = 表达式区文本", calc->expressionText(), QStringLiteral("(2+3)*4 = 20"))) return false;
+
+ calc->onClear();
+ calc->onDigit(1); calc->onOperator(QLatin1Char('+'));
+ calc->onLeftParen();
+ calc->onDigit(2); calc->onOperator(QLatin1Char('*'));
+ calc->onLeftParen();
+ calc->onDigit(3); calc->onOperator(QLatin1Char('+')); calc->onDigit(4);
+ calc->onRightParen();
+ calc->onRightParen();
+ calc->onEquals();
+ if (!expectEqual("1 + (2 × (3 + 4)) =(嵌套括号)", calc->displayText(), QStringLiteral("15"))) return false;
+
+ calc->onClear();
+ calc->onLeftParen();
+ calc->onDigit(1); calc->onOperator(QLatin1Char('+')); calc->onDigit(2);
+ calc->onEquals(); // 未手动补齐右括号,应自动闭合后求值
+ if (!expectEqual("(1 + 2 =(自动补全括号)", calc->displayText(), QStringLiteral("3"))) return false;
+
+ return true;
+}
+
+int main(int argc, char* argv[]) {
+ QApplication app(argc, argv);
+ CalculatorDesigner calc;
+ calc.show();
+
+ if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
+ QTimer::singleShot(0, &calc, [&calc] {
+ const bool ok = runSelfTest(&calc);
+ QCoreApplication::exit(ok ? 0 : 1);
+ });
+ }
+ return app.exec();
+}
diff --git a/docs/teaching/examples/calculator_designer_ext/widget.ui b/docs/teaching/examples/calculator_designer_ext/widget.ui
new file mode 100644
index 0000000..9b7c009
--- /dev/null
+++ b/docs/teaching/examples/calculator_designer_ext/widget.ui
@@ -0,0 +1,200 @@
+
+
+ CalculatorForm
+
+
+
+ 0
+ 0
+ 358
+ 320
+
+
+
+ 简易计算器(Designer 版)
+
+
+ -
+
+
+
+ 0
+ 50
+
+
+
+
+ 16777215
+ 80
+
+
+
+ true
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ 0
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ true
+
+
+
+ -
+
+
-
+
+
+ 7
+
+
+
+ -
+
+
+ 8
+
+
+
+ -
+
+
+ 9
+
+
+
+ -
+
+
+ ÷
+
+
+
+ -
+
+
+ 4
+
+
+
+ -
+
+
+ 5
+
+
+
+ -
+
+
+ 6
+
+
+
+ -
+
+
+ ×
+
+
+
+ -
+
+
+ 1
+
+
+
+ -
+
+
+ 2
+
+
+
+ -
+
+
+ 3
+
+
+
+ -
+
+
+ -
+
+
+
+ -
+
+
+ 0
+
+
+
+ -
+
+
+ .
+
+
+
+ -
+
+
+ =
+
+
+
+ -
+
+
+ +
+
+
+
+ -
+
+
+ C
+
+
+
+ -
+
+
+ ⌫
+
+
+
+ -
+
+
+ (
+
+
+
+ -
+
+
+ )
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/teaching/examples/calculator_mainwindow/README.md b/docs/teaching/examples/calculator_mainwindow/README.md
new file mode 100644
index 0000000..c7debde
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/README.md
@@ -0,0 +1,76 @@
+# Day3 上午参考实现:计算器 → QMainWindow
+
+> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day3 上午「QMainWindow / 对话框
+> QDialog」+ [`ASSIGNMENTS.md`](../../ASSIGNMENTS.md) Day3 上午课堂练习。手工维护,
+> 非生成产物。
+
+## 定位:把 Day2 增强版计算器装进 QMainWindow
+
+以 Day2 增强版计算器 [`../calculator_designer_ext/`](../calculator_designer_ext/README.md)
+的 `CalculatorDesigner`(`QWidget`,含表达式求值 + 括号)为**中心部件**,给它套上
+`QMainWindow` 外壳:菜单栏 / 状态栏 / 历史停靠窗。**计算器面板本身的输入与求值逻辑
+一字未改**——这正是「界面外壳升级、核心部件不动」的关注点分离。
+
+## 与 Day2 增强版的差异(仅两处,集中在 `calculatordesigner.{h,cpp}`)
+
+| 改动 | 位置 | 目的 |
+|---|---|---|
+| 新增 `evaluated(expr, result)` 信号 | `calculatordesigner.h/.cpp` | 按 `=` 求值成功后发出,供历史停靠窗订阅(演示「给已有部件加信号,与外壳通信」) |
+| 新增 `loadExpression(expr)` 方法 | `calculatordesigner.h/.cpp` | 把一条历史表达式回填到编辑区,配合历史双击 |
+
+`expressionevaluator.{h,cpp}` / `widget.ui` 与 Day2 增强版完全一致(自包含复制,便于
+学员单读 Day3 代码不跳目录)。
+
+## 与 [`../mainwindow_showcase/`](../mainwindow_showcase/README.md) 的分工对照
+
+两者同为 Day3 上午教学载体,风格刻意对立,覆盖不同教学诉求:
+
+| | `mainwindow_showcase` | 本例 `calculator_mainwindow` |
+|---|---|---|
+| 形态 | 极简文本编辑器(`QTextEdit` 中心) | 计算器(`CalculatorDesigner` 中心) |
+| 主窗体结构来源 | `.ui` 表单(Designer) | 纯代码 |
+| 信号槽 | `on__` 自动连接 | 显式 `connect()` |
+| 教学侧重 | QMainWindow 六大组件齐全(含资源 `.qrc`) | 作业线应用 + 显式 connect 坑卡 |
+| 用法 | 教师讲「组件协议」时演示 | 学员课堂练习对照、作业验收参考 |
+
+**坑卡对照**:`mainwindow_showcase` 用自动连接,控件改名静默失效;本例用显式 `connect`,
+`QAction` 忘 connect 时菜单点了没反应——两种「静默失败」在同一节课形成对照。
+
+## 教学要点(对应 OUTLINE_4DAY.md Day3 上午)
+
+1. **中心部件必须 `setCentralWidget`**:`mainwindow.cpp` 构造函数里 `setCentralWidget(m_calc)`。
+ 直接往 `QMainWindow` 上 `addWidget` 是 Day3 上午坑卡——布局行为不可预期。
+2. **菜单 + QAction + 显式 connect**:`buildMenu()` 逐菜单逐 `QAction` 建,每个都 `connect`。
+ `m_actAbout` 若忘 `connect`,菜单显示正常、点了没反应。
+3. **模态对话框**:`onAbout()` 用 `QMessageBox::about`,内部 `exec()` 阻塞等关闭。
+4. **非模态停靠窗**:`QDockWidget` + `addDockWidget(Qt::RightDockWidgetArea, ...)`,
+ 可拖动 / 浮动 / 关闭(`QDockWidget` 默认行为)。与 `p03/ch06` 三个 modeless 对照文件
+ 呼应「非模态对象必须活过函数作用域」——停靠窗是 `new` 在堆上 + parent=`this`。
+5. **信号槽跨对象通信**:`CalculatorDesigner::evaluated` → `MainWindow::onEvaluated`,
+ 外壳订阅核心部件的信号,二者解耦。
+
+## 构建与运行
+
+```bash
+# 构建(仓库根;Qt 5.14.2)
+cmake --build build --target qt_calculator_mainwindow
+
+# 离屏自测(9 组计算用例 + 历史追加/双击回填/清空,退出码 0 即全过)
+PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
+QT_QPA_PLATFORM=offscreen \
+ ./build/docs/teaching/examples/qt_calculator_mainwindow.exe; echo "exit=$?"
+
+# 有 GUI 环境直接看窗口
+./build/docs/teaching/examples/qt_calculator_mainwindow.exe
+```
+
+## 自测用例
+
+`MainWindow::runSelfTest()`(offscreen 自动执行)覆盖:
+
+- Day2 全部 9 组计算用例(验证升级外壳后计算行为不变)
+- 按等号后历史停靠窗追加 `"expr = result"` 条目
+- 除零 `Error` **不**进历史(`evaluated` 只在求值成功时 emit)
+- 历史双击 → 表达式回填到编辑区
+- 「清空历史」后列表为空
+- 「关于」action 已创建并显式连接(GUI 弹窗验证靠现场演示)
diff --git a/docs/teaching/examples/calculator_mainwindow/calculatordesigner.cpp b/docs/teaching/examples/calculator_mainwindow/calculatordesigner.cpp
new file mode 100644
index 0000000..87d909c
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/calculatordesigner.cpp
@@ -0,0 +1,248 @@
+#include "calculatordesigner.h"
+
+#include
+
+#include "expressionevaluator.h"
+#include "ui_widget.h"
+
+namespace {
+bool isOperatorChar(QChar ch) {
+ return ch == QLatin1Char('+') || ch == QLatin1Char('-') ||
+ ch == QLatin1Char('*') || ch == QLatin1Char('/');
+}
+
+bool isDigitOrDotChar(QChar ch) {
+ return ch.isDigit() || ch == QLatin1Char('.');
+}
+} // namespace
+
+CalculatorDesigner::CalculatorDesigner(QWidget* parent)
+ : QWidget(parent), ui(new Ui::CalculatorForm) {
+ ui->setupUi(this); // 表单里的控件树 + QGridLayout 全部由 Designer XML 建好
+
+ connect(ui->btn0, &QPushButton::clicked, this, [this] { onDigit(0); });
+ connect(ui->btn1, &QPushButton::clicked, this, [this] { onDigit(1); });
+ connect(ui->btn2, &QPushButton::clicked, this, [this] { onDigit(2); });
+ connect(ui->btn3, &QPushButton::clicked, this, [this] { onDigit(3); });
+ connect(ui->btn4, &QPushButton::clicked, this, [this] { onDigit(4); });
+ connect(ui->btn5, &QPushButton::clicked, this, [this] { onDigit(5); });
+ connect(ui->btn6, &QPushButton::clicked, this, [this] { onDigit(6); });
+ connect(ui->btn7, &QPushButton::clicked, this, [this] { onDigit(7); });
+ connect(ui->btn8, &QPushButton::clicked, this, [this] { onDigit(8); });
+ connect(ui->btn9, &QPushButton::clicked, this, [this] { onDigit(9); });
+ connect(ui->btnDot, &QPushButton::clicked, this, [this] { onDot(); });
+
+ connect(ui->btnPlus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('+')); });
+ connect(ui->btnMinus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('-')); });
+ connect(ui->btnMul, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('*')); });
+ connect(ui->btnDiv, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('/')); });
+
+ connect(ui->btnLeftParen, &QPushButton::clicked, this, &CalculatorDesigner::onLeftParen);
+ connect(ui->btnRightParen, &QPushButton::clicked, this, &CalculatorDesigner::onRightParen);
+
+ connect(ui->btnEquals, &QPushButton::clicked, this, &CalculatorDesigner::onEquals);
+ connect(ui->btnClear, &QPushButton::clicked, this, &CalculatorDesigner::onClear);
+ connect(ui->btnBackspace, &QPushButton::clicked, this, &CalculatorDesigner::onBackspace);
+
+ onClear();
+}
+
+CalculatorDesigner::~CalculatorDesigner() { delete ui; }
+
+void CalculatorDesigner::onDigit(int digit) {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ const QString token = currentOperandToken();
+ if (token == QStringLiteral("0")) {
+ m_expression.chop(1); // 用新数字替换孤立的前导 0,避免出现 "007"
+ } else if (currentOperandDigitCount() >= kMaxOperandLength) {
+ return; // 达到单个运算数的长度上限,忽略后续数字
+ }
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QString::number(digit));
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onDot() {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ const QString token = currentOperandToken();
+ if (token.contains(QLatin1Char('.'))) return; // 当前运算数已有小数点
+
+ const int appended = token.isEmpty() ? 2 : 1; // 空token时需要补一个前导 0
+ if (m_expression.size() + appended > kMaxExpressionLength) return;
+
+ if (token.isEmpty()) m_expression.append(QLatin1Char('0'));
+ m_expression.append(QLatin1Char('.'));
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onOperator(QChar op) {
+ if (m_error) return;
+ if (m_resultShown) {
+ m_expression = m_lastResultText;
+ m_resultShown = false;
+ }
+
+ if (m_expression.isEmpty()) {
+ if (op != QLatin1Char('-')) return; // 表达式开头只允许负号(单目负号)
+ } else {
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (last == QLatin1Char('(')) {
+ if (op != QLatin1Char('-')) return; // 左括号后只允许负号
+ } else if (isOperatorChar(last)) {
+ m_expression.chop(1); // 连续按运算符时,用新的运算符替换上一个
+ }
+ }
+
+ if (m_expression.size() >= kMaxExpressionLength) return;
+ m_expression.append(op);
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onLeftParen() {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ if (!m_expression.isEmpty()) {
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (!isOperatorChar(last) && last != QLatin1Char('(')) {
+ return; // 数字或右括号后不支持隐式乘法,忽略
+ }
+ }
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QLatin1Char('('));
+ ++m_openParenCount;
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onRightParen() {
+ if (m_error) return;
+ if (m_resultShown) return;
+ if (m_openParenCount <= 0 || m_expression.isEmpty()) return;
+
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (isOperatorChar(last) || last == QLatin1Char('(')) return; // 括号内需要完整子表达式
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QLatin1Char(')'));
+ --m_openParenCount;
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onEquals() {
+ if (m_error) return;
+ if (m_expression.isEmpty()) return;
+
+ QString toEvaluate = m_expression;
+ for (int i = 0; i < m_openParenCount; ++i) toEvaluate.append(QLatin1Char(')')); // 自动补全未闭合括号
+
+ const ExpressionEvaluator::Result result = ExpressionEvaluator::evaluate(toEvaluate);
+ if (!result.ok) {
+ showError();
+ return;
+ }
+
+ const QString resultText = QString::number(result.value, 'g', ExpressionEvaluator::kResultPrecision);
+ m_expression = toEvaluate;
+ m_lastResultText = resultText;
+ m_openParenCount = 0;
+ m_resultShown = true;
+ ui->expressionEdit->setPlainText(m_expression + QStringLiteral(" = ") + resultText);
+ ui->display->setText(resultText);
+
+ // 【新增】通知外壳(历史停靠窗):本次求值的表达式与结果。
+ emit evaluated(m_expression, resultText);
+}
+
+void CalculatorDesigner::onClear() {
+ m_expression.clear();
+ m_lastResultText.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ m_error = false;
+ ui->expressionEdit->clear();
+ ui->display->setText(QStringLiteral("0"));
+}
+
+void CalculatorDesigner::onBackspace() {
+ if (m_error) { onClear(); return; }
+ if (m_resultShown) return; // 结果已展示,需先清空或继续输入新表达式
+ if (m_expression.isEmpty()) return;
+
+ const QChar removed = m_expression.at(m_expression.size() - 1);
+ m_expression.chop(1);
+ if (removed == QLatin1Char('(')) --m_openParenCount;
+ else if (removed == QLatin1Char(')')) ++m_openParenCount;
+ refreshDisplays();
+}
+
+QString CalculatorDesigner::displayText() const { return ui->display->text(); }
+
+QString CalculatorDesigner::expressionText() const { return ui->expressionEdit->toPlainText(); }
+
+// 【新增】把一条历史表达式回填到编辑区:重置状态机并把表达式原样载入,
+// 未闭合括号数按字符重新统计。调用后用户可直接按 = 重新求值,或继续编辑。
+void CalculatorDesigner::loadExpression(const QString& expr) {
+ if (m_error) onClear();
+ m_expression = expr;
+ m_openParenCount = 0;
+ for (const QChar ch : m_expression) {
+ if (ch == QLatin1Char('(')) ++m_openParenCount;
+ else if (ch == QLatin1Char(')')) --m_openParenCount;
+ }
+ if (m_openParenCount < 0) m_openParenCount = 0;
+ m_resultShown = false;
+ m_lastResultText.clear();
+ refreshDisplays();
+}
+
+QString CalculatorDesigner::currentOperandToken() const {
+ int start = m_expression.size();
+ while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
+ return m_expression.mid(start);
+}
+
+int CalculatorDesigner::currentOperandDigitCount() const {
+ const QString token = currentOperandToken();
+ int count = 0;
+ for (const QChar ch : token) {
+ if (ch.isDigit()) ++count;
+ }
+ return count;
+}
+
+QString CalculatorDesigner::lastNumberToken() const {
+ int end = m_expression.size();
+ while (end > 0 && !isDigitOrDotChar(m_expression.at(end - 1))) --end; // 跳过末尾的运算符/括号
+ int start = end;
+ while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
+ return m_expression.mid(start, end - start);
+}
+
+void CalculatorDesigner::refreshDisplays() {
+ ui->expressionEdit->setPlainText(m_expression);
+ const QString token = lastNumberToken();
+ ui->display->setText(token.isEmpty() ? QStringLiteral("0") : token);
+}
+
+void CalculatorDesigner::showError() {
+ ui->display->setText(QStringLiteral("Error"));
+ m_error = true;
+}
diff --git a/docs/teaching/examples/calculator_mainwindow/calculatordesigner.h b/docs/teaching/examples/calculator_mainwindow/calculatordesigner.h
new file mode 100644
index 0000000..68347d7
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/calculatordesigner.h
@@ -0,0 +1,66 @@
+#pragma once
+
+#include
+#include
+#include
+
+QT_BEGIN_NAMESPACE
+namespace Ui {
+class CalculatorForm;
+}
+QT_END_NAMESPACE
+
+// CalculatorDesigner —— 计算器面板(QWidget)。
+// 【Day3 calculator_mainwindow】相对 ../calculator_designer_ext/ 版仅两处改动:
+// ① 新增 evaluated(expression,result) 信号:按 = 求值成功后发出,供 MainWindow
+// 的历史停靠窗订阅(演示「给已有部件加信号,与外壳通信」)。
+// ② 新增 loadExpression(expr):把一条历史表达式回填到编辑区(演示「部件对外
+// 提供 API」,配合历史双击回填)。
+// 其余输入校验/状态机/求值逻辑一字未改。
+class CalculatorDesigner : public QWidget {
+ Q_OBJECT
+public:
+ explicit CalculatorDesigner(QWidget* parent = nullptr);
+ ~CalculatorDesigner() override;
+
+ // 供自测直接调用,效果等价于点击对应按钮;逻辑与 ../calculator_designer_ext/ 版一字不差。
+ void onDigit(int digit);
+ void onDot();
+ void onOperator(QChar op);
+ void onLeftParen();
+ void onRightParen();
+ void onEquals();
+ void onClear();
+ void onBackspace();
+
+ QString displayText() const;
+ QString expressionText() const;
+
+ // 【新增】把一条历史表达式回填到编辑区(历史双击时调用)。
+ void loadExpression(const QString& expr);
+
+signals:
+ // 【新增】按 = 求值成功后发出,参数为(自动补全括号后的表达式, 结果文本)。
+ void evaluated(const QString& expression, const QString& result);
+
+private:
+ // 单个运算数(数字串)允许的最大长度,要求不得小于 10 位。
+ static constexpr int kMaxOperandLength = 15;
+ static_assert(kMaxOperandLength >= 10, "单个运算数的最大长度不得小于 10 位");
+ // 表达式总长度上限,要求不得低于 100 个字符。
+ static constexpr int kMaxExpressionLength = 200;
+ static_assert(kMaxExpressionLength >= 100, "表达式总长度上限不得低于 100 个字符");
+
+ QString currentOperandToken() const;
+ int currentOperandDigitCount() const;
+ QString lastNumberToken() const;
+ void refreshDisplays();
+ void showError();
+
+ Ui::CalculatorForm* ui;
+ QString m_expression; // 当前正在编辑的表达式,例如 "12+3*(4-2"
+ QString m_lastResultText; // 上一次成功计算得到的结果文本
+ int m_openParenCount = 0; // 尚未闭合的左括号数量
+ bool m_resultShown = false; // 显示区当前展示的是计算结果而非正在输入的数字
+ bool m_error = false;
+};
diff --git a/docs/teaching/examples/calculator_mainwindow/expressionevaluator.cpp b/docs/teaching/examples/calculator_mainwindow/expressionevaluator.cpp
new file mode 100644
index 0000000..4967eab
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/expressionevaluator.cpp
@@ -0,0 +1,145 @@
+#include "expressionevaluator.h"
+
+namespace {
+
+// 递归下降解析器:expression := term (('+'|'-') term)*
+// term := factor (('*'|'/') factor)*
+// factor := ('+'|'-') factor | '(' expression ')' | number
+// 括号会先被完整求值,天然实现"优先级提升";乘除法在 term 层处理,
+// 天然高于 expression 层的加减法。
+// 【Day3 calculator_mainwindow】与 ../calculator_designer_ext/ 版一字不差。
+class Parser {
+public:
+ explicit Parser(const QString& text) : m_text(text) {}
+
+ ExpressionEvaluator::Result parse() {
+ ExpressionEvaluator::Result result;
+ skipSpaces();
+ if (atEnd()) {
+ result.errorMessage = QStringLiteral("表达式为空");
+ return result;
+ }
+
+ bool ok = true;
+ const double value = parseExpression(ok);
+ skipSpaces();
+ if (!ok) {
+ result.errorMessage = m_error;
+ return result;
+ }
+ if (!atEnd()) {
+ result.errorMessage = QStringLiteral("表达式包含无法识别的字符");
+ return result;
+ }
+
+ result.ok = true;
+ result.value = value;
+ return result;
+ }
+
+private:
+ double parseExpression(bool& ok) {
+ double value = parseTerm(ok);
+ while (ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('+')) {
+ advance();
+ value += parseTerm(ok);
+ } else if (peek() == QLatin1Char('-')) {
+ advance();
+ value -= parseTerm(ok);
+ } else {
+ break;
+ }
+ }
+ return value;
+ }
+
+ double parseTerm(bool& ok) {
+ double value = parseFactor(ok);
+ while (ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('*')) {
+ advance();
+ value *= parseFactor(ok);
+ } else if (peek() == QLatin1Char('/')) {
+ advance();
+ const double rhs = parseFactor(ok);
+ if (!ok) return 0.0;
+ if (rhs == 0.0) {
+ fail(QStringLiteral("除数不能为零"), ok);
+ return 0.0;
+ }
+ value /= rhs;
+ } else {
+ break;
+ }
+ }
+ return value;
+ }
+
+ double parseFactor(bool& ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('+')) {
+ advance();
+ return parseFactor(ok);
+ }
+ if (peek() == QLatin1Char('-')) {
+ advance();
+ return -parseFactor(ok);
+ }
+ if (peek() == QLatin1Char('(')) {
+ advance();
+ const double value = parseExpression(ok);
+ if (!ok) return 0.0;
+ skipSpaces();
+ if (peek() != QLatin1Char(')')) {
+ fail(QStringLiteral("括号不匹配"), ok);
+ return 0.0;
+ }
+ advance();
+ return value;
+ }
+ return parseNumber(ok);
+ }
+
+ double parseNumber(bool& ok) {
+ skipSpaces();
+ const int start = m_pos;
+ bool hasDigits = false;
+ while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
+ if (!atEnd() && peek() == QLatin1Char('.')) {
+ advance();
+ while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
+ }
+ if (!hasDigits) {
+ fail(QStringLiteral("表达式格式错误,缺少数字"), ok);
+ return 0.0;
+ }
+ const QString token = m_text.mid(start, m_pos - start);
+ bool numOk = false;
+ const double value = token.toDouble(&numOk);
+ if (!numOk) {
+ fail(QStringLiteral("数字格式错误"), ok);
+ return 0.0;
+ }
+ return value;
+ }
+
+ void skipSpaces() { while (!atEnd() && peek().isSpace()) advance(); }
+ bool atEnd() const { return m_pos >= m_text.size(); }
+ QChar peek() const { return atEnd() ? QChar() : m_text.at(m_pos); }
+ void advance() { ++m_pos; }
+ void fail(const QString& message, bool& ok) { ok = false; m_error = message; }
+
+ const QString& m_text;
+ int m_pos = 0;
+ QString m_error;
+};
+
+} // namespace
+
+ExpressionEvaluator::Result ExpressionEvaluator::evaluate(const QString& expression) {
+ Parser parser(expression);
+ return parser.parse();
+}
diff --git a/docs/teaching/examples/calculator_mainwindow/expressionevaluator.h b/docs/teaching/examples/calculator_mainwindow/expressionevaluator.h
new file mode 100644
index 0000000..a4dbc00
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/expressionevaluator.h
@@ -0,0 +1,22 @@
+#pragma once
+
+#include
+
+// 对形如 "1+2*(3-4)" 的中缀表达式求值:
+// 乘除优先级高于加减,括号可提升优先级。
+// 【Day3 calculator_mainwindow】与 ../calculator_designer_ext/ 版一字不差,
+// 求值逻辑独立于界面外壳——Day3 把 QWidget 换成 QMainWindow,本类不动。
+class ExpressionEvaluator {
+public:
+ struct Result {
+ bool ok = false;
+ double value = 0.0;
+ QString errorMessage;
+ };
+
+ // 结果显示精度(有效数字位数),要求不得小于 10。
+ static constexpr int kResultPrecision = 12;
+ static_assert(kResultPrecision >= 10, "结果显示精度不得小于 10 位有效数字");
+
+ static Result evaluate(const QString& expression);
+};
diff --git a/docs/teaching/examples/calculator_mainwindow/main.cpp b/docs/teaching/examples/calculator_mainwindow/main.cpp
new file mode 100644
index 0000000..1ad6629
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/main.cpp
@@ -0,0 +1,28 @@
+// ============================================================================
+// calculator_mainwindow —— Day3 上午版程序入口
+// 对应教案 OUTLINE_4DAY.md Day3 上午「QMainWindow / 对话框 QDialog」。
+//
+// 把 Day2 增强版计算器(CalculatorDesigner,QWidget)装进 QMainWindow:
+// - 菜单栏(文件/编辑/视图/帮助,纯代码 + 显式 connect)
+// - 历史停靠窗(QDockWidget + QListWidget,订阅 evaluated 信号)
+// - 状态栏(就绪 / 结果临时提示)
+// 离屏自测覆盖 Day2 全部 9 组计算用例 + Day3 新增(历史追加/双击回填/清空)。
+// ============================================================================
+#include
+#include
+
+#include "mainwindow.h"
+
+int main(int argc, char* argv[]) {
+ QApplication app(argc, argv);
+ MainWindow w;
+ w.show();
+
+ if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
+ QTimer::singleShot(0, &w, [&w] {
+ const bool ok = w.runSelfTest();
+ QCoreApplication::exit(ok ? 0 : 1);
+ });
+ }
+ return app.exec();
+}
diff --git a/docs/teaching/examples/calculator_mainwindow/mainwindow.cpp b/docs/teaching/examples/calculator_mainwindow/mainwindow.cpp
new file mode 100644
index 0000000..d3a78fc
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/mainwindow.cpp
@@ -0,0 +1,212 @@
+// ============================================================================
+// mainwindow.cpp —— Day3 上午版实现
+// 详见 mainwindow.h 顶部注释。本文件只做外壳组装,不含任何计算逻辑。
+// ============================================================================
+#include "mainwindow.h"
+#include "calculatordesigner.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
+ setWindowTitle(QStringLiteral("计算器(QMainWindow 版)"));
+
+ // ★ 中心部件必须显式安放(Day3 上午坑卡:直接往 QMainWindow 上 addWidget
+ // 布局行为不可预期)。CalculatorDesigner 是 QWidget,原样塞进去即可。
+ m_calc = new CalculatorDesigner(this);
+ setCentralWidget(m_calc);
+
+ buildMenu();
+ buildDock();
+ buildStatusBar();
+
+ // 计算器求值成功 → 历史停靠窗追加一条(Day2 信号槽的复用:给已有部件
+ // 加 evaluated 信号,外壳订阅它)。
+ connect(m_calc, &CalculatorDesigner::evaluated, this, &MainWindow::onEvaluated);
+}
+
+MainWindow::~MainWindow() = default;
+
+// ---- 菜单栏(纯代码 + 显式 connect,对照 mainwindow_showcase 的 .ui 自动连接)----
+void MainWindow::buildMenu() {
+ QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("文件(&F)"));
+ m_actQuit = fileMenu->addAction(QStringLiteral("退出(&Q)"));
+ m_actQuit->setShortcut(QKeySequence(QStringLiteral("Ctrl+Q")));
+ connect(m_actQuit, &QAction::triggered, this, &QWidget::close);
+
+ QMenu* editMenu = menuBar()->addMenu(QStringLiteral("编辑(&E)"));
+ m_actClearHistory = editMenu->addAction(QStringLiteral("清空历史(&C)"));
+ connect(m_actClearHistory, &QAction::triggered, this, &MainWindow::onClearHistory);
+
+ QMenu* viewMenu = menuBar()->addMenu(QStringLiteral("视图(&V)"));
+ m_actToggleHistory = viewMenu->addAction(QStringLiteral("显示历史(&H)"));
+ m_actToggleHistory->setCheckable(true);
+ m_actToggleHistory->setChecked(true);
+ connect(m_actToggleHistory, &QAction::toggled, this, &MainWindow::onToggleHistoryVisible);
+
+ QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("帮助(&H)"));
+ m_actAbout = helpMenu->addAction(QStringLiteral("关于(&A)"));
+ // ★ 显式 connect:忘了这一行,菜单显示正常、点了没反应(Day3 上午坑卡)。
+ connect(m_actAbout, &QAction::triggered, this, &MainWindow::onAbout);
+}
+
+// ---- 历史停靠窗 ----
+void MainWindow::buildDock() {
+ m_history = new QListWidget(this);
+ m_history->setAlternatingRowColors(true);
+ m_dock = new QDockWidget(QStringLiteral("历史"), this);
+ m_dock->setObjectName(QStringLiteral("historyDock")); // 供 saveState/restoreState
+ m_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea);
+ m_dock->setWidget(m_history);
+ addDockWidget(Qt::RightDockWidgetArea, m_dock);
+ // 双击历史条目 → 回填表达式(QListWidget 信号槽)
+ connect(m_history, &QListWidget::itemDoubleClicked, this, &MainWindow::onHistoryDoubleClicked);
+}
+
+void MainWindow::buildStatusBar() {
+ statusBar()->showMessage(QStringLiteral("就绪"));
+}
+
+void MainWindow::refreshStatus(const QString& msg) {
+ statusBar()->showMessage(msg, 3000); // 临时提示,3 秒后恢复
+}
+
+// ---- 槽 ----
+void MainWindow::onAbout() {
+ // 标准对话框:QMessageBox::about 是模态的,exec() 内部阻塞等用户关闭。
+ QMessageBox::about(this, QStringLiteral("关于"),
+ QStringLiteral(
+ "计算器(QMainWindow 版)
"
+ "Day3 上午教学参考实现:把 Day2 增强版计算器(表达式求值 + 括号)"
+ "装进 QMainWindow,演示菜单栏 / 状态栏 / 历史停靠窗。
"
+ "对应教案 OUTLINE_4DAY.md Day3 上午。"));
+}
+
+void MainWindow::onClearHistory() {
+ m_history->clear();
+ refreshStatus(QStringLiteral("已清空历史"));
+}
+
+void MainWindow::onToggleHistoryVisible(bool checked) {
+ m_dock->setVisible(checked);
+}
+
+void MainWindow::onEvaluated(const QString& expr, const QString& result) {
+ m_history->addItem(expr + QStringLiteral(" = ") + result);
+ refreshStatus(QStringLiteral("结果: ") + result);
+}
+
+void MainWindow::onHistoryDoubleClicked(QListWidgetItem* item) {
+ // 条目文本形如 "expr = result",取最后一个 " = " 左侧作为表达式回填。
+ // 用 lastIndexOf 而非 indexOf:result 文本里一般不含 " = ",但用 lastIndexOf
+ // 更稳妥(避免表达式里万一出现 " = ",实际上表达式不会含 =)。
+ const QString text = item->text();
+ const int idx = text.lastIndexOf(QStringLiteral(" = "));
+ if (idx < 0) return;
+ m_calc->loadExpression(text.left(idx));
+ refreshStatus(QStringLiteral("已回填表达式"));
+}
+
+// ------------------------------------------------------------ 自测 ----
+bool MainWindow::runSelfTest() {
+ auto expect = [](const char* label, bool cond) -> bool {
+ if (!cond) { qCritical() << "自测失败:" << label; return false; }
+ qInfo() << "自测通过:" << label; return true;
+ };
+ auto expectEq = [](const char* label, const QString& actual, const QString& expected) -> bool {
+ if (actual != expected) {
+ qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
+ return false;
+ }
+ qInfo() << "自测通过:" << label << "=>" << actual;
+ return true;
+ };
+
+ // —— Day2 计算 9 组用例(验证升级外壳后计算行为不变)——
+ m_calc->onClear();
+ m_calc->onDigit(7); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3); m_calc->onEquals();
+ if (!expectEq("7 + 3 =", m_calc->displayText(), QStringLiteral("10"))) return false;
+ if (!expect("历史追加第 1 条", m_history->count() == 1)) return false;
+ if (!expectEq("历史条目文本", m_history->item(0)->text(), QStringLiteral("7+3 = 10"))) return false;
+
+ m_calc->onClear();
+ m_calc->onDigit(6); m_calc->onOperator(QLatin1Char('/')); m_calc->onDigit(0); m_calc->onEquals();
+ if (!expectEq("6 ÷ 0 =", m_calc->displayText(), QStringLiteral("Error"))) return false;
+ if (!expect("除零 Error 不进历史", m_history->count() == 1)) return false;
+
+ m_calc->onClear();
+ m_calc->onDigit(4); m_calc->onDot(); m_calc->onDigit(5);
+ m_calc->onOperator(QLatin1Char('+'));
+ m_calc->onDigit(0); m_calc->onDot(); m_calc->onDigit(5);
+ m_calc->onEquals();
+ if (!expectEq("4.5 + 0.5 =", m_calc->displayText(), QStringLiteral("5"))) return false;
+
+ m_calc->onClear();
+ m_calc->onDigit(1); m_calc->onDigit(2); m_calc->onBackspace();
+ if (!expectEq("12 退格", m_calc->displayText(), QStringLiteral("1"))) return false;
+
+ m_calc->onClear();
+ m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+'));
+ m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('+'));
+ m_calc->onDigit(4); m_calc->onEquals();
+ if (!expectEq("2 + 3 + 4 =", m_calc->displayText(), QStringLiteral("9"))) return false;
+
+ m_calc->onClear();
+ m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+'));
+ m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('*'));
+ m_calc->onDigit(4); m_calc->onEquals();
+ if (!expectEq("2 + 3 × 4 =", m_calc->displayText(), QStringLiteral("14"))) return false;
+
+ m_calc->onClear();
+ m_calc->onLeftParen();
+ m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3);
+ m_calc->onRightParen();
+ m_calc->onOperator(QLatin1Char('*'));
+ m_calc->onDigit(4); m_calc->onEquals();
+ if (!expectEq("(2 + 3) × 4 =", m_calc->displayText(), QStringLiteral("20"))) return false;
+
+ m_calc->onClear();
+ m_calc->onDigit(1); m_calc->onOperator(QLatin1Char('+'));
+ m_calc->onLeftParen();
+ m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('*'));
+ m_calc->onLeftParen();
+ m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(4);
+ m_calc->onRightParen();
+ m_calc->onRightParen();
+ m_calc->onEquals();
+ if (!expectEq("1 + (2 × (3 + 4)) =", m_calc->displayText(), QStringLiteral("15"))) return false;
+
+ m_calc->onClear();
+ m_calc->onLeftParen();
+ m_calc->onDigit(1); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(2);
+ m_calc->onEquals();
+ if (!expectEq("(1 + 2 = 自动补全", m_calc->displayText(), QStringLiteral("3"))) return false;
+
+ // 9 组用例里:第 4 组(12 退格)无 onEquals、第 2 组(6÷0)Error 不 emit,
+ // 其余 7 次 onEquals 各进历史一条。
+ if (!expect("历史累计 7 条", m_history->count() == 7)) return false;
+
+ // —— Day3 新增:历史双击回填 ——
+ QListWidgetItem* last = m_history->item(m_history->count() - 1);
+ if (!expectEq("最后一条历史", last->text(), QStringLiteral("(1+2) = 3"))) return false;
+ onHistoryDoubleClicked(last);
+ if (!expectEq("双击回填表达式区", m_calc->expressionText(), QStringLiteral("(1+2)"))) return false;
+
+ // —— Day3 新增:清空历史 ——
+ onClearHistory();
+ if (!expect("清空历史后 count==0", m_history->count() == 0)) return false;
+
+ // —— Day3 新增:关于 action 已创建并显式连接 ——
+ // (QMessageBox::about 是模态弹窗,离屏下不真正触发,GUI 弹窗验证靠现场演示)
+ if (!expect("关于 action 已创建", m_actAbout != nullptr)) return false;
+
+ return true;
+}
diff --git a/docs/teaching/examples/calculator_mainwindow/mainwindow.h b/docs/teaching/examples/calculator_mainwindow/mainwindow.h
new file mode 100644
index 0000000..cd6b04e
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/mainwindow.h
@@ -0,0 +1,59 @@
+// ============================================================================
+// mainwindow.h —— Day3 上午版:把增强版计算器装进 QMainWindow
+//
+// 对应教案 OUTLINE_4DAY.md Day3 上午「QMainWindow / 对话框 QDialog」。
+// 本类只做「外壳」:菜单栏 / 状态栏 / 历史停靠窗 + 把 CalculatorDesigner
+// 作为中心部件 setCentralWidget。计算器面板本身的输入/求值逻辑一字未改
+// (见 calculatordesigner.cpp)——这正是「界面外壳升级、核心部件不动」
+// 的关注点分离演示。
+//
+// 与 mainwindow_showcase 的风格对照(同为 Day3 上午教学载体):
+// - mainwindow_showcase:.ui 表单定义主窗体结构 + on__ 自动连接
+// - 本例:纯代码建菜单/停靠窗 + 显式 connect()
+// 两种风格并列,前者突出「Designer 快速搭骨架」,后者突出「QAction 必须
+// connect 才生效」(Day3 上午坑卡:菜单显示正常点了没反应)。
+// ============================================================================
+#ifndef CALCULATOR_MAINWINDOW_H
+#define CALCULATOR_MAINWINDOW_H
+
+#include
+
+class CalculatorDesigner;
+class QListWidget;
+class QListWidgetItem;
+class QDockWidget;
+class QAction;
+
+class MainWindow : public QMainWindow {
+ Q_OBJECT
+public:
+ explicit MainWindow(QWidget* parent = nullptr);
+ ~MainWindow() override;
+
+ // 离屏自测:复用 Day2 的 9 组计算用例 + Day3 新增(历史追加 / 双击回填 /
+ // 关于对话框触发)。返回全过为 true,供 main.cpp 决定退出码。
+ bool runSelfTest();
+
+private slots:
+ void onAbout(); // 帮助→关于:QMessageBox::about
+ void onClearHistory(); // 编辑→清空历史
+ void onToggleHistoryVisible(bool checked); // 视图→显示历史
+ void onEvaluated(const QString& expr, const QString& result); // 计算器求值→追加历史
+ void onHistoryDoubleClicked(QListWidgetItem* item); // 双击历史→回填表达式
+
+private:
+ void buildMenu();
+ void buildDock();
+ void buildStatusBar();
+ void refreshStatus(const QString& msg);
+
+ CalculatorDesigner* m_calc = nullptr;
+ QListWidget* m_history = nullptr;
+ QDockWidget* m_dock = nullptr;
+ QAction* m_actQuit = nullptr;
+ QAction* m_actClearHistory = nullptr;
+ QAction* m_actToggleHistory = nullptr;
+ QAction* m_actAbout = nullptr;
+};
+
+#endif // CALCULATOR_MAINWINDOW_H
diff --git a/docs/teaching/examples/calculator_mainwindow/widget.ui b/docs/teaching/examples/calculator_mainwindow/widget.ui
new file mode 100644
index 0000000..e446f09
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow/widget.ui
@@ -0,0 +1,200 @@
+
+
+ CalculatorForm
+
+
+
+ 0
+ 0
+ 358
+ 320
+
+
+
+ 计算器面板
+
+
+ -
+
+
+
+ 0
+ 50
+
+
+
+
+ 16777215
+ 80
+
+
+
+ true
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ 0
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ true
+
+
+
+ -
+
+
-
+
+
+ 7
+
+
+
+ -
+
+
+ 8
+
+
+
+ -
+
+
+ 9
+
+
+
+ -
+
+
+ ÷
+
+
+
+ -
+
+
+ 4
+
+
+
+ -
+
+
+ 5
+
+
+
+ -
+
+
+ 6
+
+
+
+ -
+
+
+ ×
+
+
+
+ -
+
+
+ 1
+
+
+
+ -
+
+
+ 2
+
+
+
+ -
+
+
+ 3
+
+
+
+ -
+
+
+ -
+
+
+
+ -
+
+
+ 0
+
+
+
+ -
+
+
+ .
+
+
+
+ -
+
+
+ =
+
+
+
+ -
+
+
+ +
+
+
+
+ -
+
+
+ C
+
+
+
+ -
+
+
+ ⌫
+
+
+
+ -
+
+
+ (
+
+
+
+ -
+
+
+ )
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/README.md b/docs/teaching/examples/calculator_mainwindow_qss/README.md
new file mode 100644
index 0000000..1d8e8aa
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/README.md
@@ -0,0 +1,61 @@
+# Day3 下午参考实现:计算器 QMainWindow + QSS 双主题
+
+> 对应教案 [`OUTLINE_4DAY.md`](../../OUTLINE_4DAY.md) Day3 下午「布局/控件 + QSS 美化」
+> + [`ASSIGNMENTS.md`](../../ASSIGNMENTS.md) Day3 下午作业。手工维护,非生成产物。
+
+## 定位:上午版 + QSS 美化
+
+以 [`../calculator_mainwindow/`](../calculator_mainwindow/README.md)(Day3 上午版)为
+起点,**只加 QSS**:按键按功能分色、`:hover`/`:pressed` 视觉反馈、深/浅双主题切换。
+菜单/停靠窗/状态栏/计算逻辑与上午版完全一致——**QSS 与逻辑正交**,这正是 Day3 下午
+「美化」环节要传达的核心。
+
+## 与上午版的差异(集中在 `mainwindow.{h,cpp}`)
+
+| 改动 | 位置 | 内容 |
+|---|---|---|
+| 双主题 QSS 字符串 | `mainwindow.cpp` 顶部 `kLightQss` / `kDarkQss` | 按键分色 + 伪状态 |
+| 视图菜单「深色主题」 | `buildMenu()` | `checkable` QAction,`toggled` → `onToggleTheme` |
+| `applyStyle(bool dark)` | `mainwindow.cpp` | `qApp->setStyleSheet(...)` 全局切换 |
+| 自测加主题断言 | `runSelfTest()` | 验证切换后 `styleSheet()` 含对应主题标识 |
+
+`calculatordesigner.{h,cpp}` / `expressionevaluator.{h,cpp}` / `widget.ui` 与上午版
+完全一致(自包含复制)。
+
+## QSS 教学要点(对应 OUTLINE_4DAY.md Day3 下午)
+
+1. **ID 选择器认 objectName**:`QPushButton#btnEquals { ... }` 命中的前提是控件
+ `setObjectName("btnEquals")`。本例的 objectName 在 `widget.ui` 里已定义
+ (``),无需再 `setObjectName`——
+ 这是「样式写了没生效」头号原因(忘设 objectName)的反面教材。
+2. **按键分色用功能归类**:数字键(`btn0`–`btn9`,`btnDot`)浅蓝、运算键
+ (`btnPlus/Minus/Mul/Div`)橙、等号(`btnEquals`)绿、括号灰、清空/退格红——
+ 用 ID 选择器分组,比给每个按钮单独写样式更易维护。
+3. **伪状态**:`:hover` / `:pressed` 不用手写鼠标事件就能做交互反馈。
+4. **`qApp->setStyleSheet` 全局 vs `widget->setStyleSheet` 局部**:本例用全局,
+ 一次切换全部生效。坑卡:全局样式会被 widget 级样式覆盖,混用易级联失控
+ (OUTLINE_4DAY.md Day3 下午「QSS 级联失控」卡)。
+5. **主题切换不碰逻辑**:`applyStyle` 只改 `styleSheet`,不动任何业务代码——
+ QSS 与逻辑正交的可维护性演示。
+
+## 构建与运行
+
+```bash
+cmake --build build --target qt_calculator_mainwindow_qss
+
+# 离屏自测(计算用例 + 主题切换改变 styleSheet 的断言,退出码 0 即全过)
+PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
+QT_QPA_PLATFORM=offscreen \
+ ./build/docs/teaching/examples/qt_calculator_mainwindow_qss.exe; echo "exit=$?"
+
+# 有 GUI 环境:勾选「视图→深色主题」看整体配色切换
+./build/docs/teaching/examples/qt_calculator_mainwindow_qss.exe
+```
+
+## 验收对照(ASSIGNMENTS.md Day3 下午)
+
+- **基础版**:数字/运算/等号 3 色区分 ✓(本例 5 色,含括号/清空退格)
+- **基础版**:布局随窗口缩放不重叠 ✓(`QGridLayout` + `QMainWindow` 区域协议)
+- **挑战版**:`:hover`/`:pressed` ✓
+- **挑战版**:结果显示框套样式 ✓(`display`/`expressionEdit` 米黄背景 + 圆角)
+- **挑战版**:深/浅双主题切换 ✓(视图菜单「深色主题」)
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.cpp b/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.cpp
new file mode 100644
index 0000000..87d909c
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.cpp
@@ -0,0 +1,248 @@
+#include "calculatordesigner.h"
+
+#include
+
+#include "expressionevaluator.h"
+#include "ui_widget.h"
+
+namespace {
+bool isOperatorChar(QChar ch) {
+ return ch == QLatin1Char('+') || ch == QLatin1Char('-') ||
+ ch == QLatin1Char('*') || ch == QLatin1Char('/');
+}
+
+bool isDigitOrDotChar(QChar ch) {
+ return ch.isDigit() || ch == QLatin1Char('.');
+}
+} // namespace
+
+CalculatorDesigner::CalculatorDesigner(QWidget* parent)
+ : QWidget(parent), ui(new Ui::CalculatorForm) {
+ ui->setupUi(this); // 表单里的控件树 + QGridLayout 全部由 Designer XML 建好
+
+ connect(ui->btn0, &QPushButton::clicked, this, [this] { onDigit(0); });
+ connect(ui->btn1, &QPushButton::clicked, this, [this] { onDigit(1); });
+ connect(ui->btn2, &QPushButton::clicked, this, [this] { onDigit(2); });
+ connect(ui->btn3, &QPushButton::clicked, this, [this] { onDigit(3); });
+ connect(ui->btn4, &QPushButton::clicked, this, [this] { onDigit(4); });
+ connect(ui->btn5, &QPushButton::clicked, this, [this] { onDigit(5); });
+ connect(ui->btn6, &QPushButton::clicked, this, [this] { onDigit(6); });
+ connect(ui->btn7, &QPushButton::clicked, this, [this] { onDigit(7); });
+ connect(ui->btn8, &QPushButton::clicked, this, [this] { onDigit(8); });
+ connect(ui->btn9, &QPushButton::clicked, this, [this] { onDigit(9); });
+ connect(ui->btnDot, &QPushButton::clicked, this, [this] { onDot(); });
+
+ connect(ui->btnPlus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('+')); });
+ connect(ui->btnMinus, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('-')); });
+ connect(ui->btnMul, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('*')); });
+ connect(ui->btnDiv, &QPushButton::clicked, this, [this] { onOperator(QLatin1Char('/')); });
+
+ connect(ui->btnLeftParen, &QPushButton::clicked, this, &CalculatorDesigner::onLeftParen);
+ connect(ui->btnRightParen, &QPushButton::clicked, this, &CalculatorDesigner::onRightParen);
+
+ connect(ui->btnEquals, &QPushButton::clicked, this, &CalculatorDesigner::onEquals);
+ connect(ui->btnClear, &QPushButton::clicked, this, &CalculatorDesigner::onClear);
+ connect(ui->btnBackspace, &QPushButton::clicked, this, &CalculatorDesigner::onBackspace);
+
+ onClear();
+}
+
+CalculatorDesigner::~CalculatorDesigner() { delete ui; }
+
+void CalculatorDesigner::onDigit(int digit) {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ const QString token = currentOperandToken();
+ if (token == QStringLiteral("0")) {
+ m_expression.chop(1); // 用新数字替换孤立的前导 0,避免出现 "007"
+ } else if (currentOperandDigitCount() >= kMaxOperandLength) {
+ return; // 达到单个运算数的长度上限,忽略后续数字
+ }
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QString::number(digit));
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onDot() {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ const QString token = currentOperandToken();
+ if (token.contains(QLatin1Char('.'))) return; // 当前运算数已有小数点
+
+ const int appended = token.isEmpty() ? 2 : 1; // 空token时需要补一个前导 0
+ if (m_expression.size() + appended > kMaxExpressionLength) return;
+
+ if (token.isEmpty()) m_expression.append(QLatin1Char('0'));
+ m_expression.append(QLatin1Char('.'));
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onOperator(QChar op) {
+ if (m_error) return;
+ if (m_resultShown) {
+ m_expression = m_lastResultText;
+ m_resultShown = false;
+ }
+
+ if (m_expression.isEmpty()) {
+ if (op != QLatin1Char('-')) return; // 表达式开头只允许负号(单目负号)
+ } else {
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (last == QLatin1Char('(')) {
+ if (op != QLatin1Char('-')) return; // 左括号后只允许负号
+ } else if (isOperatorChar(last)) {
+ m_expression.chop(1); // 连续按运算符时,用新的运算符替换上一个
+ }
+ }
+
+ if (m_expression.size() >= kMaxExpressionLength) return;
+ m_expression.append(op);
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onLeftParen() {
+ if (m_error) onClear();
+ if (m_resultShown) {
+ m_expression.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ }
+
+ if (!m_expression.isEmpty()) {
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (!isOperatorChar(last) && last != QLatin1Char('(')) {
+ return; // 数字或右括号后不支持隐式乘法,忽略
+ }
+ }
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QLatin1Char('('));
+ ++m_openParenCount;
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onRightParen() {
+ if (m_error) return;
+ if (m_resultShown) return;
+ if (m_openParenCount <= 0 || m_expression.isEmpty()) return;
+
+ const QChar last = m_expression.at(m_expression.size() - 1);
+ if (isOperatorChar(last) || last == QLatin1Char('(')) return; // 括号内需要完整子表达式
+ if (m_expression.size() >= kMaxExpressionLength) return;
+
+ m_expression.append(QLatin1Char(')'));
+ --m_openParenCount;
+ refreshDisplays();
+}
+
+void CalculatorDesigner::onEquals() {
+ if (m_error) return;
+ if (m_expression.isEmpty()) return;
+
+ QString toEvaluate = m_expression;
+ for (int i = 0; i < m_openParenCount; ++i) toEvaluate.append(QLatin1Char(')')); // 自动补全未闭合括号
+
+ const ExpressionEvaluator::Result result = ExpressionEvaluator::evaluate(toEvaluate);
+ if (!result.ok) {
+ showError();
+ return;
+ }
+
+ const QString resultText = QString::number(result.value, 'g', ExpressionEvaluator::kResultPrecision);
+ m_expression = toEvaluate;
+ m_lastResultText = resultText;
+ m_openParenCount = 0;
+ m_resultShown = true;
+ ui->expressionEdit->setPlainText(m_expression + QStringLiteral(" = ") + resultText);
+ ui->display->setText(resultText);
+
+ // 【新增】通知外壳(历史停靠窗):本次求值的表达式与结果。
+ emit evaluated(m_expression, resultText);
+}
+
+void CalculatorDesigner::onClear() {
+ m_expression.clear();
+ m_lastResultText.clear();
+ m_openParenCount = 0;
+ m_resultShown = false;
+ m_error = false;
+ ui->expressionEdit->clear();
+ ui->display->setText(QStringLiteral("0"));
+}
+
+void CalculatorDesigner::onBackspace() {
+ if (m_error) { onClear(); return; }
+ if (m_resultShown) return; // 结果已展示,需先清空或继续输入新表达式
+ if (m_expression.isEmpty()) return;
+
+ const QChar removed = m_expression.at(m_expression.size() - 1);
+ m_expression.chop(1);
+ if (removed == QLatin1Char('(')) --m_openParenCount;
+ else if (removed == QLatin1Char(')')) ++m_openParenCount;
+ refreshDisplays();
+}
+
+QString CalculatorDesigner::displayText() const { return ui->display->text(); }
+
+QString CalculatorDesigner::expressionText() const { return ui->expressionEdit->toPlainText(); }
+
+// 【新增】把一条历史表达式回填到编辑区:重置状态机并把表达式原样载入,
+// 未闭合括号数按字符重新统计。调用后用户可直接按 = 重新求值,或继续编辑。
+void CalculatorDesigner::loadExpression(const QString& expr) {
+ if (m_error) onClear();
+ m_expression = expr;
+ m_openParenCount = 0;
+ for (const QChar ch : m_expression) {
+ if (ch == QLatin1Char('(')) ++m_openParenCount;
+ else if (ch == QLatin1Char(')')) --m_openParenCount;
+ }
+ if (m_openParenCount < 0) m_openParenCount = 0;
+ m_resultShown = false;
+ m_lastResultText.clear();
+ refreshDisplays();
+}
+
+QString CalculatorDesigner::currentOperandToken() const {
+ int start = m_expression.size();
+ while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
+ return m_expression.mid(start);
+}
+
+int CalculatorDesigner::currentOperandDigitCount() const {
+ const QString token = currentOperandToken();
+ int count = 0;
+ for (const QChar ch : token) {
+ if (ch.isDigit()) ++count;
+ }
+ return count;
+}
+
+QString CalculatorDesigner::lastNumberToken() const {
+ int end = m_expression.size();
+ while (end > 0 && !isDigitOrDotChar(m_expression.at(end - 1))) --end; // 跳过末尾的运算符/括号
+ int start = end;
+ while (start > 0 && isDigitOrDotChar(m_expression.at(start - 1))) --start;
+ return m_expression.mid(start, end - start);
+}
+
+void CalculatorDesigner::refreshDisplays() {
+ ui->expressionEdit->setPlainText(m_expression);
+ const QString token = lastNumberToken();
+ ui->display->setText(token.isEmpty() ? QStringLiteral("0") : token);
+}
+
+void CalculatorDesigner::showError() {
+ ui->display->setText(QStringLiteral("Error"));
+ m_error = true;
+}
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.h b/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.h
new file mode 100644
index 0000000..68347d7
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/calculatordesigner.h
@@ -0,0 +1,66 @@
+#pragma once
+
+#include
+#include
+#include
+
+QT_BEGIN_NAMESPACE
+namespace Ui {
+class CalculatorForm;
+}
+QT_END_NAMESPACE
+
+// CalculatorDesigner —— 计算器面板(QWidget)。
+// 【Day3 calculator_mainwindow】相对 ../calculator_designer_ext/ 版仅两处改动:
+// ① 新增 evaluated(expression,result) 信号:按 = 求值成功后发出,供 MainWindow
+// 的历史停靠窗订阅(演示「给已有部件加信号,与外壳通信」)。
+// ② 新增 loadExpression(expr):把一条历史表达式回填到编辑区(演示「部件对外
+// 提供 API」,配合历史双击回填)。
+// 其余输入校验/状态机/求值逻辑一字未改。
+class CalculatorDesigner : public QWidget {
+ Q_OBJECT
+public:
+ explicit CalculatorDesigner(QWidget* parent = nullptr);
+ ~CalculatorDesigner() override;
+
+ // 供自测直接调用,效果等价于点击对应按钮;逻辑与 ../calculator_designer_ext/ 版一字不差。
+ void onDigit(int digit);
+ void onDot();
+ void onOperator(QChar op);
+ void onLeftParen();
+ void onRightParen();
+ void onEquals();
+ void onClear();
+ void onBackspace();
+
+ QString displayText() const;
+ QString expressionText() const;
+
+ // 【新增】把一条历史表达式回填到编辑区(历史双击时调用)。
+ void loadExpression(const QString& expr);
+
+signals:
+ // 【新增】按 = 求值成功后发出,参数为(自动补全括号后的表达式, 结果文本)。
+ void evaluated(const QString& expression, const QString& result);
+
+private:
+ // 单个运算数(数字串)允许的最大长度,要求不得小于 10 位。
+ static constexpr int kMaxOperandLength = 15;
+ static_assert(kMaxOperandLength >= 10, "单个运算数的最大长度不得小于 10 位");
+ // 表达式总长度上限,要求不得低于 100 个字符。
+ static constexpr int kMaxExpressionLength = 200;
+ static_assert(kMaxExpressionLength >= 100, "表达式总长度上限不得低于 100 个字符");
+
+ QString currentOperandToken() const;
+ int currentOperandDigitCount() const;
+ QString lastNumberToken() const;
+ void refreshDisplays();
+ void showError();
+
+ Ui::CalculatorForm* ui;
+ QString m_expression; // 当前正在编辑的表达式,例如 "12+3*(4-2"
+ QString m_lastResultText; // 上一次成功计算得到的结果文本
+ int m_openParenCount = 0; // 尚未闭合的左括号数量
+ bool m_resultShown = false; // 显示区当前展示的是计算结果而非正在输入的数字
+ bool m_error = false;
+};
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.cpp b/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.cpp
new file mode 100644
index 0000000..4967eab
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.cpp
@@ -0,0 +1,145 @@
+#include "expressionevaluator.h"
+
+namespace {
+
+// 递归下降解析器:expression := term (('+'|'-') term)*
+// term := factor (('*'|'/') factor)*
+// factor := ('+'|'-') factor | '(' expression ')' | number
+// 括号会先被完整求值,天然实现"优先级提升";乘除法在 term 层处理,
+// 天然高于 expression 层的加减法。
+// 【Day3 calculator_mainwindow】与 ../calculator_designer_ext/ 版一字不差。
+class Parser {
+public:
+ explicit Parser(const QString& text) : m_text(text) {}
+
+ ExpressionEvaluator::Result parse() {
+ ExpressionEvaluator::Result result;
+ skipSpaces();
+ if (atEnd()) {
+ result.errorMessage = QStringLiteral("表达式为空");
+ return result;
+ }
+
+ bool ok = true;
+ const double value = parseExpression(ok);
+ skipSpaces();
+ if (!ok) {
+ result.errorMessage = m_error;
+ return result;
+ }
+ if (!atEnd()) {
+ result.errorMessage = QStringLiteral("表达式包含无法识别的字符");
+ return result;
+ }
+
+ result.ok = true;
+ result.value = value;
+ return result;
+ }
+
+private:
+ double parseExpression(bool& ok) {
+ double value = parseTerm(ok);
+ while (ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('+')) {
+ advance();
+ value += parseTerm(ok);
+ } else if (peek() == QLatin1Char('-')) {
+ advance();
+ value -= parseTerm(ok);
+ } else {
+ break;
+ }
+ }
+ return value;
+ }
+
+ double parseTerm(bool& ok) {
+ double value = parseFactor(ok);
+ while (ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('*')) {
+ advance();
+ value *= parseFactor(ok);
+ } else if (peek() == QLatin1Char('/')) {
+ advance();
+ const double rhs = parseFactor(ok);
+ if (!ok) return 0.0;
+ if (rhs == 0.0) {
+ fail(QStringLiteral("除数不能为零"), ok);
+ return 0.0;
+ }
+ value /= rhs;
+ } else {
+ break;
+ }
+ }
+ return value;
+ }
+
+ double parseFactor(bool& ok) {
+ skipSpaces();
+ if (peek() == QLatin1Char('+')) {
+ advance();
+ return parseFactor(ok);
+ }
+ if (peek() == QLatin1Char('-')) {
+ advance();
+ return -parseFactor(ok);
+ }
+ if (peek() == QLatin1Char('(')) {
+ advance();
+ const double value = parseExpression(ok);
+ if (!ok) return 0.0;
+ skipSpaces();
+ if (peek() != QLatin1Char(')')) {
+ fail(QStringLiteral("括号不匹配"), ok);
+ return 0.0;
+ }
+ advance();
+ return value;
+ }
+ return parseNumber(ok);
+ }
+
+ double parseNumber(bool& ok) {
+ skipSpaces();
+ const int start = m_pos;
+ bool hasDigits = false;
+ while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
+ if (!atEnd() && peek() == QLatin1Char('.')) {
+ advance();
+ while (!atEnd() && peek().isDigit()) { advance(); hasDigits = true; }
+ }
+ if (!hasDigits) {
+ fail(QStringLiteral("表达式格式错误,缺少数字"), ok);
+ return 0.0;
+ }
+ const QString token = m_text.mid(start, m_pos - start);
+ bool numOk = false;
+ const double value = token.toDouble(&numOk);
+ if (!numOk) {
+ fail(QStringLiteral("数字格式错误"), ok);
+ return 0.0;
+ }
+ return value;
+ }
+
+ void skipSpaces() { while (!atEnd() && peek().isSpace()) advance(); }
+ bool atEnd() const { return m_pos >= m_text.size(); }
+ QChar peek() const { return atEnd() ? QChar() : m_text.at(m_pos); }
+ void advance() { ++m_pos; }
+ void fail(const QString& message, bool& ok) { ok = false; m_error = message; }
+
+ const QString& m_text;
+ int m_pos = 0;
+ QString m_error;
+};
+
+} // namespace
+
+ExpressionEvaluator::Result ExpressionEvaluator::evaluate(const QString& expression) {
+ Parser parser(expression);
+ return parser.parse();
+}
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.h b/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.h
new file mode 100644
index 0000000..a4dbc00
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/expressionevaluator.h
@@ -0,0 +1,22 @@
+#pragma once
+
+#include
+
+// 对形如 "1+2*(3-4)" 的中缀表达式求值:
+// 乘除优先级高于加减,括号可提升优先级。
+// 【Day3 calculator_mainwindow】与 ../calculator_designer_ext/ 版一字不差,
+// 求值逻辑独立于界面外壳——Day3 把 QWidget 换成 QMainWindow,本类不动。
+class ExpressionEvaluator {
+public:
+ struct Result {
+ bool ok = false;
+ double value = 0.0;
+ QString errorMessage;
+ };
+
+ // 结果显示精度(有效数字位数),要求不得小于 10。
+ static constexpr int kResultPrecision = 12;
+ static_assert(kResultPrecision >= 10, "结果显示精度不得小于 10 位有效数字");
+
+ static Result evaluate(const QString& expression);
+};
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/main.cpp b/docs/teaching/examples/calculator_mainwindow_qss/main.cpp
new file mode 100644
index 0000000..9f08729
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/main.cpp
@@ -0,0 +1,24 @@
+// ============================================================================
+// calculator_mainwindow_qss —— Day3 下午版程序入口(上午版 + QSS 双主题)
+// 对应教案 OUTLINE_4DAY.md Day3 下午「布局/控件 + QSS 美化」。
+// 与 ../calculator_mainwindow/main.cpp 仅 include 的头不同(本目录自己的),
+// 逻辑一致:offscreen 时调 MainWindow::runSelfTest(),按返回值决定退出码。
+// ============================================================================
+#include
+#include
+
+#include "mainwindow.h"
+
+int main(int argc, char* argv[]) {
+ QApplication app(argc, argv);
+ MainWindow w;
+ w.show();
+
+ if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
+ QTimer::singleShot(0, &w, [&w] {
+ const bool ok = w.runSelfTest();
+ QCoreApplication::exit(ok ? 0 : 1);
+ });
+ }
+ return app.exec();
+}
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.cpp b/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.cpp
new file mode 100644
index 0000000..4d5b4e9
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.cpp
@@ -0,0 +1,277 @@
+// ============================================================================
+// mainwindow.cpp —— Day3 下午版实现(上午版 + QSS 双主题)
+// 与 ../calculator_mainwindow/mainwindow.cpp 的差异全部集中在本文件的 QSS 部分
+// (见 kLightQss / kDarkQss / applyStyle / onToggleTheme / 视图菜单多一项),
+// 菜单/停靠窗/状态栏/历史/自测的计算用例部分一字未改。
+// ============================================================================
+#include "mainwindow.h"
+#include "calculatordesigner.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// ---- QSS:按按键功能分色,ID 选择器精确定位(widget.ui 里已设好 objectName)----
+// 教学要点:QSS 选择器认的是 objectName——setObjectName("btnEquals") 之后才能用
+// #btnEquals{...}。本例的 objectName 在 widget.ui 里已定义,无需再 setObjectName。
+namespace {
+
+// 浅色主题:数字=浅蓝、运算=橙、等号=绿、括号=灰、清空/退格=红、显示区=米黄
+const char* kLightQss = R"(
+QMainWindow { background: #f5f5f5; }
+QLineEdit#display, QTextEdit#expressionEdit {
+ background: #fffbe6; color: #222;
+ border: 1px solid #d0c8a0; border-radius: 4px;
+ font-size: 18px; padding: 4px;
+}
+QPushButton {
+ border: 1px solid #bbb; border-radius: 4px;
+ padding: 8px; font-size: 15px; min-height: 28px;
+}
+QPushButton#btn0, QPushButton#btn1, QPushButton#btn2, QPushButton#btn3,
+QPushButton#btn4, QPushButton#btn5, QPushButton#btn6, QPushButton#btn7,
+QPushButton#btn8, QPushButton#btn9, QPushButton#btnDot {
+ background: #e3f2fd; border-color: #90caf9; color: #0d47a1;
+}
+QPushButton#btnPlus, QPushButton#btnMinus, QPushButton#btnMul, QPushButton#btnDiv {
+ background: #ffe0b2; border-color: #ffb74d; color: #e65100;
+}
+QPushButton#btnEquals {
+ background: #c8e6c9; border-color: #81c784; color: #1b5e20; font-weight: bold;
+}
+QPushButton#btnLeftParen, QPushButton#btnRightParen {
+ background: #eceff1; border-color: #b0bec5; color: #37474f;
+}
+QPushButton#btnClear, QPushButton#btnBackspace {
+ background: #ffcdd2; border-color: #e57373; color: #b71c1c;
+}
+QPushButton:hover { background: #ddd; }
+QPushButton:pressed { background: #bbb; }
+QListWidget { background: #fafafa; alternate-background-color: #eeeeee; }
+QStatusBar { background: #e0e0e0; }
+QMenuBar { background: #eceff1; }
+QDockWidget { color: #333; }
+)";
+
+// 深色主题:整体深底浅字,功能分色保留但调暗,便于和浅色对照验证「切换生效」
+const char* kDarkQss = R"(
+QMainWindow { background: #2b2b2b; color: #eee; }
+QLineEdit#display, QTextEdit#expressionEdit {
+ background: #3a3a2a; color: #fffbe6;
+ border: 1px solid #6b6b4a; border-radius: 4px;
+ font-size: 18px; padding: 4px;
+}
+QPushButton {
+ border: 1px solid #555; border-radius: 4px;
+ padding: 8px; font-size: 15px; min-height: 28px; color: #eee;
+}
+QPushButton#btn0, QPushButton#btn1, QPushButton#btn2, QPushButton#btn3,
+QPushButton#btn4, QPushButton#btn5, QPushButton#btn6, QPushButton#btn7,
+QPushButton#btn8, QPushButton#btn9, QPushButton#btnDot {
+ background: #1565c0; border-color: #42a5f5; color: #e3f2fd;
+}
+QPushButton#btnPlus, QPushButton#btnMinus, QPushButton#btnMul, QPushButton#btnDiv {
+ background: #d84315; border-color: #ff7043; color: #ffe0b2;
+}
+QPushButton#btnEquals {
+ background: #2e7d32; border-color: #66bb6a; color: #c8e6c9; font-weight: bold;
+}
+QPushButton#btnLeftParen, QPushButton#btnRightParen {
+ background: #455a64; border-color: #78909c; color: #eceff1;
+}
+QPushButton#btnClear, QPushButton#btnBackspace {
+ background: #c62828; border-color: #ef5350; color: #ffcdd2;
+}
+QPushButton:hover { background: #666; }
+QPushButton:pressed { background: #888; }
+QListWidget { background: #333; alternate-background-color: #3d3d3d; color: #eee; }
+QStatusBar { background: #1f1f1f; color: #eee; }
+QMenuBar { background: #37474f; color: #eee; }
+QDockWidget { color: #eee; }
+)";
+
+} // namespace
+
+MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
+ setWindowTitle(QStringLiteral("计算器(QMainWindow + QSS 版)"));
+
+ m_calc = new CalculatorDesigner(this);
+ setCentralWidget(m_calc);
+
+ buildMenu();
+ buildDock();
+ buildStatusBar();
+
+ connect(m_calc, &CalculatorDesigner::evaluated, this, &MainWindow::onEvaluated);
+
+ applyStyle(false); // 默认浅色主题
+}
+
+MainWindow::~MainWindow() = default;
+
+void MainWindow::buildMenu() {
+ QMenu* fileMenu = menuBar()->addMenu(QStringLiteral("文件(&F)"));
+ m_actQuit = fileMenu->addAction(QStringLiteral("退出(&Q)"));
+ m_actQuit->setShortcut(QKeySequence(QStringLiteral("Ctrl+Q")));
+ connect(m_actQuit, &QAction::triggered, this, &QWidget::close);
+
+ QMenu* editMenu = menuBar()->addMenu(QStringLiteral("编辑(&E)"));
+ m_actClearHistory = editMenu->addAction(QStringLiteral("清空历史(&C)"));
+ connect(m_actClearHistory, &QAction::triggered, this, &MainWindow::onClearHistory);
+
+ QMenu* viewMenu = menuBar()->addMenu(QStringLiteral("视图(&V)"));
+ m_actToggleHistory = viewMenu->addAction(QStringLiteral("显示历史(&H)"));
+ m_actToggleHistory->setCheckable(true);
+ m_actToggleHistory->setChecked(true);
+ connect(m_actToggleHistory, &QAction::toggled, this, &MainWindow::onToggleHistoryVisible);
+
+ // 【下午版新增】深色主题切换:checkable,勾上=深色
+ m_actToggleTheme = viewMenu->addAction(QStringLiteral("深色主题(&D)"));
+ m_actToggleTheme->setCheckable(true);
+ m_actToggleTheme->setChecked(false);
+ connect(m_actToggleTheme, &QAction::toggled, this, &MainWindow::onToggleTheme);
+
+ QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("帮助(&H)"));
+ m_actAbout = helpMenu->addAction(QStringLiteral("关于(&A)"));
+ connect(m_actAbout, &QAction::triggered, this, &MainWindow::onAbout);
+}
+
+void MainWindow::buildDock() {
+ m_history = new QListWidget(this);
+ m_history->setAlternatingRowColors(true);
+ m_dock = new QDockWidget(QStringLiteral("历史"), this);
+ m_dock->setObjectName(QStringLiteral("historyDock"));
+ m_dock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea | Qt::BottomDockWidgetArea);
+ m_dock->setWidget(m_history);
+ addDockWidget(Qt::RightDockWidgetArea, m_dock);
+ connect(m_history, &QListWidget::itemDoubleClicked, this, &MainWindow::onHistoryDoubleClicked);
+}
+
+void MainWindow::buildStatusBar() {
+ statusBar()->showMessage(QStringLiteral("就绪"));
+}
+
+void MainWindow::refreshStatus(const QString& msg) {
+ statusBar()->showMessage(msg, 3000);
+}
+
+// 【下午版新增】主题切换:用 qApp->setStyleSheet 全局生效(影响所有窗口及其子部件)。
+// 注意 QSS 级联——qApp 级样式会被 widget 级 setStyleSheet 覆盖;本例只用 qApp 级,
+// 避免和未来某控件局部样式打架(Day3 下午坑卡:setStyleSheet 级联失控)。
+void MainWindow::applyStyle(bool dark) {
+ m_dark = dark;
+ qApp->setStyleSheet(QString::fromUtf8(dark ? kDarkQss : kLightQss));
+}
+
+void MainWindow::onToggleTheme(bool dark) {
+ applyStyle(dark);
+ refreshStatus(dark ? QStringLiteral("已切换深色主题") : QStringLiteral("已切换浅色主题"));
+}
+
+void MainWindow::onAbout() {
+ QMessageBox::about(this, QStringLiteral("关于"),
+ QStringLiteral(
+ "计算器(QMainWindow + QSS 版)
"
+ "Day3 下午教学参考实现:在上午版基础上加 QSS 双主题美化——"
+ "数字/运算/等号/括号/清空退格按键分色,:hover/:pressed 视觉反馈,"
+ "深/浅主题切换。
"
+ "对应教案 OUTLINE_4DAY.md Day3 下午。"));
+}
+
+void MainWindow::onClearHistory() {
+ m_history->clear();
+ refreshStatus(QStringLiteral("已清空历史"));
+}
+
+void MainWindow::onToggleHistoryVisible(bool checked) {
+ m_dock->setVisible(checked);
+}
+
+void MainWindow::onEvaluated(const QString& expr, const QString& result) {
+ m_history->addItem(expr + QStringLiteral(" = ") + result);
+ refreshStatus(QStringLiteral("结果: ") + result);
+}
+
+void MainWindow::onHistoryDoubleClicked(QListWidgetItem* item) {
+ const QString text = item->text();
+ const int idx = text.lastIndexOf(QStringLiteral(" = "));
+ if (idx < 0) return;
+ m_calc->loadExpression(text.left(idx));
+ refreshStatus(QStringLiteral("已回填表达式"));
+}
+
+// ------------------------------------------------------------ 自测 ----
+bool MainWindow::runSelfTest() {
+ auto expect = [](const char* label, bool cond) -> bool {
+ if (!cond) { qCritical() << "自测失败:" << label; return false; }
+ qInfo() << "自测通过:" << label; return true;
+ };
+ auto expectEq = [](const char* label, const QString& actual, const QString& expected) -> bool {
+ if (actual != expected) {
+ qCritical() << "自测失败:" << label << "实际=" << actual << "预期=" << expected;
+ return false;
+ }
+ qInfo() << "自测通过:" << label << "=>" << actual;
+ return true;
+ };
+
+ // —— 计算用例与上午版一致(验证加 QSS 后行为不变)——
+ m_calc->onClear();
+ m_calc->onDigit(7); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3); m_calc->onEquals();
+ if (!expectEq("7 + 3 =", m_calc->displayText(), QStringLiteral("10"))) return false;
+ if (!expect("历史追加第 1 条", m_history->count() == 1)) return false;
+
+ m_calc->onClear();
+ m_calc->onDigit(6); m_calc->onOperator(QLatin1Char('/')); m_calc->onDigit(0); m_calc->onEquals();
+ if (!expectEq("6 ÷ 0 =", m_calc->displayText(), QStringLiteral("Error"))) return false;
+ if (!expect("除零 Error 不进历史", m_history->count() == 1)) return false;
+
+ m_calc->onClear();
+ m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+'));
+ m_calc->onDigit(3); m_calc->onOperator(QLatin1Char('*'));
+ m_calc->onDigit(4); m_calc->onEquals();
+ if (!expectEq("2 + 3 × 4 =", m_calc->displayText(), QStringLiteral("14"))) return false;
+
+ m_calc->onClear();
+ m_calc->onLeftParen();
+ m_calc->onDigit(2); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(3);
+ m_calc->onRightParen();
+ m_calc->onOperator(QLatin1Char('*'));
+ m_calc->onDigit(4); m_calc->onEquals();
+ if (!expectEq("(2 + 3) × 4 =", m_calc->displayText(), QStringLiteral("20"))) return false;
+
+ // —— 【下午版新增】主题切换确实改变全局 styleSheet ——
+ applyStyle(true);
+ if (!expect("深色主题 styleSheet 非空", !qApp->styleSheet().isEmpty())) return false;
+ if (!expect("深色主题 styleSheet 含深色标识",
+ qApp->styleSheet().contains(QStringLiteral("#2b2b2b")))) return false;
+ applyStyle(false);
+ if (!expect("浅色主题 styleSheet 含浅色标识",
+ qApp->styleSheet().contains(QStringLiteral("#f5f5f5")))) return false;
+ if (!expect("主题状态 m_dark 已回浅", !m_dark)) return false;
+
+ // —— 历史双击回填 + 清空(与上午版一致)——
+ m_calc->onClear();
+ m_calc->onLeftParen();
+ m_calc->onDigit(1); m_calc->onOperator(QLatin1Char('+')); m_calc->onDigit(2);
+ m_calc->onEquals();
+ QListWidgetItem* last = m_history->item(m_history->count() - 1);
+ if (!expectEq("最后一条历史", last->text(), QStringLiteral("(1+2) = 3"))) return false;
+ onHistoryDoubleClicked(last);
+ if (!expectEq("双击回填表达式区", m_calc->expressionText(), QStringLiteral("(1+2)"))) return false;
+ onClearHistory();
+ if (!expect("清空历史后 count==0", m_history->count() == 0)) return false;
+
+ if (!expect("关于 action 已创建", m_actAbout != nullptr)) return false;
+ if (!expect("主题 action 已创建", m_actToggleTheme != nullptr)) return false;
+
+ return true;
+}
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.h b/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.h
new file mode 100644
index 0000000..c1c65dd
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/mainwindow.h
@@ -0,0 +1,56 @@
+// ============================================================================
+// mainwindow.h —— Day3 下午版:上午版 + QSS 界面美化(双主题)
+//
+// 相对 ../calculator_mainwindow/(上午版)的改动:
+// ① 视图菜单新增「深色主题」可勾选项;
+// ② applyStyle(bool dark) 按主题切换 setStyleSheet;
+// ③ runSelfTest() 增加「主题切换确实改变 styleSheet」的断言。
+// 计算器面板、菜单/停靠窗/状态栏结构与上午版完全一致——QSS 与逻辑正交,
+// 正是 Day3 下午「美化」环节要传达的核心点。
+// ============================================================================
+#ifndef CALCULATOR_MAINWINDOW_QSS_H
+#define CALCULATOR_MAINWINDOW_QSS_H
+
+#include
+
+class CalculatorDesigner;
+class QListWidget;
+class QListWidgetItem;
+class QDockWidget;
+class QAction;
+
+class MainWindow : public QMainWindow {
+ Q_OBJECT
+public:
+ explicit MainWindow(QWidget* parent = nullptr);
+ ~MainWindow() override;
+
+ bool runSelfTest();
+
+private slots:
+ void onAbout();
+ void onClearHistory();
+ void onToggleHistoryVisible(bool checked);
+ void onToggleTheme(bool dark); // 【下午版新增】深/浅主题切换
+ void onEvaluated(const QString& expr, const QString& result);
+ void onHistoryDoubleClicked(QListWidgetItem* item);
+
+private:
+ void buildMenu();
+ void buildDock();
+ void buildStatusBar();
+ void refreshStatus(const QString& msg);
+ void applyStyle(bool dark); // 【下午版新增】
+
+ CalculatorDesigner* m_calc = nullptr;
+ QListWidget* m_history = nullptr;
+ QDockWidget* m_dock = nullptr;
+ QAction* m_actQuit = nullptr;
+ QAction* m_actClearHistory = nullptr;
+ QAction* m_actToggleHistory = nullptr;
+ QAction* m_actToggleTheme = nullptr; // 【下午版新增】
+ QAction* m_actAbout = nullptr;
+ bool m_dark = false; // 【下午版新增】当前是否深色主题
+};
+
+#endif // CALCULATOR_MAINWINDOW_QSS_H
diff --git a/docs/teaching/examples/calculator_mainwindow_qss/widget.ui b/docs/teaching/examples/calculator_mainwindow_qss/widget.ui
new file mode 100644
index 0000000..e446f09
--- /dev/null
+++ b/docs/teaching/examples/calculator_mainwindow_qss/widget.ui
@@ -0,0 +1,200 @@
+
+
+ CalculatorForm
+
+
+
+ 0
+ 0
+ 358
+ 320
+
+
+
+ 计算器面板
+
+
+ -
+
+
+
+ 0
+ 50
+
+
+
+
+ 16777215
+ 80
+
+
+
+ true
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+
+ -
+
+
+ 0
+
+
+ Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter
+
+
+ true
+
+
+
+ -
+
+
-
+
+
+ 7
+
+
+
+ -
+
+
+ 8
+
+
+
+ -
+
+
+ 9
+
+
+
+ -
+
+
+ ÷
+
+
+
+ -
+
+
+ 4
+
+
+
+ -
+
+
+ 5
+
+
+
+ -
+
+
+ 6
+
+
+
+ -
+
+
+ ×
+
+
+
+ -
+
+
+ 1
+
+
+
+ -
+
+
+ 2
+
+
+
+ -
+
+
+ 3
+
+
+
+ -
+
+
+ -
+
+
+
+ -
+
+
+ 0
+
+
+
+ -
+
+
+ .
+
+
+
+ -
+
+
+ =
+
+
+
+ -
+
+
+ +
+
+
+
+ -
+
+
+ C
+
+
+
+ -
+
+
+ ⌫
+
+
+
+ -
+
+
+ (
+
+
+
+ -
+
+
+ )
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/teaching/examples/mainwindow_showcase/README.md b/docs/teaching/examples/mainwindow_showcase/README.md
new file mode 100644
index 0000000..43484fe
--- /dev/null
+++ b/docs/teaching/examples/mainwindow_showcase/README.md
@@ -0,0 +1,104 @@
+# QMainWindow 教学综合示例(qt_mainwindow_showcase)
+
+覆盖川大 wiki「5 QMainWindow」(pageId 58954529,https://wiki.suncaper.net/display/scu2023/5+QMainWindow)
+的 **5.1–5.6 全部六大核心组件**,形态为**极简文本编辑器**(`QTextEdit` 中心部件),
+纯组件演示、**无文件 I/O**。主窗体结构用 **Qt Designer(`.ui`)** 设计,应用图标与动作图标
+走 **资源文件(`.qrc`)**。
+
+> 仓库已有 `p03/ch05/` 的零散单组件示例(central_widget、dock_widget);本示例是
+> **综合演示**,把六者整合到一个 `QMainWindow` 程序里,并能交互操控它们。
+
+## 六大组件 ↔ 代码映射
+
+| wiki | 组件 | 代码位置 | 演示的 API |
+|---|---|---|---|
+| 5.5 | 中心部件 | `mainwindow.ui`:`centralwidget/textEdit`;`.cpp`:`textChanged`/`cursorPositionChanged → updateStatusBar` | `setCentralWidget(QTextEdit)` |
+| 5.1 | 菜单栏 | `.ui`:`menubar` + `menuFile/menuView/menuHelp/menuToolBarArea` + 12 个 `QAction`;`.cpp`:`on_action*_triggered/toggled` | `menuBar()`/`addMenu`/`QAction` |
+| 5.2 | 工具栏 | `.ui`:`mainToolBar`(复用 `QAction`);`.cpp`:`setMovable`/`setAllowedAreas`/`setVisible` | `addToolBar`/`setAllowedAreas`/`setMovable` |
+| 5.3 | 状态栏 | `.cpp`:`addPermanentWidget(statusPosLabel)`/`showMessage` | `addWidget`/`insertWidget`/`removeWidget`(见下) |
+| 5.4 | 浮动窗口 | `.ui`:`dockWidget`;`.cpp`:`setFloating`/`setAllowedAreas` | `QDockWidget`/`addDockWidget` |
+| 5.6 | 资源文件 | `mainwindow.qrc` + `.ui` 的 `windowIcon` + 各 `QAction` 图标 | `:/icons/*.png` |
+
+## 关键设计点
+
+### 1. QAction 是菜单项与工具按钮的公共抽象(5.1)
+12 个 `QAction` 在 `.ui` 的 `MainWindow` widget 内**定义一次**,被菜单和工具栏**共同引用**
+(``)——这正是 wiki 强调的「同一动作,加进菜单显示为菜单项、
+加进工具栏显示为工具按钮」。例如 `actionNew` 同时出现在「文件」菜单和主工具栏。
+
+### 2. 信号槽:on_\_\ 自动连接
+槽命名遵循 Qt 约定,由 `setupUi()` 触发的 `QMetaObject::connectSlotsByName` **自动连接**,
+无需手写 `connect`(KISS)。例如 `on_actionToggleDockFloating_triggered()` 自动连接
+`actionToggleDockFloating` 的 `triggered` 信号。
+
+### 3. 视图菜单 = 「操控」各组件的入口
+| 动作 | 行为 | 对应组件/API |
+|---|---|---|
+| 显示主工具栏 ✓ | `mainToolBar->setVisible(checked)` | 5.2 |
+| 显示状态栏 ✓ | `statusBar()->setVisible(checked)` | 5.3 |
+| 工具栏可移动 ✓ | `mainToolBar->setMovable(checked)` | 5.2 `setMovable` |
+| 停靠窗口浮动/停靠 | `dockWidget->setFloating(!isFloating())` | 5.4 `setFloating` |
+| 显示浮动窗口 | `dockWidget->show()` | 5.4 重新显示(被 X 关闭后找回) |
+| 关闭浮动窗口 | `dockWidget->hide()` | 5.4 直接隐藏 |
+| 工具栏停靠区(子菜单 5 项) | `mainToolBar->setAllowedAreas(...)` | 5.2 `setAllowedAreas` |
+
+### 4. 状态栏 API(5.3)
+运行时演示 `addPermanentWidget`(永久「行:列 字数」标签)+ `showMessage`(动作临时提示)。
+其余三 API 的等价用法(教学参考):
+
+```cpp
+// addWidget:在状态栏左侧添加普通部件(非永久,会挤占临时消息区)
+statusBar()->addWidget(new QLabel("就绪"));
+// insertWidget:在 index 位置插入部件
+statusBar()->insertWidget(0, new QLabel("插入"));
+// removeWidget:移除某部件
+statusBar()->removeWidget(someLabel);
+```
+
+## 目录结构
+```
+mainwindow_showcase/
+├── mainwindow.ui # Designer 表单:主窗体结构 + 12 个 QAction
+├── mainwindow_showcase.h/.cpp # MainWindow 类(动态行为)
+├── main.cpp # QApplication + offscreen 自动退出
+├── mainwindow.qrc # 资源清单(5 个图标)
+├── gen_icons.py # 图标生成脚本(标准库,无依赖)
+├── icons/ # app/new/quit/about/dock.png(gen_icons.py 产物)
+└── README.md # 本文档
+```
+
+## 构建与运行(Windows + MinGW 7.3.0 + Qt 5.14.2)
+
+> 必须用与 Qt 5.14.2 ABI 匹配的 mingw730_64(`E:/Qt/Qt5.14.2/Tools/mingw730_64`),
+> 不可用 PATH 中可能存在的更高版本 g++。
+
+```bash
+# 1) 生成图标
+python docs/teaching/examples/mainwindow_showcase/gen_icons.py
+
+# 2) 配置(在仓库根;本示例随 docs/teaching/examples 一起被顶层 CMakeLists 收录)
+cmake -S . -B build_mw -G "MinGW Makefiles" \
+ -DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64 \
+ -DCMAKE_CXX_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/g++.exe \
+ -DCMAKE_C_COMPILER=E:/Qt/Qt5.14.2/Tools/mingw730_64/bin/gcc.exe
+
+# 3) 构建单目标
+cmake --build build_mw --target qt_mainwindow_showcase
+
+# 4) 运行(GUI)
+PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
+QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
+ ./build_mw/docs/teaching/examples/qt_mainwindow_showcase.exe
+
+# 5) 离屏自动退出验证(退出码应为 0)
+QT_QPA_PLATFORM=offscreen \
+QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms \
+PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH \
+ ./build_mw/docs/teaching/examples/qt_mainwindow_showcase.exe; echo "exit=$?"
+```
+
+## 教学要点小结
+- 一个 `QMainWindow` = 菜单栏(≤1)+ 工具栏(多个)+ 状态栏(1)+ 浮动窗口(多个)+ 中心部件(1)。
+- `QAction` 是「动作」的抽象,菜单与工具栏共享。
+- 工具栏/浮动窗口都是**可移动**的,停靠区域可由 `setAllowedAreas` 限定。
+- 资源文件(`.qrc`)把图标编译进可执行文件,运行时用 `:/...` 引用,无需关心磁盘文件。
diff --git a/docs/teaching/examples/mainwindow_showcase/gen_icons.py b/docs/teaching/examples/mainwindow_showcase/gen_icons.py
new file mode 100644
index 0000000..70e57b3
--- /dev/null
+++ b/docs/teaching/examples/mainwindow_showcase/gen_icons.py
@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""生成 qt_mainwindow_showcase 所需的极简 PNG 图标。
+
+仅用 Python 标准库(zlib + struct)手写最小 PNG 编码器,无第三方依赖。
+幂等:重复运行覆盖生成相同文件。
+
+ python gen_icons.py # *nix / 已配置 py 启动器的 Windows
+ py gen_icons.py # Windows 官方 py 启动器
+"""
+import os
+import struct
+import zlib
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ICONS_DIR = os.path.join(HERE, "icons")
+
+# 5×7 点阵字模("1" = 前景白色像素)
+GLYPHS = {
+ "M": ["10001", "11011", "10101", "10001", "10001", "10001", "10001"],
+ "N": ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
+ "Q": ["01110", "10001", "10001", "10001", "10101", "10011", "01111"],
+ "A": ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
+ "D": ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
+}
+
+
+def write_png(path, width, height, rgb_rows):
+ """写一张 8 位、颜色类型 2(RGB)的 PNG。rgb_rows: 每行 width*3 个 0-255 整数。"""
+ def chunk(tag, data):
+ body = tag + data
+ return (struct.pack(">I", len(data)) + body +
+ struct.pack(">I", zlib.crc32(body) & 0xffffffff))
+
+ sig = b"\x89PNG\r\n\x1a\n"
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
+ raw = b"".join(b"\x00" + bytes(row) for row in rgb_rows) # 每行前加 filter byte(0)
+ idat = zlib.compress(raw, 9)
+ with open(path, "wb") as f:
+ f.write(sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", idat) + chunk(b"IEND", b""))
+
+
+def make_icon(size, bg, letter):
+ """生成 size×size 的像素矩阵:纯色背景 + 居中白色字母。"""
+ glyph = GLYPHS[letter]
+ bw, bh = 5, 7
+ pix = 1 if size <= 16 else 2 # 16px 用 1:1 字模,32px 放大 2 倍
+ gw, gh = bw * pix, bh * pix
+ ox = (size - gw) // 2
+ oy = (size - gh) // 2
+ rows = []
+ for y in range(size):
+ row = []
+ for x in range(size):
+ gx, gy = (x - ox) // pix, (y - oy) // pix
+ if 0 <= gx < bw and 0 <= gy < bh and glyph[gy][gx] == "1":
+ row.extend((255, 255, 255))
+ else:
+ row.extend(bg)
+ rows.append(row)
+ return rows
+
+
+# (文件名, 尺寸, 背景色 RGB, 字母)
+SPECS = [
+ ("app.png", 32, (30, 80, 180), "M"),
+ ("new.png", 16, (40, 150, 70), "N"),
+ ("quit.png", 16, (200, 50, 50), "Q"),
+ ("about.png", 16, (220, 140, 30), "A"),
+ ("dock.png", 16, (130, 60, 180), "D"),
+]
+
+
+def main():
+ os.makedirs(ICONS_DIR, exist_ok=True)
+ for name, size, bg, letter in SPECS:
+ write_png(os.path.join(ICONS_DIR, name), size, size, make_icon(size, bg, letter))
+ print(f"wrote {name} ({size}x{size})")
+ print(f"done -> {ICONS_DIR}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/docs/teaching/examples/mainwindow_showcase/icons/about.png b/docs/teaching/examples/mainwindow_showcase/icons/about.png
new file mode 100644
index 0000000..0308a0a
Binary files /dev/null and b/docs/teaching/examples/mainwindow_showcase/icons/about.png differ
diff --git a/docs/teaching/examples/mainwindow_showcase/icons/app.png b/docs/teaching/examples/mainwindow_showcase/icons/app.png
new file mode 100644
index 0000000..4b9d568
Binary files /dev/null and b/docs/teaching/examples/mainwindow_showcase/icons/app.png differ
diff --git a/docs/teaching/examples/mainwindow_showcase/icons/dock.png b/docs/teaching/examples/mainwindow_showcase/icons/dock.png
new file mode 100644
index 0000000..de26f6f
Binary files /dev/null and b/docs/teaching/examples/mainwindow_showcase/icons/dock.png differ
diff --git a/docs/teaching/examples/mainwindow_showcase/icons/new.png b/docs/teaching/examples/mainwindow_showcase/icons/new.png
new file mode 100644
index 0000000..dcfd7f6
Binary files /dev/null and b/docs/teaching/examples/mainwindow_showcase/icons/new.png differ
diff --git a/docs/teaching/examples/mainwindow_showcase/icons/quit.png b/docs/teaching/examples/mainwindow_showcase/icons/quit.png
new file mode 100644
index 0000000..9ea0e3d
Binary files /dev/null and b/docs/teaching/examples/mainwindow_showcase/icons/quit.png differ
diff --git a/docs/teaching/examples/mainwindow_showcase/main.cpp b/docs/teaching/examples/mainwindow_showcase/main.cpp
new file mode 100644
index 0000000..20f8a69
--- /dev/null
+++ b/docs/teaching/examples/mainwindow_showcase/main.cpp
@@ -0,0 +1,22 @@
+// ============================================================================
+// mainwindow_showcase —— QMainWindow 教学综合示例(程序入口)
+// 覆盖 wiki「5 QMainWindow」(pageId 58954529) 的 5.1–5.6 全部六大组件:
+// 5.1 菜单栏 · 5.2 工具栏 · 5.3 状态栏 · 5.4 浮动窗口 · 5.5 中心部件 · 5.6 资源文件
+// 形态:极简文本编辑器(QTextEdit 为中心部件),纯组件演示,无文件 I/O。
+// ============================================================================
+#include
+#include
+
+#include "mainwindow_showcase.h"
+
+int main(int argc, char *argv[]) {
+ QApplication app(argc, argv);
+ MainWindow w;
+ w.show();
+ // 仅在离屏自动化验证时(QT_QPA_PLATFORM=offscreen)200ms 后自动退出;
+ // 正常运行(有显示环境)窗口保持打开,不自动退出。仿 p03/ch05 风格。
+ if (qgetenv("QT_QPA_PLATFORM") == "offscreen") {
+ QTimer::singleShot(200, &app, &QApplication::quit);
+ }
+ return app.exec();
+}
diff --git a/docs/teaching/examples/mainwindow_showcase/mainwindow.qrc b/docs/teaching/examples/mainwindow_showcase/mainwindow.qrc
new file mode 100644
index 0000000..abc19cf
--- /dev/null
+++ b/docs/teaching/examples/mainwindow_showcase/mainwindow.qrc
@@ -0,0 +1,9 @@
+
+
+ icons/app.png
+ icons/new.png
+ icons/quit.png
+ icons/about.png
+ icons/dock.png
+
+
diff --git a/docs/teaching/examples/mainwindow_showcase/mainwindow.ui b/docs/teaching/examples/mainwindow_showcase/mainwindow.ui
new file mode 100644
index 0000000..2f43d8d
--- /dev/null
+++ b/docs/teaching/examples/mainwindow_showcase/mainwindow.ui
@@ -0,0 +1,299 @@
+
+
+ MainWindow
+
+
+
+ 0
+ 0
+ 720
+ 480
+
+
+
+ QMainWindow 教学综合示例
+
+
+
+ :/icons/app.png:/icons/app.png
+
+
+
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+
+ 0
+
+ -
+
+
+ 欢迎使用 QMainWindow 教学综合示例。
+在这里编辑文字,状态栏会实时显示光标行列与字数。
+视图菜单可操控工具栏、状态栏与浮动窗口。
+
+
+
+
+
+
+
+
+
+
+
+
+ 主工具栏
+
+
+ TopToolBarArea
+
+
+ false
+
+
+
+
+
+
+
+
+
+ 浮动窗口
+
+
+ 1
+
+
+
+ -
+
+
+ 我是浮动窗口(QDockWidget,5.4 铆接部件)。
+可拖出浮动,也可停靠到主窗口边缘。
+视图菜单的「停靠窗口浮动/停靠」可切换我的状态。
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+ :/icons/new.png:/icons/new.png
+
+
+ 新建
+
+
+ Ctrl+N
+
+
+ 清空编辑区(5.5 中心部件)
+
+
+
+
+
+ :/icons/quit.png:/icons/quit.png
+
+
+ 退出
+
+
+ Ctrl+Q
+
+
+ 退出程序
+
+
+
+
+ true
+
+
+ true
+
+
+ 显示主工具栏
+
+
+ 显示或隐藏主工具栏(5.2 工具栏)
+
+
+
+
+ true
+
+
+ true
+
+
+ 显示状态栏
+
+
+ 显示或隐藏状态栏(5.3 状态栏)
+
+
+
+
+ true
+
+
+ true
+
+
+ 工具栏可移动
+
+
+ 切换工具栏是否可拖动(5.2 setMovable)
+
+
+
+
+
+ :/icons/dock.png:/icons/dock.png
+
+
+ 停靠窗口浮动/停靠
+
+
+ 切换浮动窗口的浮动状态(5.4 setFloating)
+
+
+
+
+ 显示浮动窗口
+
+
+ 重新显示已关闭的浮动窗口(5.4 show)
+
+
+
+
+ 关闭浮动窗口
+
+
+ 隐藏浮动窗口(5.4 hide)
+
+
+
+
+ 停靠左侧
+
+
+ 限制工具栏只能停靠左侧(5.2 setAllowedAreas)
+
+
+
+
+ 停靠右侧
+
+
+ 限制工具栏只能停靠右侧
+
+
+
+
+ 停靠顶部
+
+
+ 限制工具栏只能停靠顶部
+
+
+
+
+ 停靠底部
+
+
+ 限制工具栏只能停靠底部
+
+
+
+
+ 允许全部区域
+
+
+ 工具栏允许停靠在任意区域
+
+
+
+
+
+ :/icons/about.png:/icons/about.png
+
+
+ 关于
+
+
+ 关于本程序
+
+
+
+
+
+
+
+
diff --git a/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.cpp b/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.cpp
new file mode 100644
index 0000000..79e823f
--- /dev/null
+++ b/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.cpp
@@ -0,0 +1,110 @@
+// ============================================================================
+// mainwindow_showcase.cpp —— MainWindow 实现(动态行为)
+//
+// 静态结构见 mainwindow.ui(Designer)。本文件只做:
+// - 资源文件应用(5.6):setWindowIcon 从 .qrc 加载
+// - 状态栏(5.3):addPermanentWidget + 实时行列/字数 + showMessage 临时提示
+// - 信号槽:on__ 自动连接(connectSlotsByName)
+// - 视图菜单:运行时操控工具栏/状态栏/浮动窗口
+// ============================================================================
+#include "mainwindow_showcase.h"
+#include "ui_mainwindow.h" // AUTOUIC 从 mainwindow.ui 生成
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+MainWindow::MainWindow(QWidget *parent)
+ : QMainWindow(parent), ui(new Ui::MainWindow) {
+ ui->setupUi(this);
+
+ // 5.6 资源文件:应用图标从 mainwindow.qrc 加载(:/icons/app.png)
+ setWindowIcon(QIcon(":/icons/app.png"));
+
+ // 5.3 状态栏:永久标签(addPermanentWidget),实时显示光标行列与字数
+ statusPosLabel = new QLabel(QStringLiteral("行:1 列:1 字数:0"), this);
+ statusBar()->addPermanentWidget(statusPosLabel);
+
+ // 5.5 中心部件 QTextEdit 的内容/光标变化 → 更新状态栏
+ connect(ui->textEdit, &QTextEdit::textChanged, this, &MainWindow::updateStatusBar);
+ connect(ui->textEdit, &QTextEdit::cursorPositionChanged, this, &MainWindow::updateStatusBar);
+ updateStatusBar();
+}
+
+MainWindow::~MainWindow() { delete ui; }
+
+// ---- 文件菜单 ----------------------------------------------------------
+void MainWindow::on_actionNew_triggered() {
+ ui->textEdit->clear();
+ statusBar()->showMessage(QStringLiteral("已新建"), 2000); // 5.3 临时提示
+}
+
+void MainWindow::on_actionQuit_triggered() { close(); }
+
+// ---- 视图菜单 —— 运行时操控各组件 --------------------------------------
+void MainWindow::on_actionToggleToolBar_toggled(bool checked) {
+ ui->mainToolBar->setVisible(checked);
+}
+
+void MainWindow::on_actionToggleStatusBar_toggled(bool checked) {
+ statusBar()->setVisible(checked);
+}
+
+void MainWindow::on_actionToggleToolBarMovable_toggled(bool checked) {
+ ui->mainToolBar->setMovable(checked); // 5.2 setMovable
+}
+
+void MainWindow::on_actionToggleDockFloating_triggered() {
+ ui->dockWidget->setFloating(!ui->dockWidget->isFloating()); // 5.4 setFloating
+}
+
+void MainWindow::on_actionShowDock_triggered() {
+ ui->dockWidget->show(); // 5.4 重新显示已关闭的浮动窗口
+}
+
+void MainWindow::on_actionHideDock_triggered() {
+ ui->dockWidget->hide(); // 5.4 关闭(隐藏)浮动窗口
+}
+
+void MainWindow::on_actionToolBarAreaLeft_triggered() {
+ ui->mainToolBar->setAllowedAreas(Qt::LeftToolBarArea); // 5.2 setAllowedAreas
+}
+void MainWindow::on_actionToolBarAreaRight_triggered() {
+ ui->mainToolBar->setAllowedAreas(Qt::RightToolBarArea);
+}
+void MainWindow::on_actionToolBarAreaTop_triggered() {
+ ui->mainToolBar->setAllowedAreas(Qt::TopToolBarArea);
+}
+void MainWindow::on_actionToolBarAreaBottom_triggered() {
+ ui->mainToolBar->setAllowedAreas(Qt::BottomToolBarArea);
+}
+void MainWindow::on_actionToolBarAreaAll_triggered() {
+ ui->mainToolBar->setAllowedAreas(Qt::AllToolBarAreas);
+}
+
+// ---- 帮助菜单 ----------------------------------------------------------
+void MainWindow::on_actionAbout_triggered() {
+ QMessageBox::about(this, QStringLiteral("关于"),
+ QStringLiteral(
+ "QMainWindow 教学综合示例
"
+ "一个极简文本编辑器,演示 QMainWindow 的六大核心组件"
+ "(wiki「5 QMainWindow」pageId 58954529):
"
+ "5.1 菜单栏 · 5.2 工具栏 · 5.3 状态栏 · "
+ "5.4 浮动窗口 · 5.5 中心部件 · 5.6 资源文件"));
+}
+
+// ---- 状态栏更新 --------------------------------------------------------
+void MainWindow::updateStatusBar() {
+ const QTextCursor c = ui->textEdit->textCursor();
+ const int line = c.blockNumber() + 1;
+ const int col = c.columnNumber() + 1;
+ const int count = ui->textEdit->toPlainText().size();
+ statusPosLabel->setText(
+ QStringLiteral("行:%1 列:%2 字数:%3").arg(line).arg(col).arg(count));
+}
diff --git a/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.h b/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.h
new file mode 100644
index 0000000..03692af
--- /dev/null
+++ b/docs/teaching/examples/mainwindow_showcase/mainwindow_showcase.h
@@ -0,0 +1,57 @@
+// ============================================================================
+// mainwindow_showcase.h —— MainWindow 类声明
+//
+// 主窗体的静态结构(central QTextEdit / menuBar / mainToolBar / statusBar /
+// dockWidget / 全部 QAction)在 mainwindow.ui 中用 Qt Designer 设计;
+// 本类只负责动态行为:信号槽、运行时操控、状态栏更新、关于对话框。
+//
+// 槽命名遵循 Qt 自动连接约定 on__,由 setupUi() 触发的
+// QMetaObject::connectSlotsByName 自动连接,无需手写 connect(KISS)。
+// ============================================================================
+#ifndef MAINWINDOW_SHOWCASE_H
+#define MAINWINDOW_SHOWCASE_H
+
+#include
+#include
+
+QT_BEGIN_NAMESPACE
+namespace Ui { class MainWindow; }
+QT_END_NAMESPACE
+
+class MainWindow : public QMainWindow {
+ Q_OBJECT
+
+public:
+ explicit MainWindow(QWidget *parent = nullptr);
+ ~MainWindow() override;
+
+private slots:
+ // 文件菜单
+ void on_actionNew_triggered();
+ void on_actionQuit_triggered();
+
+ // 视图菜单 —— 操控各组件
+ void on_actionToggleToolBar_toggled(bool checked); // 5.2 显示/隐藏工具栏
+ void on_actionToggleStatusBar_toggled(bool checked); // 5.3 显示/隐藏状态栏
+ void on_actionToggleToolBarMovable_toggled(bool checked); // 5.2 工具栏可移动
+ void on_actionToggleDockFloating_triggered(); // 5.4 浮动/停靠切换
+ void on_actionShowDock_triggered(); // 5.4 重新显示浮动窗口
+ void on_actionHideDock_triggered(); // 5.4 关闭浮动窗口
+ void on_actionToolBarAreaLeft_triggered(); // 5.2 setAllowedAreas
+ void on_actionToolBarAreaRight_triggered();
+ void on_actionToolBarAreaTop_triggered();
+ void on_actionToolBarAreaBottom_triggered();
+ void on_actionToolBarAreaAll_triggered();
+
+ // 帮助菜单
+ void on_actionAbout_triggered();
+
+ // 状态栏:根据中心编辑区更新「行:列 字数:N」
+ void updateStatusBar();
+
+private:
+ Ui::MainWindow *ui;
+ QLabel *statusPosLabel; // 状态栏永久标签(演示 5.3 addPermanentWidget)
+};
+
+#endif // MAINWINDOW_SHOWCASE_H