diff --git a/.gitignore b/.gitignore index e2d6a9b..58839e2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ ui_*.h # Tooling caches .qt/ .cache/ +__pycache__/ +*.pyc # Editor / OS .vscode/ diff --git a/docs/teaching/ppt/day1.pptx b/docs/teaching/ppt/day1.pptx new file mode 100644 index 0000000..6dbbea4 Binary files /dev/null and b/docs/teaching/ppt/day1.pptx differ diff --git a/docs/teaching/ppt/day2.pptx b/docs/teaching/ppt/day2.pptx new file mode 100644 index 0000000..4ece964 Binary files /dev/null and b/docs/teaching/ppt/day2.pptx differ diff --git a/docs/teaching/ppt/day3.pptx b/docs/teaching/ppt/day3.pptx new file mode 100644 index 0000000..5fb877d Binary files /dev/null and b/docs/teaching/ppt/day3.pptx differ diff --git a/docs/teaching/ppt/day4.pptx b/docs/teaching/ppt/day4.pptx new file mode 100644 index 0000000..bf7b0b9 Binary files /dev/null and b/docs/teaching/ppt/day4.pptx differ diff --git a/docs/teaching/ppt/day5.pptx b/docs/teaching/ppt/day5.pptx new file mode 100644 index 0000000..3cc93a1 Binary files /dev/null and b/docs/teaching/ppt/day5.pptx differ diff --git a/tools/teaching_ppt/day1.py b/tools/teaching_ppt/day1.py new file mode 100644 index 0000000..1cfcdc8 --- /dev/null +++ b/tools/teaching_ppt/day1.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +"""Day1 · 开训 + C++ 回顾与提升(全天 C++)—— 页面对应 slides/day1.html。""" + +SLIDES = [ +{ + 'kind': 'title', + 'badge': 'C++/Qt 方向实训 · 第 1 / 4 个讲授日 · 对齐 2026-07 新版 10 天安排', + 'title': 'Day 1 · 开训 + C++ 回顾与提升', + 'subtitle': '全天 C++:技术版图 / 环境与 CMake / OOP / 继承多态 / 内存管理 / STL', + 'bullets': [ + '上午 08:30–11:00 讲授:开训 · C++ 技术版图 · 环境与 CMake · PRETEST 摸底 · OOP 回顾', + '上午 11:00–12:00 课堂练习:独立完成 Part1 构建(144 目标全绿)', + '下午 13:30–16:00 讲授:继承与多态 · 内存管理与所有权 · STL · 现代 C++ 巡礼', + '下午 16:00–17:00 作业:ERRATA 修坑讲解 + 版本探针(选做)', + ], + 'source': '大纲:docs/teaching/OUTLINE_4DAY.md · 摸底卷:docs/teaching/PRETEST.md', + 'minutes': 3, 'segment': '开场', + 'notes': """【定位】今天全天 C++,是"回顾和提升",不是零基础教学。开场就把这话挑明:基础好的别嫌无聊,基础弱的别慌,今天讲的是"学过但容易讲不透"的那些点。 +【时长骨架】上午:开训典礼约 40 分钟(组织方)→ 技术版图讲座 25 → 环境与 CMake 25 → 编译vs链接 10 → PRETEST 摸底 10 → OOP 回顾 25,留 15 分钟机动。下午:讲授 6 页约 90 分钟,穿插 3 次 15 分钟随堂练,再留 15 分钟机动。 +【讲法】本页只念四行安排,强调两点:每半天都有动手环节;今天的产出(构建全绿)是后面四天所有演示的前提。""" +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': 'C++ 常用技术和框架(讲座)', + 'boxes': [ + ('concept', '核心概念——C++ 的四大应用版图', [ + '桌面客户端:Qt(本次实训主线)、跨平台 GUI', + '服务端/基础设施:高性能网络服务、数据库引擎、游戏服务器', + '嵌入式/车载:资源受限环境的第一梯队语言', + '游戏/图形:UE 引擎、渲染管线', + ]), + ('info', '而且它还在进化', [ + 'C++11 起三年一版:11「几乎新语言」→ 17 实用大件 → 20 四大件', + '自学入口:docs/teaching/MODERN_CPP.md · MODERN_CPP_20PLUS.md(今天下午末尾回收)', + ]), + ], + 'minutes': 25, 'segment': '上午 · 讲座(开训典礼后)', + 'notes': """【开场】开训典礼刚结束,注意力还散着。用一个提问把它接回来:"你们手机和电脑里,哪些软件是 C++ 写的?"微信桌面版、Chrome 内核、各种游戏引擎,基本都躲不开。 +【讲法】四大版图每个讲 4–5 分钟:举一个具体产品,再说清楚这个领域为什么离不开 C++(性能可控、贴近硬件、生态成熟)。讲到桌面版图时落到 Qt,预告一句:"明天开始四天全是它。" +【第二个盒子】只花 3 分钟。告诉学员 C++ 不是老古董,三年一版在进化;两份仓库内文档是自学材料,下午末尾还会回来点一次,现在不展开。 +【过渡】"要用这门语言干活,先得把工程环境立起来。下一页,构建工具。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': 'CMake:用本仓库当教材', + 'code': 'cmake -S . -B build -G Ninja -DBUILD_QT_PART=OFF -DCMAKE_BUILD_TYPE=Debug\ncmake --build build # 144 个目标全绿', + 'boxes': [ + ('concept', None, [ + 'CMake 是「生成构建系统的工具」不是构建系统本身:-S -B 生成 Ninja/Makefile 工程,--build 才是真正编译', + 'Makefile 是 CMake 的生成目标之一——这就是两者的关系', + 'option(BUILD_QT_PART) 控制是否编 Qt 部分;每个示例一个 add_executable 目标', + ]), + ('pitfall', None, [ + '换生成器/清缓存后要重新走一次 -S -B(大多数普通改动 CMake 会自动侦测)', + ]), + ('info', '包管理器一分钟', [ + 'vcpkg:装库 + toolchain 文件 → find_package 自动定位;本仓库选「隔离 Qt 手动装」——两种思路都要认识', + ]), + ], + 'source': '出处:仓库根 CMakeLists.txt', + 'minutes': 25, 'segment': '上午 · 环境与构建', + 'notes': """【讲法】本页是演示页,不是念 PPT:切到终端,真敲上面两条命令(预先建过一次 build,第二次跑很快),学员就能看到 144 个目标滚屏全绿。 +【关键区分】"生成"与"构建"两阶段用装修类比:CMakeLists 是图纸,-S -B 是出施工方案(Ninja 文件),--build 才是开工。学员以后 90% 的 CMake 困惑,都出在分不清这两步。 +【Makefile 的处理】新安排点名了 Makefile,这里用一句话安放它:打开 build/ 目录看生成产物,告诉学员"你们以前手写的 Makefile,在 CMake 工作流里是被生成出来的",不单独展开手写语法。 +【包管理器】最后 2 分钟带过 vcpkg 概念即可。本仓库用隔离安装,明天装 Qt 时会亲眼看到。 +【过渡】"构建会失败,失败分两种。下一页教你一眼分辨。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '★ 编译错误 vs 链接错误:排查方向完全不同', + 'code': "main.cpp:12:5: error: 'foo' was not declared <- 编译期:带文件行号\nmain.o: undefined reference to `Widget::bar()' <- 链接期:没有行号", + 'boxes': [ + ('concept', None, [ + 'error: 带行号 = 编译期(语法/类型/没声明)——回代码查那一行', + 'undefined reference 无行号 = 链接期(有声明没定义/缺库/缺目标文件)——查「定义在哪、链没链进来」', + ]), + ('pitfall', None, [ + '见到 undefined reference 就回头查语法拼写——方向错了', + '伏笔:明天 Qt 忘写 Q_OBJECT 报的正是链接错误,且报错完全不提宏', + ]), + ], + 'minutes': 10, 'segment': '上午 · 环境与构建', + 'notes': """【讲法】把上面两行报错投在屏幕上,让学员找不同:一个有 main.cpp:12:5,一个只有 main.o。就用"有没有行号"这个最粗暴的判据,帮他们建立第一反应。 +【为什么现在讲】这不是语法点,是排错元技能。今天下午写练习、明天起写 Qt,报错会立刻大量出现,先给分类器再给知识。 +【伏笔要点破】明确预告:明天会有一个"忘写宏导致链接错误"的经典坑,报错信息完全不提宏。到时候谁先想起今天这页,谁先解决。 +【过渡】"讲了半天,你们的底子到底怎么样?测一下,10 分钟。" """ +}, +{ + 'badge': '上午 · 摸底 10 分钟', + 'title': 'PRETEST:10 题摸底(闭卷、独立完成)', + 'bullets': [ + '双平行卷 A/B 交错发放,防抄袭;语言基线 C++98,只测经典功底', + '三级梯度:L1 基础语法(1–3 题)/ L2 OOP 与内存(4–7 题)/ L3 多态细节(8–10 题)', + ], + 'boxes': [ + ('info', '测完怎么用', [ + 'L1 大面积错 → 回退预案(放慢 OOP 回顾、下午 STL 让位基础加固)', + 'L2 部分错 → 对应知识点讲授时加时;L3 全对 → 全速推进', + ]), + ], + 'source': '卷面与分层预案:docs/teaching/PRETEST.md', + 'minutes': 10, 'segment': '上午 · 摸底', + 'notes': """【操作】A/B 卷按座位交错发。规则宣布清楚:闭卷、不查资料、10 分钟,答不出就空着。这是给教学定速用的,不计成绩(这句必须说,否则有人抄有人慌)。 +【计时】投屏倒计时 10 分钟。期间不讲课,巡场看答题情况,L1 三道题扫一眼就能看出班级底色。 +【收卷后】当场只判 L1(30 秒能判完一摞)。L1 通过率高就按原速走;低于一半,启用 PRETEST 里的回退预案:下午 STL 压缩为 10 分钟点名,把时间还给继承与多态。L2/L3 课后判,用于明天微调。 +【过渡】"卷子里的问题,接下来两个小时会逐个讲透。先从最基本的:类和封装。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': 'OOP 回顾:类 / 对象 / 封装 + namespace', + 'boxes': [ + ('concept', None, [ + '封装 = 数据和操作绑定 + 访问控制(public/private 是给「人」划的边界,不是安全机制)', + '构造/析构对应对象生命周期的两端——这个模型下午讲「所有权」还要用', + 'namespace 的「作用域」不是「文件顺序」:可跨文件延续、跨块合并', + ]), + ('pitfall', None, [ + '头文件里 using namespace std; 会把污染带给所有 include 它的文件——只在 .cpp 用,或全程写限定名', + ]), + ], + 'source': '出处:p01/ch04/(类与对象各节)· p01/ch03/namespace_syntax.cpp · 对照 docs/ERRATA.md', + 'minutes': 25, 'segment': '上午 · OOP 回顾(按 PRETEST 结果调速)', + 'notes': """【调速点】这一页的深度看刚才 L1 判卷结果:底子好,10 分钟把三个概念点过,多出的时间给学员提问;底子弱,打开 p01/ch04 的源文件逐个走读,讲满 25 分钟。 +【讲法】封装讲"边界":private 挡的是队友的手滑,不是黑客,工程含义大于语言含义。构造/析构用"出生登记/注销"类比,并预告:下午所有权、明天 Qt 父子树,全在这条线上。 +【namespace】重点讲 ERRATA 里的真实修正案例(wiki 原文把作用域理解成文件顺序)。打开 docs/ERRATA.md 对应条目,让学员看到"教材也会错,错误可以被记录和修正"。这也是下午作业的预演。 +【过渡】"上午最后一小时,轮到你们上手。任务卡在下一页。" """ +}, +{ + 'badge': '上午 · 课堂练习 11:00–12:00(工程师指导、答疑)', + 'title': '任务卡 1 · 走一遍真实构建流程', + 'boxes': [ + ('task', '任务卡 1', [ + '目标:独立完成本仓库 Part1 的配置+构建,不看着教师操作', + '验收:cmake -S . -B build -G Ninja -DBUILD_QT_PART=OFF 配置成功;cmake --build build 144 目标全绿', + '验收:能说出 option(BUILD_QT_PART) 的作用', + '参考:仓库根 CMakeLists.txt · CMake 官方 Tutorial(选读·需联网)', + ]), + ], + 'minutes': 60, 'segment': '上午 · 课堂练习', + 'notes': """【组织】学员独立操作,教师和助教巡场。预期 20 分钟内多数人完成两条命令;剩下时间处理个体环境问题(权限、Ninja 没装、路径带中文空格等,这些"环境坑"本身就是教学素材,遇到就广播讲)。 +【常见卡点】①在错误目录下执行(顺势回收上午"工作目录"意识,Day4 还会正式讲);②build 目录残留旧缓存报错,正好演示"清缓存要重新 -S -B"。 +【提前完成者】引导做两件事:给 BUILD_QT_PART=ON 试一次(会因没装 Qt 停在配置期,预告明天装);或提前看 docs/ERRATA.md 挑下午作业要讲的条目。 +【收尾 5 分钟】随机抽两人口头回答 option(BUILD_QT_PART) 的作用,达标才算过验收。""" +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '下午总览:继承多态 / 内存管理 / STL', + 'bullets': [ + 'C++ 核心特性:函数重载决议 · 虚函数与虚析构', + '内存管理:引用的本质 · ★所有权——谁负责 delete', + 'STL 常用容器 + 迭代器', + '收尾:现代 C++ 巡礼(兴趣钩子)', + ], + 'minutes': 3, 'segment': '下午 · 开场', + 'notes': """【节奏】下午 6 个知识点约 90 分钟讲授,每讲完 2 个插一次 10–15 分钟随堂练(写几行代码验证刚讲的行为),最后留 15 分钟机动。 +【主线】向学员挑明下午的暗线:所有内容都在回答"对象什么时候生、什么时候死、死的时候发生什么"。这条线明天会直接接到 Qt 的对象树上。""" +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '函数重载决议', + 'boxes': [ + ('concept', None, [ + '编译器靠名字改写(name mangling)把重载变成不同符号', + '二义性往往不是写错,而是候选集中有两个「一样好」的候选', + '重载(编译期·同类内)vs 重写(运行期·继承体系)是两码事', + ]), + ('pitfall', None, [ + '隐式类型转换会扩大候选集——传 long 可能触发意料之外的重载版本', + ]), + ], + 'source': '出处:p01/ch03/function_overload_mechanism.cpp', + 'minutes': 15, 'segment': '下午 · 继承与多态', + 'notes': """【引入】把 PRETEST 第 4 题(h(long)/h(double) 传 5)投出来重做一遍。这题当堂错误率通常最高,正好当引子。 +【讲法】用 nm 或编译器输出展示一次名字改写后的符号(_Z1hl 之类),学员对"重载在符号层怎么实现"会豁然开朗,顺带把上午"链接错误查符号"的意识再钉一次。 +【随堂练点】讲完本页加下一页后插第一次随堂练(15 分钟):让学员亲手制造一个二义性调用并读懂报错。 +【过渡】"重载是编译期挑函数;运行期挑函数就是多态。它有个最容易漏的角落:析构。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '虚函数与虚析构', + 'code': 'Base* p = new Derived();\ndelete p; // 如果 ~Base() 不是 virtual,~Derived() 不会被调用——经典泄漏', + 'boxes': [ + ('concept', None, [ + '经基类指针 delete 派生对象时,只有基类析构是 virtual 才会沿虚表调到派生类析构', + '这就是「多态」在析构场景下的具体体现', + ]), + ('pitfall', None, [ + '类可能被继承 + 可能经基类指针删除 ⇒ 析构就该 virtual(防御性约定)', + '明天会看到 Qt 的 QObject 体系全线虚析构', + ]), + ], + 'source': '出处:p01/ch04/s08/virtual_dtor_purpose.cpp', + 'minutes': 18, 'segment': '下午 · 继承与多态', + 'notes': """【讲法】当堂跑 p01/ch04/s08/virtual_dtor_purpose.cpp(tools/run.sh),先注释掉 virtual 看派生类析构不打印,再加回去看两级析构依次打印。"看见泄漏"比讲十分钟原理有效。 +【原理层】底子好的班补讲虚表指针:对象头部藏着 vptr,delete 时经 vptr 找到"真正的类型"的析构。底子弱的班跳过 vptr,只记防御性约定。 +【互动】提问:"STL 容器(vector)为什么没有虚析构却没事?"因为没人拿基类指针删它,规则是条件式的,不是"所有类都要 virtual"。 +【随堂练】此处插第一次随堂练 15 分钟:改 virtual_dtor_purpose.cpp 自己复现一次泄漏与修复。""" +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '引用的本质', + 'boxes': [ + ('concept', None, [ + '把引用理解成「自动解引用的 const 指针」——能解释为什么必须初始化、不能重新绑定', + ]), + ('pitfall', None, [ + '返回局部变量的引用:函数结束即悬空,访问是未定义行为——和返回局部指针同一类坑', + ]), + ], + 'source': '出处:p01/ch03/reference_essence.cpp', + 'minutes': 12, 'segment': '下午 · 内存管理', + 'notes': """【引入】PRETEST 第 1 题(引用能否绑 nullptr)回收:用"const 指针"视角一句话解释所有选项。 +【讲法】画两张内存图:指针是"另一个格子存地址",引用是"同一个格子的别名"。但底层实现常常就是指针,语言只是不让你碰它。 +【悬空引用】强调它是"时间维度"的错误:代码每一行单看都对,错在对象死了引用还活着。这正是下页"所有权"和 Day2 lambda 悬空坑的同一个病根,讲到那里会反复回指这页。 +【过渡】"对象死了还被人用。那到底该谁管对象的死活?下一页,全天最重要的一页。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '★ 所有权:谁负责 delete?', + 'boxes': [ + ('concept', None, [ + '内存管理的核心问题不是「会不会 new」,而是「这块资源归谁管、由谁释放」', + '现代答案:RAII / 智能指针——unique_ptr 独占、shared_ptr 共享(MODERN_CPP.md §2)', + '一个对象只能有一个所有者', + ]), + ('pitfall', None, [ + '双重释放 / 忘记释放 / 释放后使用——三兄弟全部源于所有权不明', + '明天的伏笔:Qt 用「父子树」回答同一个问题,两套所有权体系不能混用', + ]), + ], + 'minutes': 18, 'segment': '下午 · 内存管理', + 'notes': """【地位】如果今天只能让学员带走一页,就是这页。所有权心智模型是"学过 C++ 语法"和"写得出不崩的程序"之间最大的那道坎,Day2 的三个崩溃坑全是它的变体。 +【讲法】先讲问题再讲方案:举一个函数返回 new 出来的指针的例子,问"谁该 delete?调用方?被调方?文档里写了吗?"让学员体会到,"所有权不明"本身就是 bug 温床。然后给出 unique_ptr(独占,move 转移)和 shared_ptr(引用计数),作为把所有权写进类型的方案。 +【尺度】智能指针只讲到"是什么、解决什么",用法细节指向 MODERN_CPP.md §2 自学。因为明天起 Qt 的世界用父子树而不是智能指针,别让学员在 Qt 代码里到处套 shared_ptr(这是真实的常见误用)。 +【伏笔】用加重语气念出"两套所有权体系不能混用",预告明天上午有一个现场崩溃演示。 +【随堂练】此处插第二次随堂练 15 分钟:给一段双重释放的代码,让学员用"所有权归谁"的语言说出错因。""" +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '模板与 STL 常用容器', + 'boxes': [ + ('concept', None, [ + '泛型编程:同一份代码适配不同类型;vector / map / string 是项目实作三件套', + '迭代器是「容器和算法之间的统一接口」——STL 算法能脱离具体容器复用的关键', + ]), + ('pitfall', None, [ + '容器扩容/删元素后旧迭代器可能失效(如 vector 搬家),继续用是未定义行为', + ]), + ], + 'source': '出处:p01/ch05/class_template_basics.cpp · p01/ch09/s03/vector_iterator.cpp', + 'minutes': 15, 'segment': '下午 · STL', + 'notes': """【调速点】PRETEST 弱班把本页压到 8 分钟,只讲 vector 加失效坑;强班讲满,并当堂演示一次 push_back 导致迭代器失效的未定义行为(可能崩也可能"正常",顺势又强化一次"UB 不一定崩")。 +【讲法】三件套各给一个"项目里会怎么用"的场景:vector 存记录列表(Day5 案例真的用)、map 做查找表、string 到处都是。迭代器讲成"统一的游标协议",range-for 是它的糖。 +【失效坑】口诀给到位:"遍历中增删,先查该容器的失效规则。"不要求背规则,要求知道有这回事、会去查。 +【随堂练】第三次随堂练 15 分钟:写 10 行代码,vector 边遍历边 erase,观察行为并修复(用 erase 返回值)。""" +}, +{ + 'badge': '下午 · 讲授 13:30–16:00 · 兴趣钩子', + 'title': 'C++11/14/17/20+:一门仍在进化的语言', + 'boxes': [ + ('concept', '每 3 年一次实打实的能力升级', [ + 'C++11:auto · 智能指针 · 移动语义 · lambda · 语言级并发——「现代 C++ 元年」', + 'C++14:泛型 lambda · 返回类型推导 · make_unique', + 'C++17:结构化绑定 · if-init · optional/variant/string_view · filesystem(本仓库基线)', + 'C++20:Concepts · Ranges · 协程 · 模块——「自 C++11 以来最大变革」', + ]), + ('pitfall', None, [ + '新标准 ≠ 现在能用:生产工具链常年落后 1–2 版;用 tools/std_probe.py 实测(宿主 gcc 13.3 到 C++23)', + ]), + ], + 'source': '自学:docs/teaching/MODERN_CPP.md(11/14/17,仓库基线可编译)· MODERN_CPP_20PLUS.md(20/23/26 + GCC 实测矩阵)', + 'minutes': 12, 'segment': '下午 · 收尾钩子', + 'notes': """【定位】这页是点火不是授课,目标是让学有余力的人课后打开那两份文档,不是当堂教会任何新特性。 +【讲法】每个版本挑一个"最有体感"的特性各 1–2 分钟:C++11 讲 lambda(明天信号槽就大量用,这个衔接要点破);C++17 讲结构化绑定的赏心悦目;C++20 讲 Concepts 把几百行模板报错变一句人话。 +【演示(可选)】时间富余就当堂跑一次 tools/std_probe.py,看同一段代码在 -std=c++98 和 c++17 下一个报错一个通过——"标准差异是可以实测的"。 +【过渡】"今天的 C++ 之旅到此,最后一小时:作业。明天早上 08:30,Qt。" """ +}, +{ + 'badge': '下午 · 课堂作业 16:00–17:00(工程师答疑)', + 'title': '作业:修一个真实的坑 + 版本探针(选做)', + 'boxes': [ + ('task', '任务卡 2 · 修一个真实的坑', [ + '目标:从 docs/ERRATA.md 挑 2 条与今天知识点相关的记录(虚析构/引用/重载类)', + '验收:能用一句话向同组同学解释「wiki 原文错在哪、为什么」,不能只念代码', + ]), + ('task', '任务卡 3(选做)· 版本探针', [ + '目标:用 tools/std_probe.py 验证一个标准版本差异点', + '验收:跑出「该标准报错、另一标准通过」的对比,对照 docs/VERSION_NOTES.md §2', + ]), + ], + 'minutes': 60, 'segment': '下午 · 作业', + 'notes': """【组织】作业开做前花 3 分钟带学员浏览一遍 ERRATA.md 的结构(每条:原文错误→修正→原因),示范挑一条"虚析构类"的怎么讲,给出"一句话解释"的示范标准。 +【巡场重点】拦住"念代码"式的讲解:追问"如果不改会发生什么?"逼出因果表述。这个表达训练到 Day10 答辩就能用上。 +【选做引导】完成快的学员推 std_probe:跑通一个对比后,引导他们把结论和 VERSION_NOTES.md 印证。 +【收尾】17:00 前每组出 1 人讲 1 条 ERRATA(每人 1 分钟内),当日验收随堂完成。""" +}, +{ + 'badge': '当日收尾', + 'title': '当日产出验证', + 'code': 'cmake --build build --target all # Part1,144 个目标全绿', + 'bullets': [ + '每人:构建全绿 + 2 条 ERRATA 坑点讲解(口头验收)', + 'PRETEST 结果归档,决定后续调速', + '明天 Day2:Qt 概述 / 创建项目 / 第一个程序 / 信号槽 → 计算器', + ], + 'minutes': 5, 'segment': '收尾', + 'notes': """【操作】收尾 5 分钟:口头快速过验收清单;预告明天需要装隔离 Qt(tools/install_qt_host.sh),机器慢的可以现在挂着下载。 +【衔接】用一句话给今天收线:"今天所有内容都在讲对象的生与死。明天你们会看到,Qt 用一棵树管理成千上万个对象的生死。"留下钩子散场。""" +}, +] diff --git a/tools/teaching_ppt/day2.py b/tools/teaching_ppt/day2.py new file mode 100644 index 0000000..3812a08 --- /dev/null +++ b/tools/teaching_ppt/day2.py @@ -0,0 +1,259 @@ +# -*- coding: utf-8 -*- +"""Day2 · Qt 入门 / 信号槽 / 计算器案例 —— 页面对应 slides/day2.html。""" + +SLIDES = [ +{ + 'kind': 'title', + 'badge': 'C++/Qt 方向实训 · 第 2 / 4 个讲授日', + 'title': 'Day 2 · Qt 入门 / 信号槽 → 计算器', + 'subtitle': 'Qt 概述 / 创建项目 / 第一个程序 / 信号槽机制 / 案例:简易计算器', + 'bullets': [ + '上午 08:30–11:00 讲授:Qt 概述 · 创建 Qt 项目 · 第一个 Qt 小程序', + '上午 11:00–12:00 课堂练习:跑通并改造最小程序(中文标题埋点)', + '下午 13:30–16:00 讲授:信号槽三种写法 + 三张★坑卡 → 计算器组装', + '下午 16:00–17:00 作业:完成简易计算器', + ], + 'source': '开始前一次性准备:tools/install_qt_host.sh && cmake -DBUILD_QT_PART=ON -DCMAKE_PREFIX_PATH=$PWD/.qt514/5.14.2/gcc_64', + 'minutes': 5, 'segment': '开场', + 'notes': """【开场】开课先集体执行"开始前准备"(装隔离 Qt 加重新配置构建)。装的等待时间正好用来讲第一页 Qt 概述,安排好并行,别干等。 +【时长骨架】上午:概述 20 → 创建项目 25 → 第一个程序 25 → 对象树 15 → 栈坑演示 15,共 100 分钟讲授加随堂跟敲,余量约 30 分钟给装环境收尾和答疑。下午:信号槽 35 → lambda 坑 15 → 重复 connect 12 → 编码 12 → 计算器组装讲解 25 + 当堂开工 30。 +【主线】今天的暗线是昨天的所有权:对象树就是 Qt 的所有权答案;三个★坑全是"生命周期"的变体。开场就把这条线挑明。""" +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': 'Qt 概述', + 'boxes': [ + ('concept', None, [ + 'Qt = 跨平台 C++ 应用框架:一份代码,Windows/Linux/macOS 原生编译', + '模块化:本课程用 Core(对象/容器/IO)· Gui · Widgets(桌面控件)', + '版本纪律:统一用隔离安装的 Qt 5.14.2(.qt514/),不碰系统 Qt', + ]), + ('info', '工程示范', [ + '顶层 CMakeLists 有版本断言,连错版本直接 FATAL_ERROR——「锁依赖版本」的真实做法', + ]), + ], + 'source': '出处:仓库根 CMakeLists.txt(版本断言一节)', + 'minutes': 20, 'segment': '上午 · 概述(可与装 Qt 并行)', + 'notes': """【讲法】"跨平台"用学员熟悉的软件举证(WPS、VirtualBox 界面都是 Qt);模块化用"按需取件"讲:Widgets 底下垫着 Gui,Gui 底下垫着 Core,链接哪些模块 CMake 里看得见。 +【版本纪律】这里讲的其实是工程课不是 Qt 课:为什么全班必须同一个 Qt 版本(示例行为一致、报错可复现),为什么用断言硬拦(出错要趁早,在配置期不在运行期)。打开顶层 CMakeLists 的断言给学员看一眼。 +【互动】问"谁的机器已经装完了?"用最快的一台跑 cmake 配置,看 Qt 5.14.2 被正确找到的输出长什么样,对照"找错版本会 FATAL_ERROR"。 +【过渡】"环境有了,怎么从零建一个 Qt 项目?下一页拆给你看。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '创建 Qt 项目(CMake 方式)', + 'code': 'find_package(Qt5 5.14 REQUIRED COMPONENTS Core Gui Widgets)\nadd_executable(minimal_qt_app minimal_qt_app.cpp)\nset_target_properties(minimal_qt_app PROPERTIES AUTOMOC ON)\ntarget_link_libraries(minimal_qt_app PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)', + 'boxes': [ + ('concept', None, [ + '昨天的 find_package 今天落地:按 CMAKE_PREFIX_PATH 找到 Qt', + 'AUTOMOC 自动跑 moc(元对象编译器)——为 Q_OBJECT 类生成信号槽代码,下一页见', + ]), + ], + 'source': '出处:p03/CMakeLists.txt · p03/ch02/CMakeLists.txt', + 'minutes': 25, 'segment': '上午 · 创建项目', + 'notes': """【讲法】四行 CMake 逐行讲:find_package 找什么(头文件+库+版本)、REQUIRED 意味着找不到就停、AUTOMOC 是给下页的 moc 留的钩子、target_link_libraries 对应模块化那页的"按需取件"。 +【moc 预热】这里先给 moc 一句话定义:"Qt 在编译前多跑的一个代码生成器",具体生成什么下一页配合 Q_OBJECT 讲。两页共用一个概念,分两次砸实。 +【随堂跟敲】让学员照着 p03/ch02/CMakeLists.txt 找到自己 build 目录里对应的 moc 产物文件(*_autogen),亲眼确认"moc 真的生成了代码"。这也是今晚钩子(读 moc_*.cpp)的铺垫。 +【过渡】"工程能编了,写第一个窗口。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '第一个 Qt 小程序', + 'boxes': [ + ('concept', None, [ + 'QApplication 是唯一的事件循环拥有者,app.exec() 阻塞进入事件循环分发系统事件', + 'Q_OBJECT 宏触发 moc 生成信号槽/属性系统所需的元对象代码', + ]), + ('pitfall', None, [ + '一个进程只能有一个 QApplication;忘 exec() 窗口一闪而过', + '用了信号槽却忘 Q_OBJECT:undefined reference——链接错误(昨天那页!),报错完全不提宏', + ]), + ], + 'source': '出处:p03/ch02/minimal_qt_app.cpp', + 'minutes': 25, 'segment': '上午 · 第一个程序', + 'notes': """【讲法】当堂跑 minimal_qt_app,然后做两个"破坏实验":①注释掉 exec() 重新跑,窗口一闪而过,讲清 exec 前程序是"直播结束"状态;②讲事件循环用"前台服务员"类比:exec 就是服务员站上岗位开始接单(鼠标、键盘、重绘都是单子)。 +【Q_OBJECT 坑】这是今天最有价值的一个坑:口头承诺学员"你们两周内一定会撞上它"。撞上时的症状:undefined reference to vtable / 信号槽相关符号。回忆昨天"链接错误查定义",而这里的"定义"在 moc 生成的文件里,宏没写 moc 就不生成。修复:加宏 + 重新构建。 +【互动】问:"报错里一个字没提 Q_OBJECT,你怎么定位到它?"建立"链接错误 + Qt 类 ⇒ 先查宏"的条件反射。 +【过渡】"窗口有了,往里放控件。控件归谁管?昨天下午的问题来了。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '控件创建与对象树', + 'boxes': [ + ('concept', None, [ + 'QObject 对象树 = Qt 的所有权答案(接昨天「谁负责 delete」)', + '创建控件把 parent 传进去,parent 析构自动 delete 所有子对象——整棵树只管好根', + ]), + ('pitfall', None, [ + '忘传 parent:对象没人管,真实内存泄漏', + 'move() 手动定位的控件一旦套上布局会被接管覆盖', + ]), + ], + 'source': '出处:p03/ch03/button_creation.cpp', + 'minutes': 15, 'segment': '上午 · 对象树', + 'notes': """【衔接】开头直接回指昨天:"昨天问'谁负责 delete',智能指针是一种答案,Qt 给了另一种:树。"两套答案解决同一个问题,这个对应关系点透。 +【讲法】画树:window → layout → buttons/labels;delete 根,全树倒序析构。强调工程收益:界面上百个控件,不写一个 delete。 +【规则】"交给树管的对象,不要再用智能指针/栈/手动 delete 管",一个对象只能有一个所有者。这句是下一页崩溃演示的谜底,先说规则再看崩溃。 +【过渡】"不信邪的写法长什么样?下一页现场崩一次。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00 · ★坑点复现', + 'title': '★ 栈对象与父子所有权打架', + 'code': 'QPushButton btn("我建在栈上"); // 先声明 -> 后析构\nQWidget window; // 后声明 -> 先析构\nbtn.setParent(&window);\n// 离开作用域:window 先析构 -> 父子树 delete &btn(栈地址!)-> 崩溃', + 'boxes': [ + ('pitfall', None, [ + '声明顺序反过来时「恰好不崩」——不要赌析构顺序的运气,规则只有一条:给了 parent 就必须在堆上', + ]), + ], + 'source': '复现示例:examples/pitfall_stack_widget/(README 有对照讲解)· 演示:tools/run_qt.sh qt_pitfall_stack_widget --crash', + 'minutes': 15, 'segment': '上午 · 栈坑演示', + 'notes': """【演示流程】①先跑默认版(正确写法):窗口正常;②跑 --crash 版:终端打出 free(): invalid pointer 后 abort,让全班看真实崩溃现场;③回到代码逐行讲析构顺序:谁后声明谁先析构,window 析构时树机制 delete 了一个栈地址。 +【关键升华】把两行声明对调再跑(README 有说明):不崩了,但它仍然是错的。这里讲"UB 不等于必然崩溃":恰好不崩是最危险的状态,因为它能潜伏到交付。这个观念下午 lambda 坑还要用一次。 +【规则收束】黑板写下今天第一条军规:"给了 parent 的控件必须 new。"(Day3 讲对话框时这条军规会第三次出现。) +【过渡】"上午最后一小时自己动手,顺便埋一个下午要用的雷(中文标题)。" """ +}, +{ + 'badge': '上午 · 课堂练习 11:00–12:00(工程师指导、答疑)', + 'title': '任务卡 1 · 跑通并改造最小程序', + 'boxes': [ + ('task', '任务卡 1', [ + '目标:编译运行 minimal_qt_app 与 button_creation;把窗口标题和按钮文字改成自己的学号+姓名(中文)', + '验收:QT_QPA_PLATFORM=offscreen tools/run_qt.sh minimal_qt_app 退出码 0', + '验收:有 X 环境下自己改的中文标题正常显示', + '参考:p03/ch02/minimal_qt_app.cpp · p03/ch03/button_creation.cpp', + ]), + ], + 'minutes': 60, 'segment': '上午 · 课堂练习', + 'notes': """【组织】前 20 分钟目标:全员把两个示例跑起来(上午刚装的环境在这里最终验收)。后 30 分钟做中文改造。 +【埋点说明(教师知悉,别提前剧透)】中文改造是给下午"QString 编码"坑卡埋的现场素材:部分学员会遇到乱码(源码编码或隐式转换),先只记录现象、告知"下午揭晓",不当场展开。带着问题听课效果最好。 +【常见卡点】CMAKE_PREFIX_PATH 没传导致 find_package 失败(回收上午创建项目页);改完代码没重新构建就跑旧程序(讲"改了要重编"的习惯)。 +【收尾】统计乱码人数,下午编码页开场直接用这个数字。""" +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '信号槽:内置 / 自定义 / lambda', + 'boxes': [ + ('concept', None, [ + '观察者模式的 Qt 实现:发送者只 emit,不知道谁在听——对象间彻底解耦', + '新语法 &Class::signal 编译期检查;旧字符串语法 SIGNAL()/SLOT() 运行时检查', + '连接方式 Direct/Queued/Auto(Day5 跨线程时回收闭环)', + ]), + ('pitfall', None, [ + '旧语法写错不报编译错误——运行时静默连接失败', + '重载信号不加 QOverload 消歧义直接编译报错', + ]), + ], + 'source': '出处:p03/ch04/builtin_signal_slot.cpp · custom_signal_slot.cpp · lambda_signal_slot.cpp', + 'minutes': 35, 'segment': '下午 · 信号槽', + 'notes': """【地位】信号槽是全部 Qt 课程的第一核心机制,本页 35 分钟是全天最大块,按"三个示例各 10 分钟 + 5 分钟总结"推进,每个示例都当堂跑。 +【讲法】①builtin:按钮 clicked 连内置槽,讲"信号=事件的语义化出口";②custom:自定义信号加 emit,讲"你的类也能发广播",顺便回收上午 Q_OBJECT/moc(信号的实现就在 moc 文件里);③lambda:匿名函数当槽,回收昨天现代 C++ 巡礼的 lambda——"昨天的新特性今天就用上了"。 +【新旧语法】演示一次旧语法故意拼错:编译通过、运行时只有一行警告、功能静默失效。对比新语法同样错误直接编译失败。结论:一律用新语法。 +【互动】"信号槽和直接调函数有什么区别?"解耦:发送者不 include 接收者,这是 Day5 案例架构分层的伏笔。 +【过渡】"lambda 好用,但它有一个能潜伏到上线的坑。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00 · ★坑点复现', + 'title': '★ lambda 槽的生命周期与第五参数', + 'code': "connect(sender, &Sender::sig, [obj]{ obj->f(); }); // X obj 销毁后仍触发\nconnect(sender, &Sender::sig, obj, [obj]{ obj->f(); }); // O 第五参数=context", + 'boxes': [ + ('concept', None, [ + '第五参数(context object)决定连接跟谁的生命周期挂钩:对象析构自动断开', + '规则:lambda 捕获了某个 QObject,就把它作为第五参数传进去', + ]), + ('pitfall', None, [ + '不传 context:接收方销毁后 lambda 仍被调用——悬空指针,可能崩也可能「看起来正常」', + ]), + ], + 'source': '复现示例:examples/pitfall_lambda_capture/ · 演示:tools/run_qt.sh qt_pitfall_lambda_capture(--dangle 看真实悬空)', + 'minutes': 15, 'segment': '下午 · 信号槽坑卡', + 'notes': """【演示流程】跑默认版看确定性对比输出:delete 接收者后,无 context 的连接仍触发、带 context 的已自动断开,两条连接的差异一目了然。再跑 --dangle:真实悬空访问,本机实测打印出了"看似正常的旧值",正好接上午的话:"UB 恰好正常是最危险的状态",第二次强化。 +【规则】只给一条可执行军规(黑板第二条):"lambda 捕获了谁,第五参数就传谁。"不要展开讲连接对象的内部实现,中初级听众记规则比记原理牢。 +【回指】这就是昨天"悬空引用"的 Qt 版:对象死了,别人手里还留着它的地址。同一个病根第三次出现,点破这个重复是刻意设计。 +【过渡】"还有一个坑,症状更迷惑:功能是对的,就是每次都来两遍。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00 · ★坑点复现', + 'title': '★ 重复 connect:点一次按钮弹两次框', + 'boxes': [ + ('concept', None, [ + 'connect 每调一次新增一条连接,Qt 不去重——连两次,槽被调两次', + '修复①connect 收拢到构造函数只做一次(首选);②Qt::UniqueConnection(仅成员函数指针,lambda 防不了)', + ]), + ('pitfall', None, [ + 'connect 写在会被多次执行的路径上(每次打开界面都跑的「初始化」函数)——第一反应查按钮,根源在连接次数', + ]), + ], + 'source': '复现示例:examples/pitfall_double_connect/ · 演示:tools/run_qt.sh qt_pitfall_double_connect', + 'minutes': 12, 'segment': '下午 · 信号槽坑卡', + 'notes': """【演示】跑复现示例:输出清楚展示"重复 connect 后一次 emit 槽调 2 次 → UniqueConnection 后只调 1 次"。确定性输出,不用赌运气。 +【讲法】重点讲"事故现场的形态":没人会故意连两次,都是 connect 藏在 setup()/init() 这类会被反复调用的函数里。所以首选修复是结构性的,connect 全部收进构造函数(今晚计算器作业的验收标准里就有这条)。 +【UniqueConnection 的边界】强调它只认成员函数指针:lambda 没有身份,防不了。想用 UniqueConnection 就别用 lambda 槽,二选一说清楚。 +【过渡】"最后一个坑不崩也不重复,它只是让你的界面变成'????'。上午改中文乱码的同学,谜底来了。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00 · ★坑卡', + 'title': '★ QString 与中文编码', + 'boxes': [ + ('concept', None, [ + 'QString 内部是 UTF-16,和 std::string(字节串)是两种东西', + '互转必须指明编码:toUtf8() / fromUtf8();中文字面量用 QStringLiteral + 源码存 UTF-8', + ]), + ('pitfall', None, [ + '源码不是 UTF-8、或用 QString(char*) 隐式转换中文字面量 -> 界面乱码', + 'Day4 读写文件时编码问题还会再来一次(同一根源)', + ]), + ], + 'minutes': 12, 'segment': '下午 · 编码坑卡', + 'notes': """【开场】直接报上午统计的乱码人数,请一位中招学员描述现象,用真实案例开讲比抽象讲编码有效得多。 +【讲法】画"两个世界"图:字节世界(std::string/文件/网络)和文字世界(QString/界面),中间隔一道墙,过墙必须声明编码。乱码的本质:过墙时没声明,系统猜错了。 +【可执行规则】三条:源码文件一律存 UTF-8;中文字面量写 QStringLiteral("…");和 std::string 互转显式 fromUtf8/toUtf8。让中招学员当堂改好自己上午的代码作为验证。 +【预告】Day4 文件读写会遇到同一堵墙的另一个门(QTextStream 默认编码随平台变),到时回收。 +【过渡】"四个坑讲完,弹药齐了。开始组装今天的正菜:计算器。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00 · 案例', + 'title': '案例 · 简易计算器(布局脚手架:先用后学)', + 'code': "auto* grid = new QGridLayout(window); // 布局原理明天下午讲透\ngrid->addWidget(btn7, 0, 0); grid->addWidget(btn8, 0, 1); // 今天先会用:\ngrid->addWidget(btn9, 0, 2); grid->addWidget(btnDiv, 0, 3); // 行、列坐标摆键盘", + 'boxes': [ + ('concept', '组装思路', [ + '按钮(上午)+ 点击信号槽(刚学)+ QGridLayout 脚手架排键盘 + QLabel/QLineEdit 显示结果', + '项目 = 已学知识点的组装——Day5 综合案例和 Day6–10 项目的预演', + ]), + ], + 'minutes': 25, 'segment': '下午 · 计算器组装讲解', + 'notes': """【讲法】自顶向下带一遍设计再放手:①界面拆解(显示区加 4×4 键盘);②交互拆解(数字键追加、运算键记住操作数和运算符、等号算);③把每块映射到今天学的知识点。白板画完设计图再看脚手架代码。 +【脚手架声明】明确"先用后学"的边界:今天只需要会 addWidget(w, row, col) 这一个动作。为什么它能自适应、还有哪些布局,明天下午专门讲,防止有人今天就钻布局细节出不来。 +【实现提示】给一个最省事的状态机设计(当前值/暂存值/待执行运算符三个成员变量),但不给完整代码,作业要自己写。演示教师版成品跑一次,明确"长这样就算达标"。 +【剩余时间】讲解 25 分钟后,剩下约 30 分钟当堂开工,作业时段继续。""" +}, +{ + 'badge': '下午 · 课堂作业 16:00–17:00(工程师答疑)', + 'title': '任务卡 2 · 简易计算器', + 'boxes': [ + ('task', '任务卡 2', [ + '目标:组合按钮 + 信号槽 + QGridLayout 脚手架,做出能加减乘除的最小计算器', + '验收:两个数 + 一种运算符计算正确并显示;界面不重叠、按钮可点击', + '验收:connect 全部用新语法且写在构造函数里(今天的坑卡现学现用)', + '参考:p03/ch03/button_creation.cpp · p03/ch04/custom_signal_slot.cpp · p03/ch08/custom_widget.cpp(布局参考)', + ]), + ], + 'minutes': 60, 'segment': '下午 · 作业', + 'notes': """【组织】独立完成,允许组内讨论思路但代码各写各的。教师端备好参考实现(不发放)。 +【巡场检查点】对照三张坑卡验收:connect 是否都在构造函数(防重复连接)、有没有栈上建控件、中文按钮文字是否正常。坑卡内容当天就进验收,记忆效果最好。 +【分层】快的学员加"连续运算"或"清除键 C";慢的学员先保证"两个数一个运算符"最小闭环,键盘可以只做 0–9 和 + =。 +【预告】明天计算器不重写,直接在今天的代码上套 QMainWindow、加菜单、上 QSS。今天的代码质量决定明天的起点,散场前提醒提交保存。""" +}, +{ + 'badge': '当日收尾', + 'title': '当日产出验证', + 'bullets': [ + '现场演示:计算器完成至少一次正确运算;中文界面无乱码', + '钩子:打开 build/ 里真实的 moc_*.cpp,看 moc 生成了什么代码', + '明天 Day3:QMainWindow / QDialog / 布局讲透 / 常用控件 / QSS——计算器升级成「正经软件」', + ], + 'minutes': 5, 'segment': '收尾', + 'notes': """【验收】抽 2–3 人现场演示计算器运算;全班自查中文无乱码。 +【钩子讲法】当堂打开一个 moc_*.cpp 滚一屏,不逐行讲,只说一句:"信号槽没有魔法,全在这份生成代码里,感兴趣的回去读。"能看懂的学员会对元对象系统祛魅。 +【收线】"今天你们写出了第一个有交互的 Qt 程序;明天给它一个正经软件的骨架。"散场。""" +}, +] diff --git a/tools/teaching_ppt/day3.py b/tools/teaching_ppt/day3.py new file mode 100644 index 0000000..97a3ca9 --- /dev/null +++ b/tools/teaching_ppt/day3.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +"""Day3 · QMainWindow / QDialog / 布局与控件 / QSS —— 页面对应 slides/day3.html。""" + +SLIDES = [ +{ + 'kind': 'title', + 'badge': 'C++/Qt 方向实训 · 第 3 / 4 个讲授日', + 'title': 'Day 3 · QMainWindow / QDialog / 布局与控件', + 'subtitle': '主窗口骨架 / 对话框交互 / 布局讲透 / 常用控件 / QSS 美化', + 'bullets': [ + '上午 08:30–11:00 讲授:QMainWindow · 对话框 QDialog · 标准对话框', + '上午 11:00–12:00 课堂练习:计算器套上 QMainWindow(菜单 + 停靠窗)', + '下午 13:30–16:00 讲授:布局管理器(回收脚手架)· 常用控件与 .qrc · QSS', + '下午 16:00–17:00 作业:QSS 美化计算器 + 缩放自适应', + ], + 'minutes': 3, 'segment': '开场', + 'notes': """【主线】今天全天围绕一件事:把昨天的计算器升级成"正经软件"。上午给骨架(主窗口加对话框),下午管排版和外观。开场就展示教师版的"升级后计算器"成品,让学员看到今天结束时自己的程序长什么样,目标可视化。 +【时长骨架】上午:QMainWindow 30 → QDialog 模态非模态 30 → 标准对话框 15,讲授约 75 分钟,剩余给随堂跟敲和答疑。下午:布局 30 → 控件与 qrc 25 → QSS 25,随堂练穿插。 +【衔接】昨天的"堆上建控件"军规今天在对话框一节会第三次出现,留意把这条线讲成呼应而不是重复。""" +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': 'QMainWindow:标准桌面软件的骨架', + 'boxes': [ + ('concept', None, [ + 'QMainWindow 不是「大号 QWidget」:菜单栏/工具栏/状态栏/停靠窗各就其位的固定区域协议', + '中心部件必须 setCentralWidget() 显式安放', + '菜单项和工具栏按钮的统一抽象是 QAction——一个动作,多处复用', + ]), + ('pitfall', None, [ + '不设中心部件直接摆控件——布局行为不可预期', + 'QAction 显示出来 ≠ 生效:忘 connect 就是「菜单点了没反应」', + ]), + ], + 'source': '出处:p03/ch05/central_widget.cpp · dock_widget.cpp', + 'minutes': 30, 'segment': '上午 · QMainWindow', + 'notes': """【引入】投一张学员都认识的软件截图(VS Code / WPS),指认五个区域:菜单/工具/状态/中心/停靠。"QMainWindow 就是这套行业标准布局的现成实现。" +【讲法】跑 central_widget.cpp 与 dock_widget.cpp,边跑边对照代码:setCentralWidget、menuBar()->addMenu、addToolBar、statusBar()->showMessage 四个入口各演示一次。 +【QAction 重点】用"一个动作三个入口"讲:同一个 QAction 同时挂菜单和工具栏,禁用它两处一起变灰,这就是抽象的价值。坑卡里"菜单点了没反应"发生率极高,让学员现在就形成"建 action 后第一件事 connect"的顺序习惯。 +【互动】问:"中心部件只能有一个,那复杂界面怎么办?"答案是中心部件本身可以是任意组合控件(下午布局课解决),先埋问题。 +【过渡】"主窗口是常驻界面;临时冒出来问一句的界面叫对话框。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': 'QDialog:模态与非模态', + 'boxes': [ + ('concept', None, [ + '模态 exec():阻塞等结果,返回值就是用户的选择(Accepted/Rejected)', + '非模态 show():立即返回,窗口独立存活——对象必须活过当前函数作用域', + '非模态三种写法:堆 + parent / WA_DeleteOnClose 自清理 /(反例)栈', + ]), + ('pitfall', '常见的坑(所有权军规第三次出现!)', [ + '非模态对话框建在栈上:show() 完函数一返回就析构——窗口一闪而过', + 'p03/ch06 的三个 modeless 版本就是这个坑的对照教材', + ]), + ], + 'source': '出处:p03/ch06/modal_dialog.cpp · modeless_dialog_stack.cpp · _heap.cpp · _deleteonclose.cpp', + 'minutes': 30, 'segment': '上午 · QDialog', + 'notes': """【讲法】模态/非模态先用生活类比定性:模态=收银台付款(不办完不能干别的),非模态=旁边开着的计算器(随时切换)。然后讲代码差异的本质:exec() 内部自己转事件循环所以能阻塞,show() 只是显示。 +【三连跑对照】依次跑 ch06 三个 modeless 文件:栈版一闪而过(学员先猜原因,Day2 军规该有人能答上)、堆版正常但要考虑谁删、DeleteOnClose 版关窗自清理。同一功能三种生命周期策略并排,是全仓库最好的所有权教材。 +【收束】黑板军规更新:"给了 parent 的控件必须 new"加"非模态对话框必须活过函数"。本质是同一条:对象的生命周期必须覆盖它的使用周期。 +【过渡】"自己写对话框之前,先看看 Qt 送了哪些现成的。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '标准对话框速览', + 'code': 'auto ret = QMessageBox::question(this, "确认", "真的要删除吗?");\nif (ret == QMessageBox::Yes) { /* ... */ }\n\nQString path = QFileDialog::getOpenFileName(this, "选择文件", "", "*.json");\nif (!path.isEmpty()) { /* ... */ } // 用户可能点取消:必须处理空返回', + 'boxes': [ + ('pitfall', None, [ + '不处理「用户点了取消」(空路径/Rejected)就直接往下用——健壮性问题高发点', + ]), + ], + 'source': '出处:p03/ch06/message_box_question.cpp · message_box_custom.cpp · file_dialog.cpp', + 'minutes': 15, 'segment': '上午 · 标准对话框', + 'notes': """【讲法】两个静态函数各演示一次即可,重点全放在坑卡上:让学员齐声回答"getOpenFileName 之后第一行代码写什么?"判空。用户永远可能点取消,这是健壮性思维的第一课(Day4 的 open() 判返回值、ParseError 检查是同一条思维线)。 +【提示】QMessageBox 的 question/information/warning/critical 四兄弟提一嘴图标差异即可,不逐个演示。 +【过渡】"骨架和对话框都有了。上午最后一小时,把你的计算器装进这副骨架。" """ +}, +{ + 'badge': '上午 · 课堂练习 11:00–12:00(工程师指导、答疑)', + 'title': '任务卡 1 · 给计算器套上 QMainWindow', + 'boxes': [ + ('task', '任务卡 1', [ + '目标:昨天的计算器改造为 QMainWindow 应用;加菜单「帮助→关于」弹 QMessageBox;加「历史」停靠窗(可先放空 QLabel)', + '验收:计算器功能不回退;菜单动作有响应;停靠窗可拖动/收起', + '参考:p03/ch05/central_widget.cpp · dock_widget.cpp · p03/ch06/message_box_question.cpp', + ]), + ], + 'minutes': 60, 'segment': '上午 · 课堂练习', + 'notes': """【关键改造思路(先讲 3 分钟再放手)】昨天的计算器是一个 QWidget,今天不重写,把它整个 setCentralWidget 进 QMainWindow。这个"旧组件装进新骨架"的动作本身就是组件化思维的第一次实践,点破它。 +【巡场检查点】①菜单 QAction 忘 connect(上午坑卡现场版,让学员自己对照坑卡定位);②功能回退(改造弄坏了昨天的计算逻辑,借机讲"改结构不改逻辑"的重构纪律)。 +【分层】快的学员让"历史"停靠窗真的记录每次运算(QLabel 追加文本即可),这直接为 Day5 案例的表格记录做铺垫。 +【收尾】抽 1–2 人演示:点菜单弹关于、拖动停靠窗。""" +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '布局管理器(回收昨天的脚手架)', + 'boxes': [ + ('concept', None, [ + '布局接管子控件位置/大小计算,随窗口缩放自动重排', + 'QHBox / QVBox / QGrid 三兄弟 + 嵌套组合 + 伸缩因子 addStretch()', + '布局对象同样遵循 QObject 父子所有权', + ]), + ('pitfall', None, [ + '同一控件不能属于两个布局', + '布局设置后又用 setGeometry()/move() 手动改位置——被下次刷新覆盖,看起来「设置没生效」', + ]), + ], + 'source': '出处:p03/ch08/custom_widget.cpp', + 'minutes': 30, 'segment': '下午 · 布局', + 'notes': """【兑现承诺】开场明确"昨天欠的布局课现在还":把计算器脚手架里的 QGridLayout 拿出来讲原理。为什么 addWidget(w, row, col) 之后拉伸窗口按钮会跟着变?因为布局在每次 resize 时重算所有子控件的几何。 +【讲法】演示三兄弟各一个最小例子,然后重点讲嵌套:垂直布局套水平布局,拼出"上显示屏下键盘"的计算器结构。学员昨天已经无意识用过,今天补上心智模型。addStretch 用"弹簧"类比。 +【坑卡】当堂演示"布局后 move() 失效":move 完拉伸一下窗口,控件跳回布局位置,"看起来没生效"的真相是被布局覆盖。规则:进了布局就别手动定位,二选一。 +【随堂练 10 分钟】把自己计算器的布局改成"显示屏占 1 份、键盘占 3 份"的比例(伸缩因子)。 +【过渡】"位置管好了,看控件本身还有什么花样。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': '常用控件 + 资源系统 .qrc', + 'boxes': [ + ('concept', None, [ + 'QLabel 一个控件多种形态:纯文本 / 富文本 HTML / 图片 QPixmap / 动图 QMovie', + '输入靠 QLineEdit;按钮族 QPushButton / QRadioButton / QCheckBox', + ':/... 路径是 Qt 资源系统:资源编译期打包进可执行文件,发布不用带散图', + ]), + ('pitfall', None, [ + '.qrc 路径与代码引用不一致:运行时静默失败(图不显示),不报编译错误', + '忘接 CMake 的 AUTORCC / qt5_add_resources——资源系统不生效', + ]), + ], + 'source': '出处:p03/ch08/label_text_html.cpp · label_pixmap.cpp · label_movie.cpp', + 'minutes': 25, 'segment': '下午 · 控件与资源', + 'notes': """【讲法】三个 label 示例连跑(文本→HTML→图片/动图),节奏快一点,每个 5 分钟:控件 API 本身不难,难的是"知道有什么可用",本页目标是建目录索引不是背 API。 +【qrc 重点】打开 label_pixmap.qrc 和对应 cpp 对照讲:qrc 里登记路径 → 代码里 :/ 引用 → CMake AUTORCC 编译打包。坑卡演示:把代码里的 :/ 路径改错一个字母再跑,不报错、图消失,"静默失败"要亲眼见过才会警惕(Day4 的 open()/ParseError 静默失败是同一主题)。 +【互动】问:"图片放资源系统 vs 放程序旁边的目录,各适合什么?"只读小资源进 qrc;用户数据/大文件放文件系统(Day4 的主题,顺势预告)。 +【过渡】"功能齐了,长得还不行。最后一节,化妆。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': 'QSS 界面美化', + 'code': 'QPushButton#okButton { background-color: #2f855a; border-radius: 6px; }\nQPushButton#okButton:hover { background-color: #276749; }', + 'boxes': [ + ('concept', None, [ + '语法脱胎于 CSS:类型选择器 / ID 选择器(#objectName)/ 伪状态(:hover :pressed)+ 层叠', + '作用对象是 QWidget 树而非 DOM;「长什么样」与「做什么」分离,改样式不碰逻辑', + ]), + ('pitfall', None, [ + 'ID 选择器必须先 setObjectName()', + 'setStyleSheet() 级联影响所有子控件——范围失控最易踩,样式尽量写窄(带选择器)', + ]), + ], + 'source': '出处:examples/qss_styling/qt_qss_styling.cpp(README 有练习方向)', + 'minutes': 25, 'segment': '下午 · QSS', + 'notes': """【讲法】跑 qss_styling 示例,然后当堂改样式立即重跑,"改外观不碰逻辑"的分离感是本节要传达的核心体验。学过 CSS 的学员点一句"就是 CSS 的方言",没学过的按"选择器=谁、属性=怎么画"从零讲。 +【坑卡演示】级联失控:在顶层 widget 上裸写 background-color(不带选择器),跑起来所有子控件全变色,然后加上类型选择器收窄。让学员记"样式写窄"三个字。 +【伪状态】:hover/:pressed 是"零代码交互反馈",性价比极高,今晚作业的验收点之一。 +【随堂练 15 分钟】给上午的主窗口版计算器套第一版 QSS(哪怕只改按钮底色),作业时段继续精修。 +【过渡】"今晚作业就是把它做完做好,验收标准在下一页。" """ +}, +{ + 'badge': '下午 · 课堂作业 16:00–17:00(工程师答疑)', + 'title': '任务卡 2 · 用 QSS 美化计算器', + 'boxes': [ + ('task', '任务卡 2', [ + '目标:数字键/运算键/等号键用 QSS 区分颜色;整体布局随窗口缩放自适应', + '验收:至少 2 类按钮不同 background-color;至少 1 个按钮有 :hover 或 :pressed', + '验收:拉伸窗口按钮跟着重排', + '参考:examples/qss_styling/ · p03/ch08/custom_widget.cpp', + ]), + ], + 'minutes': 60, 'segment': '下午 · 作业', + 'notes': """【实现提示(开工前讲 3 分钟)】三类按键区分的正确姿势:按类别 setObjectName(或用动态属性),QSS 里 ID/属性选择器分别上色。不要给每个按钮单独 setStyleSheet(层级混乱且难维护)。 +【巡场检查点】①样式写太宽把显示屏也染色了(下午坑卡现场版);②hover 无效,大概率 objectName 没设或拼错,静默失败,让学员自己按坑卡排查。 +【分层】快的学员挑战"深色主题"整套配色,或给等号键加 :pressed 按下变暗的两态反馈。 +【预告】明天计算器线暂停一天(事件/绘图/文件),Day5 综合案例会用到今天所有的骨架加布局加美化技能。""" +}, +{ + 'badge': '当日收尾', + 'title': '当日产出验证', + 'bullets': [ + '现场演示:主窗口版计算器(菜单/停靠窗)+ QSS 配色 + 缩放自适应,功能不回退', + '钩子:QSS 与前端 CSS 同源;Qt Designer / QML 是另一条 UI 路线(知道存在即可)', + '明天 Day4:事件机制 / 绘图 / 表格 + 文件系统——数据要开始落盘了', + ], + 'minutes': 5, 'segment': '收尾', + 'notes': """【验收】抽 2–3 组演示升级后的计算器,其他人对照验收标准自查打钩。 +【钩子讲法】一句话安放 Designer/QML:"拖拽和声明式是另外两条 UI 路线,本课程走纯代码是为了让你们看清每一层。会走路了再学开车不迟。" +【收线】"三天了,你们的程序还没碰过磁盘。明天开始,让数据活过程序的重启。"散场。""" +}, +] diff --git a/tools/teaching_ppt/day4.py b/tools/teaching_ppt/day4.py new file mode 100644 index 0000000..c243ce8 --- /dev/null +++ b/tools/teaching_ppt/day4.py @@ -0,0 +1,230 @@ +# -*- coding: utf-8 -*- +"""Day4 · 事件机制 / 绘图与表格 / 文件系统 —— 页面对应 slides/day4.html。""" + +SLIDES = [ +{ + 'kind': 'title', + 'badge': 'C++/Qt 方向实训 · 第 4 / 4 个讲授日', + 'title': 'Day 4 · 事件机制 / 绘图与表格 / 文件系统', + 'subtitle': 'Qt 消息机制和事件 / 绘图事件及表格事件 / 文件系统 + JSON', + 'bullets': [ + '上午 08:30–11:00 讲授:事件循环 · 事件拦截 · QPainter 绘图 · QTableWidget 表格', + '上午 11:00–12:00 课堂练习:事件过滤器 + 自绘图形', + '下午 13:30–16:00 讲授:QFile/流/JSON + ★工作目录 + ★文件编码', + '下午 16:00–17:00 作业:本地数据存储小工具(Day5 综合案例前身)', + ], + 'minutes': 3, 'segment': '开场', + 'notes': """【主线】今天上下午各一条线:上午"框架怎么把外部世界的变化送进你的代码"(事件加绘图加表格),下午"你的数据怎么活过程序的重启"(文件加 JSON)。今晚的作业「本地数据存储小工具」就是明天综合案例的直接前身,做完今天,明天的案例就是把今天的成果组装放大。 +【时长骨架】上午:事件循环 15 → 事件拦截 25 → 绘图 25 → 表格 20,讲授约 85 分钟。下午:QIODevice 20 → JSON 25 → 工作目录坑 15 → 文件编码坑 15,讲授约 75 分钟。两段都留足随堂练时间。 +【衔接提醒】上午事件循环页要给下午的"主线程阻塞=冻结"埋点,下午埋的雷明天 QThread 来拆,一条贯穿三天的暗线。""" +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '事件循环与派发:事件 vs 信号', + 'boxes': [ + ('concept', None, [ + 'Day2 的 app.exec() 拆开看:事件循环不停从队列取 QEvent 派发给目标控件', + '事件 = 框架推给控件的通知(鼠标/键盘/重绘);信号槽 = 对象间主动通知', + '两套机制并存不冲突', + ]), + ('pitfall', '明天的伏笔', [ + '主线程做耗时操作 = 事件循环停摆 = 整个界面冻结', + '学员项目最高频的「卡死」来源,正确解法(多线程)明天综合案例讲', + ]), + ], + 'source': '出处:p03/ch02/minimal_qt_app.cpp(exec)', + 'minutes': 15, 'segment': '上午 · 事件循环', + 'notes': """【衔接】回指 Day2"事件循环=前台服务员"的类比,今天把它拆开:服务员从队列里取单子(QEvent)派给对应窗口。然后区分两套机制:事件是"系统/框架主动通知控件",信号槽是"对象之间互相通知",同一个按钮点击会同时产生鼠标事件和 clicked 信号,两者不冲突。 +【坑卡只埋不拆】主线程阻塞坑今天只演示现象不解决:在按钮槽里塞一个 sleep(5) 的循环跑给学员看,窗口冻住、拖不动、系统提示未响应。明确说"这是明天综合案例的核心提升点,今天的任务是认清病症"。埋点到位,明天 QThread 出场时学员会有"终于等到"的投入感。 +【过渡】"事件派给控件之后,控件怎么处理?两条路。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '事件拦截:重写 xxxEvent vs 事件过滤器', + 'boxes': [ + ('concept', None, [ + '两条拦截路径:重写 mousePressEvent() 等虚函数(只影响自己)', + 'installEventFilter()(一个对象统一拦截多个控件的事件)', + '事件有 accept()/ignore() 状态,决定是否继续向父控件传播', + ]), + ('pitfall', None, [ + '重写事件函数忘记调用基类实现(QWidget::mousePressEvent(e))——控件默认行为悄悄丢失', + ]), + ], + 'source': '出处:p03/ch09/event_override_customwidget.cpp · event_filter_mainwindow.cpp · mouse_event.cpp', + 'minutes': 25, 'segment': '上午 · 事件拦截', + 'notes': """【讲法】两条路径用"自己处理 vs 雇保安"类比:重写 xxxEvent 是自己加装门铃(只响自己家门),installEventFilter 是给整栋楼雇一个保安(一个对象拦截多个控件)。各跑一个示例对照。 +【坑卡演示】重写 mousePressEvent 不调基类版本:跑一下,发现双击不再选中文字、右键菜单没了,"默认行为悄悄丢失"。修复就是加一行 QWidget::mousePressEvent(e)。这个坑在学员项目里命中率非常高,必须当堂见过。 +【accept/ignore】只讲概念不深挖:事件先派给最内层控件,ignore() 后会向父控件冒泡,为 Day5 综合案例"关闭事件弹确认框"埋点(closeEvent)。 +【过渡】"有一种特殊事件叫重绘事件,它的处理方式和其他事件不一样。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '绘图事件:QPainter', + 'boxes': [ + ('concept', None, [ + 'Qt 绘制是框架驱动而非「想画就画」:所有绘制只能发生在 paintEvent() 里', + '由 Qt 按需调用;数据变了想重画,调 update()(异步排队一次重绘请求)', + '绘图设备三件套 QImage / QPixmap / QPicture 作参考', + ]), + ('pitfall', None, [ + '在 paintEvent 之外直接构造 QPainter 画窗口——未定义行为', + '数据变了忘调 update()——界面不会自动重画', + ]), + ], + 'source': '出处:p03/ch10/qpainter_basic.cpp(设备类参考:paint_device_qimage.cpp 等)', + 'minutes': 25, 'segment': '上午 · 绘图', + 'notes': """【核心理念】这页最重要的一句话是"框架驱动而非想画就画",和学员过去"想画就 printf"的命令式心智彻底不同。用"只能在画室里画画,画室开门由管理员决定"类比 paintEvent 的被动性。 +【讲法】跑 qpainter_basic.cpp(画几个基本图形),然后演示 update() 的必要性:改一个数据成员不调 update,界面没反应;加上 update(),重画。这是上午随堂练的核心动作。 +【坑卡】paintEvent 外构造 QPainter 是 UB,简单说一句"别在按钮槽里直接画窗口"即可,不展开。 +【应用预告】绘图在项目里的典型用场是自绘控件(仪表盘、波形图),告诉学员"想做漂亮图表的,这是入口",给有兴趣的人留方向。 +【过渡】"刚才画的都是固定图形;要展示表格状数据,用另一种控件。" """ +}, +{ + 'badge': '上午 · 讲授 08:30–11:00', + 'title': '表格:QTableWidget 与单元格事件', + 'boxes': [ + ('concept', None, [ + 'item 类控件:数据直接塞进控件(QTableWidgetItem,new 出来交给表格接管所有权)', + '"表格事件"以信号暴露:cellClicked / cellDoubleClicked / itemChanged', + ]), + ('info', '钩子 · 背后是完整的 Model/View 架构', [ + '数据(model)与展示(view)彻底分离、同一模型可喂多个视图', + '改模型必须发 dataChanged 等信号视图才刷新——数据规模大/多视图联动时的正解', + '自学入口:examples/model_view/', + ]), + ], + 'source': '出处:examples/table_widget/qt_table_widget_basics.cpp(README)', + 'minutes': 20, 'segment': '上午 · 表格', + 'notes': """【讲法】跑 table_widget 示例:填充 3×3 加点击单元格看状态栏变化加双击编辑触发 itemChanged。重点讲 QTableWidgetItem 的所有权,又是 new 出来交给控件(QObject 父子所有权的第 N 次出现,点一句即可不再展开)。 +【两条路线的边界】QTableWidget(item 类)vs Model/View:用"自带数据的成品表格"vs"数据和展示分离的乐高"类比。明确今天用前者,上手快、中小项目够用;后者作为钩子,数据量大或多视图联动时再学。 +【钩子讲法】Model/View 只用一页概念页带过,不演示完整代码:打开 model_view 示例让学员看一眼"同一个模型喂 QListView 和 QTableView",证明分离的价值即可。深入留给自学或 Day5 案例。 +【互动】问"点击表头怎么响应?"指向 cellClicked 之外的 header sectionClicked 信号,培养"需求→查信号"的检索意识。 +【过渡】"上午最后一小时,自己拦事件、自己画图形。" """ +}, +{ + 'badge': '上午 · 课堂练习 11:00–12:00(工程师指导、答疑)', + 'title': '任务卡 1 · 事件过滤器 + 自绘图形', + 'boxes': [ + ('task', '任务卡 1', [ + '目标①:给一个控件装事件过滤器,拦截鼠标按下并打印坐标', + '目标②:在 paintEvent 里画一个组合图形(棋盘格或仪表盘刻度)', + '验收:点击控件终端能看到坐标输出;图形正确绘制(可截图验证)', + '参考:p03/ch09/event_filter_mainwindow.cpp · p03/ch10/qpainter_basic.cpp', + ]), + ], + 'minutes': 60, 'segment': '上午 · 课堂练习', + 'notes': """【组织】两个子任务相互独立,可分组并行:A 组做事件过滤器(练"雇保安"路径),B 组做自绘(练 paintEvent + update 闭环),各 30 分钟后交叉展示。 +【巡场检查点】①事件过滤器里忘调基类 eventFilter 返回 false 导致默认行为异常(上午坑卡现场版);②自绘改了数据忘 update(上午坑卡现场版);③事件过滤器忘 installEventFilter 安装,编译过但拦截不生效。 +【分层】快的学员把两题合一:自绘一个仪表盘,鼠标点击改变指针角度并立即重绘,这就是 Day5 综合案例级别的交互雏形。 +【收尾】每组演示一次成果即可,不要求两题都完成。""" +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': 'QIODevice 统一读写抽象', + 'boxes': [ + ('concept', None, [ + 'QFile / QTextStream / QDataStream 都建立在 QIODevice 统一接口上', + '之后遇到网络/串口也是同一套接口', + 'QTextStream 面向文本行;QDataStream 面向结构化二进制', + ]), + ('pitfall', None, [ + 'open() 返回值不检查——文件不存在/没权限时静默失败,后续读写全是空操作', + '文本/二进制模式混淆会破坏内容', + ]), + ], + 'source': '出处:p03/ch11/qfile_basic.cpp · qtextstream_io.cpp · qdatastream_io.cpp', + 'minutes': 20, 'segment': '下午 · 文件 IO', + 'notes': """【引入】从"程序关掉数据就没了"切入:要数据活过重启,必须落到磁盘,这就是文件 IO 的存在理由。 +【讲法】QIODevice 抽象用"统一插头"讲:今天接文件、明天接网络、后天接串口,API 都是 open/read/write/close,投资一次受益终身。三个示例各演示读写一对:qfile_basic(裸字节)、qtextstream(按行文本)、qdatastream(结构化二进制,写进去再读出来类型不丢)。 +【坑卡演示】open() 不判返回值:把文件名改成一个不存在的路径再跑,程序不报错,写进去的数据全丢(写到一个失败的 QFile 上)。修复就是 if (!file.open(...)) return; 这一行。这是健壮性思维第二课(接上午 QMessageBox 判空)。 +【过渡】"文本/二进制流都会用,但项目里数据最常落地成什么格式?JSON。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00', + 'title': 'JSON 解析与写入', + 'boxes': [ + ('concept', None, [ + '序列化/反序列化是一对互逆操作,正确性标准 = round-trip:写出去再读回来数据一致', + 'QJsonDocument / Object / Array / Value 四件套;QJsonValue 是能装多种类型的变体', + ]), + ('pitfall', None, [ + '不检查 QJsonParseError:格式坏的输入静默变成空文档', + '类型不匹配时 toInt()/toString() 不报错只返回默认值——数据悄悄丢,很难查', + ]), + ], + 'source': '出处:examples/json_io/qt_json_parse_write.cpp(README)', + 'minutes': 25, 'segment': '下午 · JSON', + 'notes': """【讲法】round-trip 是本页核心:用"打包寄出再拆包"类比序列化/反序列化,正确性判据不是"能生成文件"而是"写出去读回来字段一个不丢"。这个判据今晚作业的验收标准就是它。 +【四件套】画树状结构讲清层级:Document 包 Object/Array,Object 是键值对,Array 是列表,Value 是叶子(可能是 string/number/bool/object/array)。跑 json_io 示例对照代码看每一层。 +【坑卡演示】两个静默失败各演示一次:①喂一段格式错的 JSON 不检查 ParseError,得到空文档,程序继续跑还以为没数据;②toInt() 取一个字符串字段,不报错返回 0,"数据悄悄丢"。这两个是 JSON 教学最有价值的两次当堂踩坑。 +【过渡】"读写都会了,但很多同学一跑就发现:'文件找不到'。下一页是几乎每届必问的坑。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00 · ★坑卡', + 'title': '★ 相对路径与工作目录', + 'code': 'QFile bad("data.json"); // X 相对当前工作目录——它在哪取决于你怎么启动\nQFile good(QCoreApplication::applicationDirPath() + "/data.json"); // O 锚定可执行文件位置', + 'boxes': [ + ('concept', None, [ + '相对路径基于进程当前工作目录——不是源码目录,也不一定是可执行文件目录', + 'IDE、终端、脚本三种启动方式的工作目录可能各不相同', + ]), + ('pitfall', None, [ + '"IDE 里能读到文件,命令行跑就说不存在"——几乎每届学员必问,根源就是这页', + ]), + ], + 'minutes': 15, 'segment': '下午 · 路径坑', + 'notes': """【痛点定位】这一页是 Day4 学员呼声最高的一页:作业一开做就会有人喊"我代码和你一样为什么读不到文件"。把它单独成页就是为了让全班同时建立"工作目录"意识。 +【讲法】当堂用三种方式跑同一个程序(IDE 跑、终端在源码目录跑、终端在 build 目录跑),分别 pwd 看工作目录,同一个相对路径读到的是三个不同位置的文件。根因讲清:相对路径不锚定可执行文件,锚定的是"启动时所在的目录"。 +【正解给两条】①只读资源走 .qrc(昨天讲的资源系统,永不漂移);②可写数据用 applicationDirPath() 拼绝对路径。今晚作业验收标准里硬性要求用第②条。 +【过渡】"路径对了、文件读到了,结果一看内容——乱码。最后一个坑。" """ +}, +{ + 'badge': '下午 · 讲授 13:30–16:00 · ★坑卡', + 'title': '★ 文本编码再遇(文件版)', + 'code': 'QTextStream ts(&file);\nts.setCodec("UTF-8"); // Qt 5 默认编码随平台/区域设置而变——跨机器必须显式指定', + 'boxes': [ + ('concept', None, [ + 'Day2 界面乱码(QString/源码编码)和今天文件乱码是同一根源的两次出现', + '字节 ↔ 文字之间永远隔着一层「编码」,跨边界(源码/文件/网络)必须显式指定', + ]), + ('pitfall', None, [ + 'Windows 记事本写的文件 Linux 程序读出乱码(或反过来)——不是「文件坏了」,是两边默认编码不同', + ]), + ], + 'minutes': 15, 'segment': '下午 · 编码坑', + 'notes': """【回指】开头直接回收 Day2 编码坑的"两个世界"图:字节世界和文字世界之间的那堵墙,今天换了一扇门,QTextStream。两次出现是刻意的间隔重复,目标是让"编码要显式指定"成为条件反射。 +【讲法】演示跨平台乱码(如有 Windows 文件样本最好,没有就用两个不同编码写读对照):QTextStream 不指定 codec 时默认随系统区域设置,同一份代码在两台机器读出不同结果,这种 bug 在交付现场最致命。 +【规则收束】第三条军规(黑板补完):"跨边界必声明编码"。源码用 UTF-8、文件读写显式 setCodec、网络同理。三天的编码主线在此收口。 +【过渡】"四个坑讲完,弹药齐了。今晚的作业是明天综合案例的前身。" """ +}, +{ + 'badge': '下午 · 课堂作业 16:00–17:00(工程师答疑)', + 'title': '任务卡 2 · 本地数据存储小工具', + 'boxes': [ + ('task', '任务卡 2', [ + '目标:设计「学员信息」结构(姓名/成绩/标签),支持增加记录并保存到 JSON 文件,重启程序后读出全部记录', + '验收:至少存 2 条记录并在重启后完整读回;字段不丢失', + '验收:数据文件路径用 applicationDirPath() 拼(今天的坑卡现学现用)', + '参考:examples/json_io/ · p03/ch11/qfile_basic.cpp', + ]), + ], + 'minutes': 60, 'segment': '下午 · 作业', + 'notes': """【设计提示(开工前讲 5 分钟)】白板画结构体 Student{name, score, tag} 加两个函数 toJson/fromJson 互逆,和下午 JSON 课的 round-trip 判据一一对应。强调"读回来的字段名必须和写出去的完全一致",否则 round-trip 一测就露馅。 +【巡场检查点】对照今天四张坑卡逐个验收:①open() 判返回值;②ParseError 检查;③路径用 applicationDirPath() 不用裸相对路径;④QTextStream 显式 setCodec UTF-8。坑卡内容当天进验收,记忆效果最好。 +【分层】快的学员加"删除记录"或"按成绩排序",这直接是 Day5 综合案例的功能子集,今天做掉明天案例就轻松。 +【预告】今晚的作业就是明天综合案例的雏形,明天在此基础上加主窗口、表格、后台导入,就是一个完整小软件。""" +}, +{ + 'badge': '当日收尾', + 'title': '当日产出验证', + 'bullets': [ + '现场演示「写入 → 重启 → 读回」完整闭环', + '讲得出 open()/ParseError 两处检查为什么不能省', + '明天 Day5 全天综合项目案例:学员信息管理器——今晚作业就是它的雏形', + ], + 'minutes': 5, 'segment': '收尾', + 'notes': """【验收】抽 2 组演示完整闭环;口头追问 open 和 ParseError 两处检查的必要性,答得出才算过。 +【收线】"四天讲授到此结束。明天把你们这四天学的所有东西——窗口、控件、信号槽、文件、JSON、表格——组装成一个完整小软件,再补上最后一块拼图:多线程。"散场。""" +}, +] diff --git a/tools/teaching_ppt/day5.py b/tools/teaching_ppt/day5.py new file mode 100644 index 0000000..0f0b30a --- /dev/null +++ b/tools/teaching_ppt/day5.py @@ -0,0 +1,171 @@ +# -*- coding: utf-8 -*- +"""Day5 · 综合项目案例讲解:学员信息管理器(全天)—— 页面对应 slides/day5.html。""" + +SLIDES = [ +{ + 'kind': 'title', + 'badge': 'C++/Qt 方向实训 · 综合案例日 · 明天起进入项目开发', + 'title': 'Day 5 · 综合项目案例讲解:学员信息管理器', + 'subtitle': '需求拆解 → 界面骨架 → 数据层 → 提升:QThread 防卡界面 → 流程规范', + 'bullets': [ + '上午:需求拆解 → 界面骨架(QMainWindow + QTableWidget + 对话框)→ 数据层(Student ↔ JSON)', + '下午 1:提升——QThread 防卡界面(worker-object 模式)', + '下午 2:开发流程与规范(Git/考核)+ 分组定项目方向', + 'Day6–9 学员项目开发;Day10 下午项目答辩', + ], + 'source': '参考实现:examples/student_manager/qt_student_manager.cpp(README)', + 'minutes': 5, 'segment': '开场', + 'notes': """【定位】今天不是新知识日,是"组装日",把四天学的全部东西捏成一个完整小软件,再补最后一块拼图(多线程)。开场明确这个定位,让学员从"听课模式"切换到"项目模式"。 +【讲法建议】全天以"先看成品→拆解→回指知识点"的节奏推进,每个环节都让学员意识到"这部分我已经学过",建立项目信心。教师参考实现 examples/student_manager/ 全程作为对照。 +【时长骨架】上午 1(需求加骨架)约 90 分钟;上午 2(数据层)约 90 分钟;下午 1(QThread 新授)约 70 分钟;下午 2(流程规范加分组)约 50 分钟。每段留 15 分钟缓冲。 +【红线提醒】下午 QThread 是全天最高优先级保底项。上午超时宁可砍需求拆解的深度也不能砍 QThread,否则项目期"界面冻结/跨线程崩溃"答疑成本数倍返还。""" +}, +{ + 'badge': '上午 1 · 需求拆解 → 界面骨架', + 'title': '需求拆解:先想清楚再动手', + 'boxes': [ + ('concept', '核心概念——开发流程的第一步不是写代码', [ + '需求:管理学员记录(姓名/成绩/标签),支持添加、保存、读取、批量导入', + '拆功能 → 映射到已学组件:列表=QTableWidget(Day4)、添加=模态对话框(Day3)、保存读取=QFile+JSON(Day4)、批量导入=后台线程(今天新授)、外壳=QMainWindow(Day3)', + '拆完就得到任务清单——这正是 Day6 起你们在 Jira 上要做的事', + ]), + ], + 'minutes': 25, 'segment': '上午 · 需求拆解', + 'notes': """【地位】这一页是"从学生到工程师"思维转换的关键页:写代码之前先拆任务。中初级学员最常见的项目障碍不是"不会写"而是"不知道先写什么",本页就是治这个。 +【讲法】白板当堂拆:把"学员信息管理器"这个一句话需求,逐条拆成 5 个功能点,再把每个功能点映射到已学组件。每映射一条就问"这是哪天学的?"让全班齐声回答 Day2/3/4。这个回指动作能极大强化"我已经具备做项目的全部知识"的信心。 +【交付物】拆解的产物就是任务清单。明确告诉学员:"Day6 起你们在 Jira 上建的每一个 task,就应该长这样:一个动词开头、可验收、映射到具体技术点。"今天先有这个意识,明天分组就用得上。 +【过渡】"任务清单有了,开始拼第一块:界面骨架。" """ +}, +{ + 'badge': '上午 1 · 需求拆解 → 界面骨架', + 'title': '界面骨架走查', + 'boxes': [ + ('concept', '回指 Day3–4', [ + 'QMainWindow:菜单(文件/记录/帮助)+ 工具栏 + 状态栏 + setCentralWidget(table)', + 'QAction 一个动作三处复用(菜单/工具栏/快捷键),每个 action 都要 connect', + '添加记录:模态 exec() + QFormLayout + QDialogButtonBox', + '表格 item 所有权:new 出来交给表格——父子所有权模型第 N 次出现', + ]), + ], + 'source': '现场跑:tools/run_qt.sh qt_student_manager(离屏自测:QT_QPA_PLATFORM=offscreen)', + 'minutes': 40, 'segment': '上午 · 骨架走查', + 'notes': """【讲法】自顶向下:先跑参考实现 examples/student_manager 给全班看成品长什么样,然后关掉投影,带学员从空 main() 开始一步步搭——QApplication → MainWindow(继承 QMainWindow) → setCentralWidget(table) → 菜单/工具栏/action → connect。每一步都问"这步对应哪天的哪个示例"。 +【回指密度】本页 4 条 bullet 每条都是一次回指:QMainWindow→Day3、QAction→Day3(坑卡"忘 connect")、模态对话框→Day3、表格 item 所有权→Day4 加贯穿全课程的所有权主线。回指的密度本身就是"项目=已学内容组装"的最佳论证。 +【代码组织建议】当场把 MainWindow 拆成 .h/.cpp,演示"声明在头、实现在 cpp"的工程结构,这是 Day6 起项目实作的基本功,今天第一次正式做。 +【互动】故意演示一次"建了 action 忘 connect",菜单项灰色点了没反应,让学员用 Day3 坑卡的语言定位问题。坑卡回炉一次。 +【过渡】"骨架立住了,往里灌数据。" """ +}, +{ + 'badge': '上午 2 · 数据层', + 'title': '记录 ↔ JSON ↔ 磁盘', + 'code': 'struct Student { QString name; double score; QString tag; };\nQJsonObject toJson(const Student&); // 互逆的一对\nStudent fromJson(const QJsonObject&); // round-trip = 正确性标准', + 'boxes': [ + ('concept', '回指 Day4(昨晚作业的完整版)', [ + '数据结构与界面分离:Student 结构体不知道 QTableWidget 的存在——Model/View 思想的朴素版', + '健壮性三处检查全部在场:open() 返回值、QJsonParseError、路径用 applicationDirPath() 拼', + ]), + ('pitfall', '复盘昨晚作业的常见问题', [ + '保存和读取的字段名拼写不一致("score" vs "Score")——round-trip 一测就露馅', + '这就是为什么验收标准是「写进去读出来一致」而不是「能生成文件」', + ]), + ], + 'minutes': 45, 'segment': '上午 · 数据层', + 'notes': """【讲法】把昨晚作业的数据层"升级"成案例版本:结构体 Student 加两个自由函数 toJson/fromJson。重点讲"数据结构与界面分离":Student 不知道 QTableWidget 的存在,这是 Model/View 思想的朴素实践(回指昨天表格页的钩子)。 +【当堂对照】让 1–2 位学员展示昨晚作业的数据层代码,对照参考实现讲差异,尤其是字段名拼写不一致的坑(昨晚作业的高发问题),round-trip 测试一跑就露馅,这正是"验收标准为什么是 round-trip"的活教材。 +【三处健壮性检查】打开参考实现源码,逐处指认 open() 判返回值、ParseError 检查、applicationDirPath() 拼路径,四天坑卡的成果在此集中体现。让学员看到"这些检查不是教条,是真实代码里必须有的"。 +【代码组织】toJson/fromJson 放在独立的 student.h(纯数据加序列化),不依赖 Qt Widgets,演示"按依赖方向分层"的工程习惯。 +【过渡】"功能齐了:能加、能存、能读。但有一个场景会让它冻住,下午第一项,新授。" """ +}, +{ + 'badge': '下午 1 · 提升:多线程防卡界面(新授)', + 'title': '先看病症:把耗时活儿放主线程', + 'boxes': [ + ('pitfall', '现场演示', [ + '把 msleep 循环直接放按钮槽里 → 整个窗口冻结:点不动、拖不动、系统提示「未响应」', + 'Day4 讲过:主线程阻塞 = 事件循环停摆——学员项目最高频的「卡死」来源', + ]), + ], + 'minutes': 15, 'segment': '下午 · QThread 新授', + 'notes': """【演示流程】在参考实现的"后台导入"按钮槽里,把 moveToThread 那套注释掉,直接在工作函数里 sleep 循环,跑给全班看:点导入后窗口完全冻结 5 秒,鼠标点哪都没反应,系统标题栏提示"未响应"。 +【讲法】让学员先用 Day4 的语言描述现象:"事件循环停摆了"。然后定性:"这是你们项目里最容易出现的卡死,根源不是代码错,而是把耗时活儿放错了线程。" +【不急着给解法】演示完病症后停一下,问"你们觉得该怎么解决?"收集学员想法(多数会猜"开个线程"),自然引出下一页的 worker 模式。先有痛点再给方案,教学效果远好于直接讲语法。 +【过渡】"对,要开线程。但 Qt 开线程有个最容易被带歪的姿势,下一页讲正确做法。" """ +}, +{ + 'badge': '下午 1 · 提升:多线程防卡界面(新授)', + 'title': '正解:worker-object 模式', + 'code': 'auto* thread = new QThread(this);\nauto* worker = new ImportWorker; // 不给 parent:有 parent 禁止 moveToThread\nworker->moveToThread(thread); // 必须在 start() 之前\nconnect(thread, &QThread::started, worker, [worker]{ worker->doImport(10); });\nconnect(worker, &ImportWorker::rowReady, this, /* 更新表格 */); // 自动 Queued\nthread->start();', + 'boxes': [ + ('concept', '核心概念(三件套)', [ + '线程亲和性:每个 QObject 归属一个线程,GUI 控件必须归主线程', + 'worker 对象:官方推荐——耗时逻辑放 worker,moveToThread 搬进工作线程,而非继承 QThread 重写 run', + '跨线程信号槽自动 Queued(Day2 连接方式闭环):worker 发信号、主线程槽更新表格,不需手动加锁', + ]), + ('pitfall', None, [ + '在工作线程直接操作 GUI 控件——未定义行为,跨线程崩溃头号来源', + 'moveToThread 写在 start() 之后 / worker 带了 parent——都不生效', + ]), + ], + 'source': '最小版:examples/qthread_worker/ · 案例集成版:student_manager 的 startImport()', + 'minutes': 40, 'segment': '下午 · QThread 新授', + 'notes': """【地位】这是全天最高优先级的新授点,也是四天来埋的"主线程阻塞"暗线的收口,必须讲透,时间是 40 分钟,不能压缩。 +【讲法顺序】先讲"不要怎么做":继承 QThread 重写 run 是网上最常见的写法但官方不推荐(容易把 worker 和线程生命周期搞混),明确否定它;再讲正解 worker-object 模式三件套:①worker 是普通 QObject 不给 parent;②moveToThread 搬家;③用信号槽通信。 +【三件套逐条讲】线程亲和性用"每个 QObject 有户籍"类比;worker 模式用"雇外援干活,干完打电话汇报"类比,打电话就是信号槽;跨线程信号槽自动 Queued 是 Qt 的魔法所在(回指 Day2 的 Direct/Queued/Auto,闭环)。强调"不需要手动加锁",这是 worker 模式相比 std::thread+mutex 的核心优势。 +【坑卡演示】在工作线程里直接 table->setItem,可能崩也可能"看起来正常"(又是 UB 双面性)。规则给死:"工作线程绝不碰 GUI,只发信号让主线程碰。" +【闭环验证】参考实现的离屏自测会自动校验"导入 3 条后总数 5 条、界面未阻塞",跑一次让全班看绿色通过。 +【过渡】"最后一块拼图补上了,你们现在具备做一个完整 Qt 桌面软件的全部技能。下午最后一节,讲怎么把这些技能变成答辩成绩。" """ +}, +{ + 'badge': '下午 2 · 开发流程与规范 + 分组定方向', + 'title': 'Git 提交习惯', + 'boxes': [ + ('concept', None, [ + '每完成一个任务就提交,提交粒度对应任务粒度', + 'commit 信息说清「做了什么、为什么」——项目的可追溯记录,评审和回滚都靠它', + ]), + ('pitfall', None, [ + '攒几天代码一次性提交、信息写"update"——历史失去可追溯性,出问题无法定位是哪次改动引入', + '从 Day6 第一次 commit 开始就按规范来', + ]), + ], + 'minutes': 15, 'segment': '下午 · 流程规范', + 'notes': """【讲法】用"代码是资产、commit 历史是资产的账本"类比,账本记得乱,资产就管不住。展示一个写得好的 commit 历史(每个消息动词开头、说清意图)和一个写得烂的(全是 update)的对比。 +【规范给具体可执行标准】①粒度:一个任务一次提交;②消息格式:动词开头("添加"/"修复"/"重构"),一行说做了什么,必要时正文说为什么;③不要提交无法编译的代码。 +【当堂练习(可选 10 分钟)】让每组在刚刚的案例代码上练习一次规范提交,这是明天起每天都要做的事,今天建立肌肉记忆。 +【过渡】"代码写得好只是考核的一部分。下一页看清考核的全貌。" """ +}, +{ + 'badge': '下午 2 · 开发流程与规范 + 分组定方向', + 'title': '考核标准与分组定方向', + 'boxes': [ + ('concept', None, [ + '考核不只看代码:项目管理(任务拆解/进度可见)+ 项目技术 + 职业素养(协作/答辩表达)', + '只埋头写代码会丢掉其他维度的分', + '今天散场前每组确认:项目选题 + 初步分工 + 任务拆解草稿(上午「需求拆解」那页就是模板)', + ]), + ('info', '兴趣钩子(各 5 分钟演示,选学)', [ + '动效:QPropertyAnimation(任何 Q_PROPERTY 都能被缓动曲线驱动)', + '多状态 UI:QStateMachine(状态成为一等对象,替代一堆 bool 标志)', + '线程的更高抽象:QtConcurrent / 线程池', + ]), + ], + 'minutes': 25, 'segment': '下午 · 考核 + 分组', + 'notes': """【考核讲法】投出考核标准三维度图,重点强调"技术只占三成",这是中初级学员最容易踩的认知坑(以为代码好就满分)。项目管理维度对应今天上午的"任务拆解",职业素养对应每天的协作和最后的答辩表达,把考核标准和今天讲的方法论一一对应。 +【分组实操】留 15 分钟让每组完成三件事:①确认项目选题(参考方案书备选项目,或自拟);②初步分工(谁主界面、谁主数据、谁主协调);③用今天上午"需求拆解"的格式写一版任务拆解草稿。教师巡场逐组确认,散场前每组交一份。 +【钩子讲法】三个钩子各 5 分钟演示即可,目的有二:①给学有余力的组指明加分方向(动画/状态机/QtConcurrent 都是答辩亮点);②告诉所有人"Qt 还有很多没讲的能力,项目期可以按需自学",留个引子。 +【收尾承诺】明确告诉学员:明天起进入项目实作,工程师全程在场答疑;今天案例的全部代码(examples/student_manager/)对他们开放作参考。""" +}, +{ + 'badge': '当日收尾 · 进入项目期', + 'title': '当日产出验证 · 进入项目期', + 'code': 'QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_student_manager\n# 自测 1/2:JSON 写入 → 清空 → 读回,2 条记录完整\n# 自测 2/2:后台线程导入 3 条,总计 5 条,界面未阻塞', + 'bullets': [ + '每组提交:项目选题 + 分工说明 + 任务拆解草稿', + '明天 Day6 起:学员编码 + 工程师指导', + 'Day10 下午:项目答辩', + ], + 'minutes': 5, 'segment': '收尾', + 'notes': """【验收】跑参考实现的离屏自测,全班看两行绿色通过;每组当场提交三件套(选题/分工/拆解草稿)。 +【结训语】"五天前你们刚进门,今天你们已经能独立做一个会存数据、不卡界面、有规范流程的桌面软件。后面五天,把它变成你们自己的项目。Day10 答辩见。"散场。""" +}, +] diff --git a/tools/teaching_ppt/gen.py b/tools/teaching_ppt/gen.py new file mode 100644 index 0000000..d31feef --- /dev/null +++ b/tools/teaching_ppt/gen.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""教学幻灯片 PPT 生成器(4 天讲授 + Day5 综合案例)—— 手工维护,非 wiki 生成产物。 + +- 数据:同目录 day1.py … day5.py(每页一个 dict) +- 产物:docs/teaching/ppt/day{1..5}.pptx,页面内容与 docs/teaching/slides/*.html 对应 +- 讲解说明文字(含建议时长/讲法/演示/互动/过渡)只写入**演讲者备注**,不进主页面 + +用法(python-pptx 不在系统 Python 里,用任一装了它的解释器): + python3 -m venv ~/.venv-pptx && ~/.venv-pptx/bin/pip install python-pptx + ~/.venv-pptx/bin/python tools/teaching_ppt/gen.py +""" +import importlib +import math +import os +import sys + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import MSO_ANCHOR +from pptx.oxml.ns import qn +from pptx.util import Inches, Pt + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT_DIR = os.path.join(ROOT, 'docs', 'teaching', 'ppt') + +FONT = 'Microsoft YaHei' +MONO = 'Consolas' + +# (填充, 边框, 标题色) +BOX_STYLE = { + 'concept': (RGBColor(0xE8, 0xF5, 0xE9), RGBColor(0x2F, 0x85, 0x5A), RGBColor(0x22, 0x54, 0x3D)), + 'pitfall': (RGBColor(0xFD, 0xEC, 0xEA), RGBColor(0xC5, 0x30, 0x30), RGBColor(0x82, 0x27, 0x27)), + 'info': (RGBColor(0xEB, 0xF8, 0xFF), RGBColor(0x2B, 0x6C, 0xB0), RGBColor(0x2A, 0x43, 0x65)), + 'task': (RGBColor(0xFF, 0xFA, 0xEB), RGBColor(0xB7, 0x79, 0x1F), RGBColor(0x74, 0x4D, 0x0B)), +} +BOX_DEFAULT_HEADING = {'concept': '核心概念', 'pitfall': '常见的坑', 'info': '说明', 'task': '任务卡'} + +TEXT_DARK = RGBColor(0x1A, 0x20, 0x2C) +TEXT_GRAY = RGBColor(0x71, 0x80, 0x96) +CODE_BG = RGBColor(0x1E, 0x1E, 0x1E) +CODE_FG = RGBColor(0xD4, 0xD4, 0xD4) + +ML = 0.45 # 左右边距 (inch) +W = 13.333 - 2 * ML + + +def set_font(run, name=FONT, size=None, bold=None, color=None, italic=None): + f = run.font + f.name = name + if size is not None: + f.size = Pt(size) + if bold is not None: + f.bold = bold + if italic is not None: + f.italic = italic + if color is not None: + f.color.rgb = color + # 东亚字体要单独设置(python-pptx 的 .name 只设 latin) + rpr = run._r.get_or_add_rPr() + ea = rpr.find(qn('a:ea')) + if ea is None: + ea = rpr.makeelement(qn('a:ea'), {}) + rpr.append(ea) + ea.set('typeface', name) + + +def add_textbox(slide, left, top, width, height): + tb = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height)) + tf = tb.text_frame + tf.word_wrap = True + tf.margin_left = tf.margin_right = Pt(4) + tf.margin_top = tf.margin_bottom = Pt(2) + return tb, tf + + +def wrap_lines(text, per_line): + return max(1, math.ceil(len(text) / per_line)) + + +def add_box(slide, kind, heading, lines, top): + """彩色概念/坑/说明卡,返回其高度(inch)。""" + fill, border, head_color = BOX_STYLE[kind] + body_h = sum(wrap_lines(t, 62) for t in lines) * 0.28 + h = 0.42 + body_h + 0.10 + shape = slide.shapes.add_shape(1, Inches(ML), Inches(top), Inches(W), Inches(h)) # 1 = 矩形 + shape.fill.solid() + shape.fill.fore_color.rgb = fill + shape.line.color.rgb = border + shape.line.width = Pt(1.2) + shape.shadow.inherit = False + tf = shape.text_frame + tf.word_wrap = True + tf.margin_left = Pt(10) + tf.margin_right = Pt(10) + tf.margin_top = Pt(5) + tf.margin_bottom = Pt(4) + tf.vertical_anchor = MSO_ANCHOR.TOP + p = tf.paragraphs[0] + r = p.add_run() + r.text = heading or BOX_DEFAULT_HEADING[kind] + set_font(r, size=14, bold=True, color=head_color) + for line in lines: + p = tf.add_paragraph() + p.space_before = Pt(3) + r = p.add_run() + r.text = '• ' + line + set_font(r, size=13, color=TEXT_DARK) + return h + + +def add_code(slide, code, top): + lines = code.rstrip('\n').split('\n') + h = 0.26 + len(lines) * 0.245 + shape = slide.shapes.add_shape(1, Inches(ML), Inches(top), Inches(W), Inches(h)) + shape.fill.solid() + shape.fill.fore_color.rgb = CODE_BG + shape.line.fill.background() + shape.shadow.inherit = False + tf = shape.text_frame + tf.word_wrap = False + tf.margin_left = Pt(12) + tf.margin_top = Pt(6) + first = True + for line in lines: + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + r = p.add_run() + r.text = line if line else ' ' + set_font(r, name=MONO, size=12, color=CODE_FG) + return h + + +def set_notes(slide, spec): + tf = slide.notes_slide.notes_text_frame + tf.clear() + minutes = spec.get('minutes') + segment = spec.get('segment', '') + header = f"⏱ 建议 {minutes} 分钟 · {segment}" if minutes else segment + paras = ([header] if header.strip(' ·') else []) + spec.get('notes', '').strip().split('\n') + first = True + for para in paras: + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + r = p.add_run() + r.text = para + set_font(r, size=12, bold=(first and bool(minutes))) + + +def build_title_slide(prs, spec): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + _, tf = add_textbox(slide, ML, 0.35, W, 0.35) + r = tf.paragraphs[0].add_run() + r.text = spec.get('badge', '') + set_font(r, size=12, color=TEXT_GRAY) + _, tf = add_textbox(slide, ML, 1.5, W, 1.0) + r = tf.paragraphs[0].add_run() + r.text = spec['title'] + set_font(r, size=40, bold=True, color=TEXT_DARK) + _, tf = add_textbox(slide, ML, 2.6, W, 0.6) + r = tf.paragraphs[0].add_run() + r.text = spec.get('subtitle', '') + set_font(r, size=20, color=RGBColor(0x2B, 0x6C, 0xB0)) + top = 3.5 + _, tf = add_textbox(slide, ML, top, W, 2.6) + first = True + for b in spec.get('bullets', []): + p = tf.paragraphs[0] if first else tf.add_paragraph() + first = False + p.space_before = Pt(6) + r = p.add_run() + r.text = '· ' + b + set_font(r, size=16, color=TEXT_DARK) + if spec.get('source'): + _, tf = add_textbox(slide, ML, 6.95, W, 0.4) + r = tf.paragraphs[0].add_run() + r.text = spec['source'] + set_font(r, size=11, color=TEXT_GRAY) + set_notes(slide, spec) + + +def build_content_slide(prs, spec, deck, idx): + slide = prs.slides.add_slide(prs.slide_layouts[6]) + _, tf = add_textbox(slide, ML, 0.18, W, 0.32) + r = tf.paragraphs[0].add_run() + r.text = spec.get('badge', '') + set_font(r, size=11, color=TEXT_GRAY) + _, tf = add_textbox(slide, ML, 0.5, W, 0.75) + r = tf.paragraphs[0].add_run() + r.text = spec['title'] + set_font(r, size=27, bold=True, color=TEXT_DARK) + top = 1.35 + for b in spec.get('bullets', []): + _, tf = add_textbox(slide, ML, top, W, 0.4) + r = tf.paragraphs[0].add_run() + r.text = '· ' + b + set_font(r, size=15, color=TEXT_DARK) + top += wrap_lines(b, 56) * 0.34 + 0.04 + if spec.get('code'): + top += 0.06 + top += add_code(slide, spec['code'], top) + 0.14 + for box in spec.get('boxes', []): + kind, heading, lines = box + top += add_box(slide, kind, heading, lines, top) + 0.14 + if spec.get('source'): + _, tf = add_textbox(slide, ML, 7.02, W, 0.35) + r = tf.paragraphs[0].add_run() + r.text = spec['source'] + set_font(r, size=10.5, color=TEXT_GRAY) + if top > 7.05: + print(f' [overflow] {deck} 第{idx}页「{spec["title"]}」内容高度 {top:.2f}in > 7.05in,请精简') + set_notes(slide, spec) + + +def build_deck(mod_name): + mod = importlib.import_module(mod_name) + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + for i, spec in enumerate(mod.SLIDES, 1): + if spec.get('kind') == 'title': + build_title_slide(prs, spec) + else: + build_content_slide(prs, spec, mod_name, i) + out = os.path.join(OUT_DIR, f'{mod_name}.pptx') + prs.save(out) + total = sum(float(s.get('minutes', 0) or 0) for s in mod.SLIDES) + print(f'{out}: {len(mod.SLIDES)} 页, 备注时长合计 ≈ {total:.0f} 分钟') + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + for n in range(1, 6): + build_deck(f'day{n}') + + +if __name__ == '__main__': + main()