diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..7adc3b6 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line = off + +[*.{yml,yaml}] +indent_style = space +indent_size = 2 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..eb2f69c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: "贡献指南 / Contributing Guide" + url: "https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/blob/main/CONTRIBUTING.md" + about: "查看文章五段结构、代码示例规范、本地预览和提交前检查。" diff --git a/.github/ISSUE_TEMPLATE/content-correction.yml b/.github/ISSUE_TEMPLATE/content-correction.yml new file mode 100644 index 0000000..cdf616f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/content-correction.yml @@ -0,0 +1,66 @@ +name: "内容勘误 / Content Correction" +description: "报告错别字、概念错误、术语不准确、代码讲解问题、专家层源码引用过时。" +title: "[内容勘误] " +labels: ["documentation"] +body: + - type: markdown + attributes: + value: | + 感谢你帮助改进教程内容。请尽量给出具体页面、原文位置和判断依据,这会让维护者更快处理。 + + - type: input + id: location + attributes: + label: "相关页面或文件" + description: "请粘贴文档页面链接,或仓库中的文件路径。" + placeholder: "例如:tutorial/beginner/01-qtbase/01-signal-slot-beginner.md 或在线页面链接" + validations: + required: true + + - type: dropdown + id: correction_type + attributes: + label: "问题类型" + multiple: true + options: + - "错别字 / 表达问题" + - "概念错误" + - "术语不准确" + - "代码讲解不准确" + - "专家层源码引用过时(文件:行号 对不上当前 Qt 源码)" + - "参考链接或引用问题" + - "其他" + validations: + required: true + + - type: textarea + id: current_content + attributes: + label: "当前内容" + description: "请贴出有问题的原文、代码片段,或描述具体位置。" + placeholder: "例如:某节中写到“...”,但这里可能不准确。" + validations: + required: true + + - type: textarea + id: problem + attributes: + label: "问题说明" + description: "请说明为什么这里不准确,或会造成什么误解。" + placeholder: "例如:Qt 6 中该 API 的行为应为 ...,当前说法不准确。" + validations: + required: true + + - type: textarea + id: suggestion + attributes: + label: "建议修改" + description: "如果你已经有建议写法,可以贴在这里。" + placeholder: "建议改为:..." + + - type: textarea + id: references + attributes: + label: "补充材料" + description: "可以附 Qt 官方文档、编译器输出、截图或其他参考资料。" + placeholder: "Qt 官方文档链接、截图、编译输出等。" diff --git a/.github/ISSUE_TEMPLATE/content-proposal.yml b/.github/ISSUE_TEMPLATE/content-proposal.yml new file mode 100644 index 0000000..eeac824 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/content-proposal.yml @@ -0,0 +1,76 @@ +name: "内容提案 / Content Proposal" +description: "建议新增文章、改进现有内容、补充示例或实例库组件。" +title: "[内容提案] " +labels: ["documentation", "enhancement"] +body: + - type: markdown + attributes: + value: | + 这个入口适合讨论“要不要写、写到哪里、写到什么深度”。如果你已经有草稿,也可以在这里先说明主题。 + + - type: dropdown + id: proposal_type + attributes: + label: "提案类型" + multiple: true + options: + - "新增正式教程(入门/进阶/专家)" + - "改进现有文章" + - "新增代码示例(examples/)" + - "新增实例库组件(widget/app/model/industrial)" + - "其他" + validations: + required: true + + - type: textarea + id: summary + attributes: + label: "主题简介" + description: "请用几句话说明你想新增或改进什么。" + placeholder: "例如:希望新增一篇关于 QProperty 绑定的进阶文章。" + validations: + required: true + + - type: textarea + id: audience + attributes: + label: "目标读者" + description: "这篇内容适合哪些读者?" + placeholder: "例如:有 QtWidgets 基础、想理解信号槽底层实现的读者。" + + - type: textarea + id: placement + attributes: + label: "建议放置位置" + description: "如果你知道,可以写对应层级、模块或目录;不确定可以写“不确定”。" + placeholder: "例如:进阶层 QtWidgets / 03-qtwidgets,或实例库 widget/。" + + - type: textarea + id: outline + attributes: + label: "建议结构" + description: "可以简单列出文章大纲、示例安排或核心问题。" + placeholder: | + - 背景和动机 + - 核心概念 + - 示例代码 + - 踩坑预防 + - 参考资料 + + - type: textarea + id: references + attributes: + label: "参考资料" + description: "可以附 Qt 官方文档、书籍、博客、源码、Issue 等。" + placeholder: "链接或书目信息。" + + - type: dropdown + id: submission_plan + attributes: + label: "你是否计划提交 PR" + options: + - "是,我准备投稿" + - "是,但需要先讨论方向" + - "暂时只是建议" + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/example-build-problem.yml b/.github/ISSUE_TEMPLATE/example-build-problem.yml new file mode 100644 index 0000000..7a0faef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/example-build-problem.yml @@ -0,0 +1,81 @@ +name: "示例或构建问题 / Example or Build Problem" +description: "报告代码示例无法编译、CMake 问题、工具链问题。" +title: "[构建问题] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + 感谢反馈代码或构建问题。请提供命令、错误输出和环境信息,方便维护者复现。 + + - type: input + id: target + attributes: + label: "相关示例或文件" + description: "请粘贴代码示例路径、章节链接或文件路径。" + placeholder: "例如:examples/beginner/01-qtbase/02-signal-slot-beginner/ 或 widget/status-led/" + validations: + required: true + + - type: dropdown + id: problem_type + attributes: + label: "问题类型" + multiple: true + options: + - "示例代码无法编译" + - "CMake 配置问题" + - "工具链或编译器兼容问题" + - "实例库(widget/app/model/industrial)问题" + - "其他" + validations: + required: true + + - type: textarea + id: command + attributes: + label: "你执行了什么命令" + description: "请贴出触发问题的命令。" + placeholder: "例如:cmake -B build && cmake --build build" + validations: + required: true + + - type: textarea + id: error + attributes: + label: "错误输出" + description: "请粘贴关键错误信息。长日志可以只贴最相关部分。" + render: shell + validations: + required: true + + - type: textarea + id: environment + attributes: + label: "环境信息" + description: "请填写与你的问题相关的环境。" + value: | + - 操作系统:Windows 10/11 / Linux / WSL2 + - Qt 版本:6.x + - 编译器:MSVC 2019/2022 / MinGW 11.2+ / GCC 11+ + - CMake 版本:≥ 3.26 + - C++ 标准:17 + validations: + required: true + + - type: dropdown + id: pr_willingness + attributes: + label: "你是否愿意提交 PR 修复" + options: + - "愿意" + - "可能可以" + - "暂时不方便" + validations: + required: true + + - type: textarea + id: extra + attributes: + label: "补充信息" + description: "可以附最小复现、相关截图、链接或你已经尝试过的修复方式。" diff --git a/.github/ISSUE_TEMPLATE/site-problem.yml b/.github/ISSUE_TEMPLATE/site-problem.yml new file mode 100644 index 0000000..f3f62cb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/site-problem.yml @@ -0,0 +1,77 @@ +name: "站点问题 / Site Problem" +description: "报告 404、搜索异常、页面显示错误、移动端排版、导航问题。" +title: "[站点问题] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + 感谢反馈站点体验问题。请尽量提供页面链接、复现步骤和截图,这会帮助我们快速定位。 + + - type: input + id: page + attributes: + label: "问题页面" + description: "请粘贴出现问题的页面链接。" + placeholder: "例如:https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeQt/..." + validations: + required: true + + - type: dropdown + id: problem_type + attributes: + label: "问题类型" + multiple: true + options: + - "404 / 链接失效" + - "搜索问题" + - "页面渲染问题" + - "Mermaid / 代码块渲染异常" + - "移动端显示问题" + - "侧边栏 / 下一页导航问题" + - "其他" + validations: + required: true + + - type: textarea + id: actual + attributes: + label: "实际表现" + description: "你看到了什么问题?" + placeholder: "例如:点击某个链接后进入 404;移动端代码块横向溢出。" + validations: + required: true + + - type: textarea + id: expected + attributes: + label: "期望表现" + description: "你认为它应该如何显示或跳转?" + placeholder: "例如:应跳转到对应章节。" + + - type: textarea + id: steps + attributes: + label: "复现步骤" + description: "如果问题可以稳定复现,请写出操作步骤。" + placeholder: | + 1. 打开 ... + 2. 点击 ... + 3. 看到 ... + + - type: textarea + id: environment + attributes: + label: "环境信息" + description: "如果是显示或搜索问题,请填写你使用的设备和浏览器。" + value: | + - 设备:桌面 / 手机 / 平板 + - 浏览器: + - 操作系统: + - 是否使用深色模式: + + - type: textarea + id: screenshots + attributes: + label: "截图" + description: "如果方便,请粘贴截图或录屏。" diff --git a/.github/PULL_REQUEST_TEMPLATE/README.md b/.github/PULL_REQUEST_TEMPLATE/README.md new file mode 100644 index 0000000..57d69f0 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/README.md @@ -0,0 +1,15 @@ +# Pull Request Templates + +本目录提供多个 PR 模板: + +- `bugfix.md`:勘误、站点、示例/实例库代码、构建脚本、专家层源码引用修复。 +- `article-submission.md`:正式教程投稿、实例库投稿。 + +GitHub 多 PR 模板通过 URL 参数选择: + +```text +https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/compare/main...BRANCH?template=bugfix.md +https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/compare/main...BRANCH?template=article-submission.md +``` + +如果直接从 GitHub 默认入口创建 PR 没有选模板,可以手动复制对应模板内容。 diff --git a/.github/PULL_REQUEST_TEMPLATE/article-submission.md b/.github/PULL_REQUEST_TEMPLATE/article-submission.md new file mode 100644 index 0000000..314052b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/article-submission.md @@ -0,0 +1,37 @@ +## 投稿类型 + +- [ ] 正式教程投稿(入门 / 进阶 / 专家) +- [ ] 实例库投稿(widget / app / model / industrial) +- [ ] 现有文章或实例改进 +- [ ] 其他 + +> 最终放置位置(正式教程层级 / 实例库目录)由维护者判断。 + +## 内容说明 + +请简要说明这篇内容写了什么、面向哪些读者,以及为什么适合本项目。 + +## 作者与授权 + +- 作者署名: +- 是否原创: +- 是否允许维护者调整标题、格式、放置位置和部分措辞: +- 外部资料、图片、代码来源: + +## 建议位置 + +- + +## 检查项 + +- [ ] Markdown 可以正常阅读 +- [ ] 引用外部资料时已提供来源 +- [ ] 图片、代码或大段材料来源清楚 +- [ ] 如包含代码,已说明是否测试过 +- [ ] 如包含示例/实例库代码,`cmake -B build && cmake --build build` 通过 +- [ ] 专家层投稿:源码引用已带「文件:行号」对齐当前 Qt 源码 +- [ ] 如申请进入正式教程,已考虑是否需要更新导航或索引 + +## 关联 Issue + +Close # diff --git a/.github/PULL_REQUEST_TEMPLATE/bugfix.md b/.github/PULL_REQUEST_TEMPLATE/bugfix.md new file mode 100644 index 0000000..5b857ca --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/bugfix.md @@ -0,0 +1,34 @@ +## 修复说明 + +请简要说明这个 PR 修复了什么问题,以及为什么需要这个修改。 + +## 修复类型 + +- [ ] 内容勘误 +- [ ] 站点显示或导航问题 +- [ ] 链接或搜索问题 +- [ ] 示例代码问题(examples/) +- [ ] 实例库代码问题(widget/app/model/industrial) +- [ ] 构建、CI 或脚本问题 +- [ ] 专家层源码引用更新(文件:行号 对齐当前 Qt 源码) +- [ ] 其他: 请在这里说明 + +## 影响范围 + +涉及的页面、目录、示例或模块: + +- + +## 验证方式 + +请说明你做过哪些检查。涉及代码的修改,请贴出本地 `cmake -B build && cmake --build build` 的关键输出方便复现。 + +- [ ] Markdown 可以正常阅读 +- [ ] 相关链接有效 +- [ ] 如包含代码,已说明是否测试过 +- [ ] 如涉及示例/实例库,`cmake -B build && cmake --build build` 通过 +- [ ] 如修改正式教程,已考虑是否需要更新导航或索引 + +## 关联 Issue + +Close # diff --git a/.github/workflows/build-check.yml b/.github/workflows/build-check.yml new file mode 100644 index 0000000..1519419 --- /dev/null +++ b/.github/workflows/build-check.yml @@ -0,0 +1,45 @@ +# 合入前检查门:PR 到 main 时先跑一遍完整构建,把"改坏配置/构建到发布才炸"堵在合入前。 +# 与 deploy.yml 共用同一套构建逻辑(install frozen → restore cache → build),只是不部署。 +name: Build Check + +on: + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # 用独立命名空间(check- 前缀),避免和 deploy 的缓存互相覆盖。 + - name: Restore build cache + uses: actions/cache@v4 + with: + path: site/.vitepress/.build-cache + key: check-vp-build-${{ runner.os }}-${{ hashFiles('tutorial/**', 'site/.vitepress/config/**', 'site/.vitepress/plugins/**', 'site/.vitepress/theme/**', 'scripts/build.ts', 'package.json', 'pnpm-lock.yaml') }} + restore-keys: | + check-vp-build-${{ runner.os }}- + + - name: Build + run: pnpm build + env: + NODE_OPTIONS: --max-old-space-size=6144 + BUILD_CONCURRENCY: 8 diff --git a/.github/workflows/build-examples.yml b/.github/workflows/build-examples.yml new file mode 100644 index 0000000..d63bc8a --- /dev/null +++ b/.github/workflows/build-examples.yml @@ -0,0 +1,63 @@ +# 示例编译门:PR 改动示例/实例库代码时,CI 真实编译所有 Qt6 CMake 工程, +# 兑现「克隆即跑」承诺。CI 不安装的小众/不可用模块示例由 build_examples.py 自动排除。 +# +# Qt6 用 jurplel/install-qt-action 从 Qt 官方镜像装(带 cache)—— Ubuntu 24.04 的 apt +# 缺不少 addon 包(core5compat / statemachine 等),装不全; +# install-qt-action 一次性给齐 CI 会编译的 addon 模块。 +name: Build Examples + +on: + pull_request: + branches: [main] + paths: + - 'examples/**' + - 'widget/**' + - 'app/**' + - 'model/**' + - 'industrial/**' + - 'scripts/build_examples.py' + - '.github/workflows/build-examples.yml' + push: + branches: [main] + paths: + - 'examples/**' + - 'widget/**' + - 'app/**' + - 'model/**' + - 'industrial/**' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # 构建工具走 apt(这些 apt 全有);Qt 本体走下面的 install-qt-action。 + - name: Install build tools (cmake / ninja / ccache + GL) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends cmake ninja-build ccache libgl1-mesa-dev + + # 从 Qt 官方镜像装 Qt 6.9.1 + 示例用到的全部 addon 模块。CI 排除的 + # NFC/MQTT/WebEngine/WebChannel/RemoteObjects/SpatialAudio/TextToSpeech/ + # Quick3D-Physics 不装。 + # base(Core/Gui/Widgets/Network/Concurrent/Sql/Xml/OpenGL/OpenGLWidgets/ + # PrintSupport/Svg/SvgWidgets/Qml/Quick/QuickControls2)随主包自带。 + # cache: true 跨 run 缓存 Qt 安装,省去每次 ~1GB 重新下载。 + - name: Install Qt 6.9.1 + addon modules + uses: jurplel/install-qt-action@v4 + with: + version: '6.9.1' + modules: 'qtcharts qtmultimedia qtserialport qtserialbus qtwebsockets qt3d qtquick3d qtshadertools qtconnectivity qtpdf qt5compat qtscxml qthttpserver' + cache: true + + - uses: actions/cache@v4 + with: + path: ~/.cache/ccache + key: ccache-qt-${{ runner.os }}-${{ github.run_id }} + restore-keys: ccache-qt-${{ runner.os }}- + + - name: Build all examples + run: | + export CCACHE_DIR="$HOME/.cache/ccache" + python3 scripts/build_examples.py --workers 6 diff --git a/.github/workflows/content-quality.yml b/.github/workflows/content-quality.yml new file mode 100644 index 0000000..3893215 --- /dev/null +++ b/.github/workflows/content-quality.yml @@ -0,0 +1,26 @@ +# 内容质量检查:PR 改动教程内容时,跑内链可达性检查。 +# 只查内链(相对路径),避免外链(doc.qt.io 等)在 CI 里被限流误报;外链检查走本地手动或未来的定时任务。 +name: Content Quality + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - 'tutorial/**/*.md' + - 'scripts/document/check_links.py' + - '.github/workflows/content-quality.yml' + +jobs: + link-check: + name: Internal Link Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Check internal links + # check_links.py 纯标准库(urllib/ssl/re/argparse),无需 pip install。 + run: python3 scripts/document/check_links.py --internal-only diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a3e08bc..be1c4d9 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -30,10 +30,24 @@ jobs: version: 10 - name: Install dependencies - run: pnpm install + run: pnpm install --frozen-lockfile + + # 复用分卷构建的增量缓存(build.ts 写入 site/.vitepress/.build-cache),二次构建只重建改动卷。 + # cache key 只含真正影响构建产物的路径——tutorial 内容 + 站点配置/插件/主题 + 构建脚本 + 依赖锁。 + - name: Restore build cache + uses: actions/cache@v4 + with: + path: site/.vitepress/.build-cache + key: deploy-vp-build-${{ runner.os }}-${{ hashFiles('tutorial/**', 'site/.vitepress/config/**', 'site/.vitepress/plugins/**', 'site/.vitepress/theme/**', 'scripts/build.ts', 'package.json', 'pnpm-lock.yaml') }} + restore-keys: | + deploy-vp-build-${{ runner.os }}- - name: Build run: pnpm build + env: + # 专家层全量 102 篇构建内存峰值较高,放宽堆上限防 OOM;并发 8 卷。 + NODE_OPTIONS: --max-old-space-size=6144 + BUILD_CONCURRENCY: 8 - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8b4bbbb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,24 @@ +# 文章格式检查。存量违规已清零(曾 122 处,已全部修复),现在强制拦截。 +name: Lint Documentation + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + markdown-lint: + name: Markdown Lint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run markdownlint + uses: DavidAnson/markdownlint-cli2-action@v17 + with: + config: '.markdownlint.json' + globs: | + tutorial/**/*.md + !tutorial/**/index.md diff --git a/.gitignore b/.gitignore index 0bbd44b..93d27c6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ reference/ # Build artifacts build/ **/build/ +**/_build_ci/ # Python cache __pycache__/ @@ -20,4 +21,4 @@ node_modules/ site/.vitepress/dist/ site/.vitepress/cache/ site/.vitepress/.build-cache/ -site/.vitepress/.build-tmp/ \ No newline at end of file +site/.vitepress/.build-tmp/ diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..0fa3072 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,25 @@ +{ + "default": true, + "MD013": false, + "MD024": { + "siblings_only": true + }, + "MD033": false, + "MD041": false, + "MD012": { + "maximum": 3 + }, + "MD004": { + "style": "dash" + }, + "MD007": { + "indent": 2, + "start_indented": false + }, + "MD001": false, + "MD025": false, + "MD035": false, + "MD036": false, + "MD046": false, + "MD060": false +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0f2c6c0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +# 本地提交前自检。安装:pnpm hooks:install(或 bash scripts/setup_precommit.sh) +# 规则与 CI 的 .github/workflows/lint.yml 用同一份 .markdownlint.json,本地和 CI 一致。 +# +# 本批先挂最稳的四类:markdown 文章格式 / C++ 代码格式 / 防大文件漏入 / 基础文件卫生。 +# frontmatter 校验、check_bold、check_links 等待对应脚本落地后在第 2 批补进来。 +repos: + # Markdown 文章格式(规则在 .markdownlint.json) + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.38.0 + hooks: + - id: markdownlint + args: ['--config', '.markdownlint.json'] + files: '^tutorial/.*\.md$' + exclude: '^tutorial/.*\bindex\.md$' + + # C/C++ 源码格式(复用根 .clang-format:LLVM / 4 空格 / 100 列) + # 专家层源码引用核对(本地有 qt_src 才核对,CI/无 qt_src 时脚本自动跳过 exit 0) + - repo: local + hooks: + - id: clang-format + name: clang-format C/C++ sources + entry: clang-format -i + language: system + files: '\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$' + - id: verify-source-refs + name: Verify expert source refs (qt_src) + entry: python3 scripts/document/verify_source_refs.py + language: system + pass_filenames: false + files: '^tutorial/expert/.*\.md$' + + # 基础文件卫生 + 防 5.5G 的 qt_src 误漏进 git 历史 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ['--maxkb=1500'] + - id: end-of-file-fixer + - id: trailing-whitespace + files: '^tutorial/.*\.md$' + - id: check-yaml + files: '^\.github/.*\.ya?ml$' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..badc4b8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,40 @@ +# AGENTS.md + +给**任何 AI 编程助手**(Claude Code / Cursor / Copilot / Codex / Windsurf 等)的项目入口,人也适用。一个 vendor-neutral 文件——所有 agent 都读它。如果你打算用 AI 助手参与 AwesomeQt 的贡献,先读这里。 + +## 这是什么 + +**AwesomeQt** —— 聚焦 C++ / QtWidgets 的 Qt 6 中文深度教程,三层递进(入门 137 ✅ / 进阶 134 ✅ / 专家 2 进行中,规划 102 篇)+ 可复用实例库(`widget/` `app/` `model/` `industrial/`,`AwesomeQt::` 命名空间),VitePress 建站。隶属 Awesome-Embedded-Learning-Studio。不摊 QML 深度 / PySide6。 + +## 目录结构 + +``` +tutorial/{beginner,advanced,expert,engineering}/ 三层教程 + 实战栏目 +examples/{beginner,advanced,expert,experiment}/ 教程配套示例(每目录独立 CMake,约 275 个) +widget/ app/ model/ industrial/ 实例库代码根(AwesomeQt:: 命名空间) +site/.vitepress/config/shared.ts 站点配置单一真相源(改 markdown 插件/head 只改这里) +site/.vitepress/{plugins,theme}/ 自定义 markdown 插件与主题 +scripts/build.ts 分卷并行构建 + 增量缓存 + 搜索索引合并 +scripts/document/check_links.py 死链检查 +qt_src/qt6.9.1/ Qt 6.9.1 源码(gitignore,专家层取证用,不入库) +``` + +## 金科玉律(所有 agent 必读) + +- **源码证据**:专家层任何涉及 Qt 源码的结论,必须带 `qt_src/qt6.9.1` 的「文件:行号」可复现证据,禁止凭记忆断言 Qt 内部实现。断言 Qt 行为前,先编译实测或查 Qt 官方文档(doc.qt.io)并标 Qt 版本(基线 6.9.1)。 +- **踩坑必须写后果**:不只写错误做法,必须写清会造成什么后果(double free / vtable 错误 / 信号不触发 / 时序错乱等)。只写真碰到的坑,不编造。 +- **文档禁止完整可运行代码**:教程只给伪代码和关键片段,完整工程交 `examples/`。每个 `examples/` 工程最少五件套(`widget.h` + `widget.cpp` + `main.cpp` + `CMakeLists.txt` + `.gitignore`),且 `cmake -B build && cmake --build build` 直接成功。 +- **链接必须真实**:不编造 URL,宁可不贴也不瞎写。外部链接指向 Qt 官方文档并标 Qt 版本。 +- **文章五段结构**:前言 / 环境说明 / 核心概念讲解(含穿插随堂测验)/ 踩坑预防 / 练习项目 / 官方文档参考链接。 +- **构建 / 校验**:`pnpm install` → `pnpm dev`(热更新)/ `pnpm build`(分卷并行);示例 `cmake -B build && cmake --build build`。 + +## 你来做什么?(按场景路由) + +| 场景 | 去哪 | +|---|---| +| 贡献文章 / 代码示例 / 实例库 | [CONTRIBUTING.md](CONTRIBUTING.md)(五段结构、五件套、PR 清单) | +| 构建与校验命令 | `pnpm dev` / `pnpm build` / `cmake -B build && cmake --build build` | +| 专家层源码查证 | 本地 `qt_src/qt6.9.1/`(不入库,gitignore) | +| 死链检查 | `python3 scripts/document/check_links.py` | + +> 本文件只描述项目规则与入口,不依赖任何私有配置。专家层进度 2/102(进行中)。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..07ba772 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,134 @@ +# 贡献指南 + +感谢你对 AwesomeQt 的关注!这是一套聚焦 C++ / QtWidgets 的 Qt 6 中文深度教程,隶属 [Awesome-Embedded-Learning-Studio](https://github.com/Awesome-Embedded-Learning-Studio)。欢迎任何形式的贡献:修正错别字、改进代码示例、补充踩坑、完善现有内容、新增章节等。 + +## 快速开始 + +```bash +# 1. Fork 仓库后克隆到本地 +git clone https://github.com/<你的用户名>/Tutorial_AwesomeQt.git +cd Tutorial_AwesomeQt + +# 2. 安装站点依赖 +pnpm install + +# 3.(可选但推荐)安装提交前自检钩子 +pnpm hooks:install + +# 4. 创建特性分支 +git switch -c fix/typo-signal-slot + +# 5. 本地预览(访问 http://localhost:5173/Tutorial_AwesomeQt/) +pnpm dev + +# 6. 提交并推送 +git commit -m "fix: 修正信号槽章节的错别字" +git push origin fix/typo-signal-slot + +# 7. 在 GitHub 上创建 Pull Request +``` + +## 提交前自检 + +本仓库用 [pre-commit](https://pre-commit.com/) 在每次 `git commit` 前自动检查(配置在 `.pre-commit-config.yaml`): + +- **markdownlint**:检查 `tutorial/` 下文章的 markdown 格式(规则在 `.markdownlint.json`) +- **clang-format**:对暂存的 C/C++ 源文件执行格式化(复用根 `.clang-format`) +- **check-added-large-files**:防止大文件(>1.5MB)误入 git 历史 +- **基础卫生**:末尾换行、尾随空格、YAML 语法 + +安装钩子:`pnpm hooks:install`(或 `bash scripts/setup_precommit.sh`)。依赖本机有 `pre-commit`、`python3`、`clang-format`。如果钩子修改了文件,它会中止本次提交,请检查改动后重新 `git add` 再提交。紧急情况可 `git commit --no-verify` 跳过,但不要在 PR 中长期绕过。日常可用 `pre-commit run --all-files` 对全仓跑一遍。 + +## 文章规范 + +### 文章结构 + +每篇教程统一五段(随堂测验穿插在「核心概念讲解」行文中,不单列章节): + +```markdown +--- +title: "1.1 信号与槽" +description: "一句话描述" +--- + +# 现代Qt开发教程(新手篇)1.1——信号与槽 + +## 1. 前言 / [为什么需要 XXX] +## 2. 环境说明 +## 3. 核心概念讲解 +## 4. 踩坑预防 ← 必须写清后果,不只写错误做法 +## 5. 练习项目 ← 入门/进阶给提示不给完整答案,专家层可给部分框架 +## 6. 官方文档参考链接 ← 链接必须真实可达,不编造 URL + +--- +[结语段落,自然收尾] +``` + +### Frontmatter 元数据 + +每篇文章开头必须包含: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `title` | 是 | 文章标题(侧边栏自动扫描依赖此字段) | +| `description` | 是 | 一句话描述(用于站点搜索与社交分享) | + +> 其余字段(如 chapter/order/tags)尚未启用,请勿自行添加。 + +### 文件命名 + +`序号-知识点名-层级.md`,例如 `01-signal-slot-beginner.md` / `01-signal-slot-advanced.md` / `01-signal-slot-expert.md`。专家层专属章节直接命名(如 `17-moc-internals-expert.md`),无需在其他层级占位。 + +### 写作风格 + +1. 语言清晰简洁的中文,首次出现的技术术语可附英文原文 +2. 代码注释用中文 +3. 标题层级不超过 4 级 +4. **专家层**:每条涉及 Qt 源码的结论必须带「文件:行号」可复现证据(指向 `qt_src/qt6.9.1`);断言 Qt 行为前先编译实测或查 Qt 官方文档,并标 Qt 版本(基线 6.9.1) +5. **踩坑**:只写真碰到的坑,不编造;每条必须写清后果(崩溃 / 内存泄漏 / 信号不触发 / 时序错乱等) + +## 代码示例规范 + +`examples/` 下每个示例最少五件套,且 `cmake -B build && cmake --build build` 必须直接成功: + +``` +01-signal-slot-beginner/ +├── widget.h +├── widget.cpp +├── main.cpp +├── CMakeLists.txt +└── .gitignore +``` + +- **C++17**,遵循根 `.clang-format`(LLVM / 4 空格 / 100 列) +- 一个示例只展示一个核心知识点,**禁止 TODO 占位**和无关代码 +- `find_package(Qt6 REQUIRED COMPONENTS ...)` 指明用到的 Qt 模块 +- 实例库(`widget/` `app/` `model/` `industrial/`)统一用 `AwesomeQt::` 命名空间 + +## 本地预览 + +```bash +pnpm dev # 热更新开发服务器 +pnpm build # 生产构建(分卷并行,与部署一致) +pnpm preview # 预览生产产物 +``` + +## PR 检查清单 + +提交前请确认: + +- [ ] frontmatter 的 `title` / `description` 完整 +- [ ] 文章符合五段结构,踩坑写了后果 +- [ ] 如涉及示例/实例库,`cmake -B build && cmake --build build` 通过 +- [ ] 专家层涉及源码处带「文件:行号」证据 +- [ ] 内部链接有效,外部链接指向 Qt 官方文档并标版本 +- [ ] `pre-commit run` 无报错 + +## 行为准则 + +请尊重所有贡献者,给出建设性反馈,专注对项目最有利的事。 + +--- + +- 使用 AI 编程助手参与贡献?请先读 [AGENTS.md](AGENTS.md)。 +- 报错或提建议?请用 [Issue 模板](https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/issues/new/choose)。 diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..8955b0c --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,31 @@ +# 许可声明 / Licensing + +本仓库包含三类内容,分别适用不同许可。 + +## 1. 示例与实例库代码 —— MIT + +`examples/`、`widget/`、`app/`、`model/`、`industrial/`、`scripts/` 下的源代码遵循 +[MIT License](LICENSE)(仓库根 LICENSE 文件)。可自由使用、修改、分发,附带版权声明即可。 + +## 2. 教程文档 —— CC BY-SA 4.0 + +`tutorial/` 下的教程正文遵循 +[Creative Commons Attribution-ShareAlike 4.0](https://creativecommons.org/licenses/by-sa/4.0/deed.zh-hans): + +- ✅ 可分享、改编(含商用) +- ⚠️ 须署名(注明出处),衍生作品须以相同许可发布(ShareAlike) + +> 如希望更宽松(仅署名、不要求相同许可),可改用 CC BY 4.0;如希望全仓统一 MIT, +> 也可去掉本节。CC BY-SA 4.0 是教程类内容的常见选择。 + +## 3. 专家层引用的 Qt 源码 —— The Qt Company 许可 + +专家层(`tutorial/expert/`)在讲解中会引用 Qt 源码片段并标注「文件:行号」。**Qt 源码本身** +遵循 [The Qt Company 的 LGPL v3 / 商用双授权](https://www.qt.io/licensing/);本仓库对这些片段 +的引用属教学性评注,不重新声明 Qt 源码的许可。读者若使用 Qt 源码,请遵守 Qt 自身条款。 + +## 第三方资源 + +- `reference/`(本地参考,不入库):第三方 Qt 项目,各保留原作者许可,不随本仓库分发。致谢见 [README](README.md)。 +- `third_party/googletest`(v1.17.0)、`third_party/benchmark`(v1.9.5):以 git submodule 引入,遵循各自的开源许可。 +- `qt_src/qt6.9.1/`:Qt 6.9.1 源码,本地取证用,不入库,遵循 The Qt Company 许可。 diff --git a/README.md b/README.md index 4b974a2..452b1e7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ -# AwesomeQt — 陪你从第一行代码到读懂源码 +# AwesomeQt — Qt 6 中文深度教程 -嘿!这里是Awesome Embedded Studio!我相信大家第一次接触 Qt 的时候,对着 `QObject::connect` 的四个参数发呆了整整一下午。后来又因为忘记加 `Q_OBJECT` 宏,收获了一个莫名其妙的 vtable 错误。再后来,在信号槽的跨线程调用上翻车,在对象树的内存管理上踩雷,在 MOC 生成的代码里迷失方向…… - -笔者就是这样过来的——有时候博客不靠谱,对着Qt5看着Qt6代码抓耳挠腮,想理解底层原理发现要不然过时了,要不然看不懂。所以——这是一份会陪着你走完整条路的教程——从第一个 `QApplication` 到读懂 MOC 生成的那一刻。我们会吐槽,会叹气,会一起熬夜调试,但你绝对不会断层。 - -希望这里的内容能让你Happy Qt Coding! +> 一套聚焦 C++ / QtWidgets 的 Qt 6 中文深度教程:三层递进(入门 → 进阶 → 读源码)+ 可复用实例库,把「学会 Qt」送到「做出东西」。隶属 [Awesome-Embedded-Learning-Studio](https://github.com/Awesome-Embedded-Learning-Studio)。 --- @@ -200,15 +196,19 @@ cd Tutorial_AwesomeQt ## 贡献与反馈 -这个教程持续更新中。 +欢迎参与 AwesomeQt 的建设:修正错别字、改进示例、补充踩坑、新增章节都可以。 + +- **贡献前**:请先读 [贡献指南](CONTRIBUTING.md)(文章五段结构、代码五件套、本地预览、提交前检查)。 +- **报错或建议**:用 [Issue 模板](https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/issues/new/choose)(勘误 / 示例构建 / 站点问题 / 内容提案)。 +- **用 AI 编程助手参与**:先读 [AGENTS.md](AGENTS.md)。 + +如果你发现错误内容、失效链接、可改进的表达,或想补充知识点,欢迎提 Issue 或 PR。 + +--- -如果你发现: -- 错误或不准确的内容 -- 链接失效 -- 可以改进的表达方式 -- 想要补充的知识点 +## 许可 -欢迎提交 Issue 或 Pull Request。 +本仓库内容分档授权:示例与实例库代码(`examples/`、`widget/`、`app/`、`model/`、`industrial/`)为 [MIT](LICENSE);教程文档(`tutorial/`)为 [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/deed.zh-hans);专家层引用的 Qt 源码遵循 [The Qt Company 自身许可](https://www.qt.io/licensing/)。详见 [NOTICE.md](NOTICE.md)。 --- @@ -220,4 +220,4 @@ cd Tutorial_AwesomeQt --- -**现在你已经知道这是什么了,准备好了吗?从 [教程索引](tutorial/index.md) 开始你的 Qt 之旅吧!** +**下一步:** 从 [教程索引](tutorial/index.md) 选择适合你的起点,或查看 [更新日志](changelogs/) 了解最新进展。问题与建议欢迎提 [Issue](https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/issues)。 diff --git a/examples/advanced/06-qml/06-qml-model-view-advanced/sort_filter_model.cpp b/examples/advanced/06-qml/06-qml-model-view-advanced/sort_filter_model.cpp index 01ee1eb..b417aed 100644 --- a/examples/advanced/06-qml/06-qml-model-view-advanced/sort_filter_model.cpp +++ b/examples/advanced/06-qml/06-qml-model-view-advanced/sort_filter_model.cpp @@ -6,46 +6,39 @@ #include "contact_model.h" #include +#include -SortFilterModel::SortFilterModel(QObject* parent) - : QSortFilterProxyModel(parent) -{ +SortFilterModel::SortFilterModel(QObject* parent) : QSortFilterProxyModel(parent) { // Re-sort and re-filter whenever source data or filter parameters change setDynamicSortFilter(true); } -QString SortFilterModel::filterString() const -{ +QString SortFilterModel::filterString() const { return m_filterString; } -void SortFilterModel::setFilterString(const QString& filter) -{ - if (m_filterString == filter) - { +void SortFilterModel::setFilterString(const QString& filter) { + if (m_filterString == filter) { return; } m_filterString = filter; - // Modern Qt 6 replacement for the deprecated invalidateFilter() +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) beginFilterChange(); endFilterChange(); +#else + invalidateFilter(); +#endif emit filterStringChanged(); } -bool SortFilterModel::filterAcceptsRow(int sourceRow, - const QModelIndex& sourceParent) const -{ +bool SortFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const { // An empty filter string means "show everything" - if (m_filterString.isEmpty()) - { + if (m_filterString.isEmpty()) { return true; } - const QModelIndex index = - sourceModel()->index(sourceRow, 0, sourceParent); - const QString name = - index.data(static_cast(ContactModel::Role::kNameRole)) - .toString(); + const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); + const QString name = index.data(static_cast(ContactModel::Role::kNameRole)).toString(); // Case-insensitive substring match on the contact name return name.contains(m_filterString, Qt::CaseInsensitive); diff --git a/package.json b/package.json index 2960d35..6cd5da5 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "awesomeqt-vitepress", - "version": "0.0.1", + "version": "0.2.0", "private": true, "type": "module", "scripts": { "dev": "vitepress dev site", "build": "NODE_OPTIONS='--disable-warning=DEP0205' pnpm exec tsx scripts/build.ts", "build:single": "vitepress build site", - "preview": "vitepress preview site" + "preview": "vitepress preview site", + "check:links": "python3 scripts/document/check_links.py", + "hooks:install": "bash scripts/setup_precommit.sh" }, "devDependencies": { "@types/node": "^25.6.2", diff --git a/scripts/build.ts b/scripts/build.ts index a011301..41ff6a1 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -131,54 +131,20 @@ function generateVolumeConfig(vol: Volume, absSiteDir: string, absSrcDir: string const relSrc = relative(absSiteDir, absSrcDir) const relOut = relative(absSiteDir, join(BUILD_TMP, 'output', vol.name)) const vpDir = join(absSiteDir, '.vitepress') - const relPlugins = relative(vpDir, join(MAIN_VP, 'plugins')).replace(/\\/g, '/') + const relShared = relative(vpDir, join(MAIN_VP, 'config', 'shared')).replace(/\\/g, '/') + // 共享配置(base / markdown 插件 / head / vite / vue / 主题基础项)统一来自 config/shared.ts。 + // 这里只挂分卷专属字段(srcDir / outDir / ignoreDeadLinks)。改 markdown 插件只改 shared.ts 一处。 return `import { defineConfig } from 'vitepress' -import { cppTemplateEscapePlugin } from '${relPlugins}/escape-cpp-templates' -import { mermaidPlugin } from '${relPlugins}/mermaid-plugin' -import { viteCppEscape } from '${relPlugins}/vite-escape-cpp' +import { sharedBase, sharedMarkdown, sharedThemeBase } from '${relShared}' export default defineConfig({ srcDir: '${relSrc.replace(/\\/g, '/')}', outDir: '${relOut.replace(/\\/g, '/')}', ignoreDeadLinks: true, - title: 'AwesomeQt 教程', - description: '系统化的现代 Qt 6 教程 — 从基础入门到源码解析', - lang: 'zh-CN', - base: '/Tutorial_AwesomeQt/', - cleanUrls: true, - lastUpdated: true, - - vite: { - build: { chunkSizeWarningLimit: 5000 }, - plugins: [viteCppEscape()], - }, - - vue: { - template: { - compilerOptions: { - isCustomElement: (tag) => tag.includes('-') || tag.includes('.'), - }, - }, - }, - - head: [['link', { rel: 'icon', href: '/Tutorial_AwesomeQt/favicon.ico' }]], - - markdown: { - lineNumbers: true, - theme: { light: 'github-light', dark: 'github-dark' }, - config(md) { cppTemplateEscapePlugin(md); md.use(mermaidPlugin) }, - }, - - themeConfig: { - search: { provider: 'local' }, - editLink: { - pattern: 'https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/edit/main/tutorial/:path', - text: '在 GitHub 上编辑此页', - }, - footer: { message: '基于 VitePress 构建', copyright: 'Copyright 2025-2026 Charliechen' }, - socialLinks: [{ icon: 'github', link: 'https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt' }], - }, + ...sharedBase, + markdown: sharedMarkdown, + themeConfig: sharedThemeBase, }) ` } @@ -189,58 +155,20 @@ function generateRootConfig(absSiteDir: string, absSrcDir: string): string { const vpDir = join(absSiteDir, '.vitepress') const relNav = relative(vpDir, join(MAIN_VP, 'config', 'nav')).replace(/\\/g, '/') const relSidebar = relative(vpDir, join(MAIN_VP, 'config', 'sidebar')).replace(/\\/g, '/') - const relPlugins = relative(vpDir, join(MAIN_VP, 'plugins')).replace(/\\/g, '/') + const relShared = relative(vpDir, join(MAIN_VP, 'config', 'shared')).replace(/\\/g, '/') return `import { defineConfig } from 'vitepress' import { navZh } from '${relNav}' import { buildSidebar } from '${relSidebar}' -import { cppTemplateEscapePlugin } from '${relPlugins}/escape-cpp-templates' -import { mermaidPlugin } from '${relPlugins}/mermaid-plugin' -import { viteCppEscape } from '${relPlugins}/vite-escape-cpp' +import { sharedBase, sharedMarkdown, sharedThemeBase } from '${relShared}' export default defineConfig({ srcDir: '${relSrc.replace(/\\/g, '/')}', outDir: '${relOut.replace(/\\/g, '/')}', ignoreDeadLinks: true, - title: 'AwesomeQt 教程', - description: '系统化的现代 Qt 6 教程 — 从基础入门到源码解析', - lang: 'zh-CN', - base: '/Tutorial_AwesomeQt/', - cleanUrls: true, - lastUpdated: true, - - vite: { - build: { chunkSizeWarningLimit: 5000 }, - plugins: [viteCppEscape()], - }, - - vue: { - template: { - compilerOptions: { - isCustomElement: (tag) => tag.includes('-') || tag.includes('.'), - }, - }, - }, - - head: [['link', { rel: 'icon', href: '/Tutorial_AwesomeQt/favicon.ico' }]], - - markdown: { - lineNumbers: true, - theme: { light: 'github-light', dark: 'github-dark' }, - config(md) { cppTemplateEscapePlugin(md); md.use(mermaidPlugin) }, - }, - - themeConfig: { - nav: navZh, - sidebar: buildSidebar(), - search: { provider: 'local' }, - editLink: { - pattern: 'https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/edit/main/tutorial/:path', - text: '在 GitHub 上编辑此页', - }, - footer: { message: '基于 VitePress 构建', copyright: 'Copyright 2025-2026 Charliechen' }, - socialLinks: [{ icon: 'github', link: 'https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt' }], - }, + ...sharedBase, + markdown: sharedMarkdown, + themeConfig: { ...sharedThemeBase, nav: navZh, sidebar: buildSidebar() }, }) ` } @@ -477,6 +405,37 @@ async function mergeSearchIndexes(outputDirs: string[], finalDist: string) { log(` ✓ 1 canonical + ${allTargets.length - 1} stubs (saved ${savedMB} MB)`) } +// ── Sitemap ──────────────────────────────────────────────── +// 多卷构建下 VitePress 内置 sitemap 会按卷碎片(11 份各覆盖一卷),所以扫 dist 全部 .html +// 自己生成一份完整的 sitemap.xml。cleanUrls:index.html → 目录、foo.html → foo。 +function generateSitemap(distDir: string) { + const SITE = 'https://awesome-embedded-learning-studio.github.io' + const BASE = '/Tutorial_AwesomeQt/' + const htmlFiles: string[] = [] + function walk(d: string) { + for (const e of readdirSync(d, { withFileTypes: true })) { + const full = join(d, e.name) + if (e.isDirectory()) walk(full) + else if (e.name.endsWith('.html') && e.name !== '404.html') htmlFiles.push(full) + } + } + walk(distDir) + const urls = htmlFiles.map(f => { + let rel = relative(distDir, f).replace(/\\/g, '/') + if (rel === 'index.html') rel = '' + else if (rel.endsWith('/index.html')) rel = rel.slice(0, -'index.html'.length) + else rel = rel.replace(/\.html$/, '') + return `${SITE}${BASE}${rel}` + }).sort() + const xml = + '\n' + + '\n' + + urls.map(u => ` ${u}`).join('\n') + '\n' + + '\n' + writeFileSync(join(distDir, 'sitemap.xml'), xml) + log(` sitemap.xml generated: ${urls.length} URLs`) +} + // ── Main ──────────────────────────────────────────────────── async function main() { @@ -512,6 +471,14 @@ async function main() { } } + // 拷公共静态资源(favicon 等)。VitePress 的 public 目录是 /public;多卷构建里 + // 根构建的 srcDir=rootSrcDir,把 tutorial/public 复制进来,根产物就带上 favicon,合并进 dist + // 后全站可访问 /Tutorial_AwesomeQt/favicon.ico(dev 与 build:single 则原生认 tutorial/public)。 + const publicSrc = join(DOCUMENTS, 'public') + if (existsSync(publicSrc)) { + cpSync(publicSrc, join(rootSrcDir, 'public'), { recursive: true }) + } + const rootTmpSite = join(BUILD_TMP, 'site-root') mkdirSync(join(rootTmpSite, '.vitepress'), { recursive: true }) writeFileSync(join(rootTmpSite, '.vitepress', 'config.ts'), generateRootConfig(rootTmpSite, rootSrcDir)) @@ -557,6 +524,9 @@ async function main() { // ── Step 3.5: Unify hash maps and site data ───────────── unifyCrossVolumeData(DIST_FINAL) + // ── Step 3.6: Generate sitemap (多卷合并后统一生成) ───── + generateSitemap(DIST_FINAL) + // ── Step 4: Finalize ──────────────────────────────────── logStep('Step 4/4: Finalizing') rmSync(BUILD_TMP, { recursive: true }) diff --git a/scripts/build_examples.py b/scripts/build_examples.py new file mode 100644 index 0000000..72fccad --- /dev/null +++ b/scripts/build_examples.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""批量编译 examples/ + 实例库根 的 Qt6 CMake 工程,保证「克隆即跑」不退化。 + +发现策略: +- examples/ 下只编译**叶子工程**(CMakeLists 不含 add_subdirectory)。每个叶子按 + example_style 规范自洽(自带 AUTOMOC + find_package),可独立 `cmake -B build`。 + 这天然跳过所有聚合器(beginner 顶层根、各模块聚合器、05-other-modules 聚合器)。 +- widget/app/model/industrial 是 root-owns-config,编译各自的**根 CMakeLists**。 +- CI 不安装的小众/不可用模块示例(NFC/MQTT/SCXML/Quick3D-Physics/WebChannel/ + WebEngine/RemoteObjects/SpatialAudio/TextToSpeech)从发现中排除。 + +用法: + python3 scripts/build_examples.py # 全量 + python3 scripts/build_examples.py --root examples/beginner # 分层放量 + python3 scripts/build_examples.py --dry-run # 只列出编译单元,不编译 +""" +import argparse +import shutil +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXAMPLES_ROOT = REPO_ROOT / "examples" +LIB_ROOTS = ["widget", "app", "model", "industrial"] + +# CI 不安装的小众/不可用模块(目录名片段匹配,大小写不敏感)——从发现中排除 +CI_EXCLUDE_FRAGMENTS = ( + "qtnfc", "mqtt", "qtscxml", "qtquick3d-physics", "qtwebchannel", + "qtwebengine", "qtremoteobjects", "qtspatial-audio", "qttexttospeech", +) + +RED, GREEN, YELLOW, CYAN, RESET = "\033[31m", "\033[32m", "\033[33m", "\033[36m", "\033[0m" + + +def is_leaf_cmake(cmake_path: Path) -> bool: + text = cmake_path.read_text(encoding="utf-8", errors="replace") + return "add_subdirectory" not in text + + +def is_ci_excluded(unit_dir: Path) -> bool: + name = unit_dir.name.lower() + return any(frag in name for frag in CI_EXCLUDE_FRAGMENTS) + + +def tail_process_output(result: subprocess.CompletedProcess[str], limit: int = 3000) -> str: + output = "\n".join(part for part in (result.stdout, result.stderr) if part) + return output[-limit:] + + +def discover_build_units() -> list[Path]: + units: list[Path] = [] + if EXAMPLES_ROOT.is_dir(): + for cmake in sorted(EXAMPLES_ROOT.rglob("CMakeLists.txt")): + lower_parts = {p.lower() for p in cmake.parts} + if lower_parts & {"build", "_build_ci", ".cache", "node_modules"}: + continue + if not is_leaf_cmake(cmake): + continue + if is_ci_excluded(cmake.parent): + continue + units.append(cmake.parent) + for lib in LIB_ROOTS: + root_cmake = REPO_ROOT / lib / "CMakeLists.txt" + if root_cmake.exists() and not is_ci_excluded(root_cmake.parent): + units.append(root_cmake.parent) + return units + + +def build_one(unit: Path, use_ccache: bool) -> tuple[Path, bool, str, float]: + build_dir = unit / "_build_ci" + if build_dir.exists(): + shutil.rmtree(build_dir) + configure = ["cmake", "-S", str(unit), "-B", str(build_dir), "-G", "Ninja"] + if use_ccache: + configure += ["-DCMAKE_CXX_COMPILER_LAUNCHER=ccache"] + t0 = time.time() + cfg = subprocess.run(configure, capture_output=True, text=True) + if cfg.returncode != 0: + return unit, False, f"configure failed:\n{tail_process_output(cfg)}", time.time() - t0 + bld = subprocess.run(["cmake", "--build", str(build_dir)], capture_output=True, text=True) + if bld.returncode != 0: + return unit, False, f"build failed:\n{tail_process_output(bld)}", time.time() - t0 + # 清理产物(_build_ci 已在 .gitignore) + shutil.rmtree(build_dir, ignore_errors=True) + return unit, True, "", time.time() - t0 + + +def main(): + ap = argparse.ArgumentParser(description="批量编译 examples/ + 实例库 Qt6 CMake 工程") + ap.add_argument("--workers", type=int, default=8, help="并行编译数(默认 8)") + ap.add_argument("--root", help="只编译匹配此前缀的工程(相对仓库根,分层放量用)") + ap.add_argument("--no-ccache", action="store_true", help="禁用 ccache") + ap.add_argument("--dry-run", action="store_true", help="只列出编译单元,不编译") + args = ap.parse_args() + + units = discover_build_units() + if args.root: + units = [u for u in units if str(u.relative_to(REPO_ROOT)).startswith(args.root)] + + print(f"{CYAN}build_examples{RESET}: 发现 {GREEN}{len(units)}{RESET} 个编译单元") + if args.dry_run: + for u in units: + print(f" · {u.relative_to(REPO_ROOT)}") + # dry-run 只负责列出实际进入 CI 构建队列的单元 + return + + if not units: + print(f"{YELLOW} 无可编译工程{RESET}") + sys.exit(0) + + use_ccache = not args.no_ccache and shutil.which("ccache") is not None + if use_ccache: + print(f" ccache: {GREEN}on{RESET} ({shutil.which('ccache')})") + else: + print(f" {YELLOW}ccache: off(未安装或 --no-ccache){RESET}") + + ok, failed = 0, [] + t0 = time.time() + with ThreadPoolExecutor(max_workers=args.workers) as pool: + futures = {pool.submit(build_one, u, use_ccache): u for u in units} + for i, fut in enumerate(as_completed(futures), 1): + unit, success, err, elapsed = fut.result() + rel = unit.relative_to(REPO_ROOT) + if success: + ok += 1 + print(f" {GREEN}✓{RESET} [{i}/{len(units)}] {rel} ({elapsed:.1f}s)") + else: + failed.append((unit, err)) + print(f" {RED}✗{RESET} [{i}/{len(units)}] {rel}") + + total_t = time.time() - t0 + print(f"\n{CYAN}{'=' * 50}{RESET}") + print(f" 总数 : {len(units)}") + print(f" {GREEN}通过 : {ok}{RESET}") + print(f" {RED}失败 : {len(failed)}{RESET}") + print(f" 耗时 : {total_t:.1f}s") + + if failed: + print(f"\n{RED}失败详情:{RESET}") + for unit, err in failed: + print(f" {RED}✗{RESET} {unit.relative_to(REPO_ROOT)}") + for line in err.strip().splitlines()[:12]: + print(f" {line}") + sys.exit(1) + print(f"\n{GREEN}全部编译通过!{RESET}") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/document/check_links.py b/scripts/document/check_links.py index 56b0843..628344f 100755 --- a/scripts/document/check_links.py +++ b/scripts/document/check_links.py @@ -124,6 +124,11 @@ def main(): ) parser.add_argument("--timeout", type=int, default=10, help="HTTP timeout in seconds (default: 10)") parser.add_argument("--workers", type=int, default=8, help="Concurrent HTTP workers (default: 8)") + parser.add_argument( + "--internal-only", + action="store_true", + help="只验证相对路径(内链),跳过外部 URL 检查。CI 用,避免外链限流误报。", + ) args = parser.parse_args() root = Path(args.path) @@ -152,6 +157,10 @@ def main(): print(f" Relative paths : {len(relative)}") print() + if args.internal_only: + print(f"{YELLOW} (--internal-only: 跳过 {len(external)} 个外链检查,只验证相对路径){RESET}") + external = [] + failures: list[tuple[Path, str, str, str]] = [] # --- Check relative paths --- diff --git a/scripts/document/verify_source_refs.py b/scripts/document/verify_source_refs.py new file mode 100644 index 0000000..5927d6a --- /dev/null +++ b/scripts/document/verify_source_refs.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""核对专家层文章里的 Qt 源码引用是否真实存在。 + +专家层文章用 `basename.ext:行号`(如 qstring.h:1340、qstring.h:1340-1341)引用 Qt 源码。 +本脚本: + 1. 解析每篇专家文章开头的「源码文件表」,建立 basename → qt_src 完整路径 的映射; + 2. 扫描正文里的 `basename.ext:行号` / `basename.ext:起-止` 引用; + 3. 去 qt_src/qt6.9.1 核对那一行真实存在、不是空行。 + +qt_src 不在仓库里(5.5G,gitignore),所以: + - 本地有 qt_src 时:核对,发现「引用的行不存在 / 越界 / 空行 / basename 未登记」报错 exit 1; + - 没有 qt_src 时(CI 或贡献者未下载):打印跳过提示 exit 0(不阻塞)。 +""" +import re +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +QT_SRC = REPO_ROOT / "qt_src" / "qt6.9.1" +EXPERT_DIR = REPO_ROOT / "tutorial" / "expert" + +# 源码文件表里的完整路径:qt_src/qt6.9.1/<...>/basename.ext +SRC_PATH_RE = re.compile(r"qt_src/qt6\.9\.1(/[^\s|`]+?\.(?:cpp|h|hpp|cc|cxx))") +# 正文引用:basename.ext:行号 或 basename.ext:起-止。前面不能是 / 或单词字符(避开路径/URL)。 +CITE_RE = re.compile(r"(? str: + """剔除 ``` 围栏代码块,只留散文(表格与引用保留)。""" + return re.sub(r"```.*?```", "", text, flags=re.DOTALL) + + +def parse_source_map(article_text: str) -> dict[str, list[str]]: + """从源码文件表提取 basename → [qt_src 下的完整相对路径列表]。""" + mapping: dict[str, list[str]] = {} + for m in SRC_PATH_RE.finditer(article_text): + rel = m.group(1).lstrip("/") + mapping.setdefault(Path(rel).name, []).append(rel) + return mapping + + +def verify_article(article: Path, qt_src: Path) -> list[tuple[str, str]]: + problems = [] + text = article.read_text(encoding="utf-8", errors="replace") + src_map = parse_source_map(text) + if not src_map: + return [] # 没有源码表,不是源码拆解篇,跳过 + prose = strip_fenced(text) + for m in CITE_RE.finditer(prose): + basename, start, end = m.group(1), int(m.group(2)), m.group(3) + cite = f"{basename}:{start}" + (f"-{end}" if end else "") + candidates = src_map.get(basename) + if not candidates: + problems.append((cite, f"basename {basename} 未在本篇源码表里登记")) + continue + end_line = int(end) if end else start + ok, last_err = False, "" + for rel in candidates: + f = qt_src / rel + if not f.exists(): + last_err = f"文件不存在: qt_src/qt6.9.1/{rel}" + continue + lines = f.read_text(encoding="utf-8", errors="replace").splitlines() + if start > len(lines) or end_line > len(lines): + last_err = f"行号越界(文件共 {len(lines)} 行): {rel}:{start}" + continue + if any(lines[i - 1].strip() for i in range(start, end_line + 1)): + ok = True + break + last_err = f"第 {start}{'-'+str(end_line) if end else ''} 行是空行: {rel}" + if not ok: + problems.append((cite, last_err)) + return problems + + +def main(): + if not QT_SRC.is_dir(): + print(f"{YELLOW}verify_source_refs: 未找到 {QT_SRC}(qt_src 未下载或在 CI 中),跳过源码引用核对。{RESET}") + sys.exit(0) + if not EXPERT_DIR.is_dir(): + print("verify_source_refs: 无 tutorial/expert 目录,跳过。") + sys.exit(0) + + articles = [ + a for a in sorted(EXPERT_DIR.rglob("*.md")) + if a.name != "index.md" and "code-index" not in a.parts + ] + all_problems, checked = [], 0 + for a in articles: + if "qt_src" not in a.read_text(encoding="utf-8", errors="replace"): + continue + checked += 1 + all_problems.extend((str(a.relative_to(REPO_ROOT)), c, e) for c, e in verify_article(a, QT_SRC)) + + if all_problems: + print(f"\n{RED}✗ 发现 {len(all_problems)} 处源码引用问题:{RESET}") + for art, cite, err in all_problems: + print(f" {RED}✗{RESET} {art}: {cite} — {err}") + sys.exit(1) + print(f"{GREEN}verify_source_refs: ✓ {checked} 篇专家层文章源码引用全部核对通过。{RESET}") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/scripts/setup_precommit.sh b/scripts/setup_precommit.sh new file mode 100644 index 0000000..715b91f --- /dev/null +++ b/scripts/setup_precommit.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# 一键安装本地提交前自检钩子。用法:pnpm hooks:install(或 bash scripts/setup_precommit.sh) +# 会清理可能存在的 core.hooksPath 冲突,再 pre-commit install。 +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "==============================" +echo " Pre-commit Hook Setup" +echo "==============================" +echo "" + +if ! git -C "$PROJECT_ROOT" rev-parse --git-dir >/dev/null 2>&1; then + echo -e "${RED}ERROR: 当前不在 Git 仓库里。${NC}" + exit 1 +fi + +if ! command -v pre-commit >/dev/null 2>&1; then + echo -e "${RED}ERROR: 需要先安装 pre-commit。${NC}" + echo "" + echo "例如:" + echo " pipx install pre-commit" + echo " # 或:python3 -m pip install --user pre-commit" + exit 1 +fi + +cd "$PROJECT_ROOT" + +# 清掉可能存在的 core.hooksPath,让 .git/hooks/pre-commit 生效 +current_hooks_path="$(git -C "$PROJECT_ROOT" config --get core.hooksPath || true)" +if [ -n "$current_hooks_path" ]; then + echo -e "${YELLOW}清理 core.hooksPath=$current_hooks_path,改用 .git/hooks/pre-commit。${NC}" + git -C "$PROJECT_ROOT" config --unset core.hooksPath +fi + +pre-commit install --config "$PROJECT_ROOT/.pre-commit-config.yaml" + +echo "" +echo -e "${GREEN}已安装 pre-commit 钩子。${NC}" +echo "" + +# 检查钩子用到的外部工具 +missing_tools=() +for tool in python3 clang-format; do + if ! command -v "$tool" >/dev/null 2>&1; then + missing_tools+=("$tool") + fi +done + +if [ "${#missing_tools[@]}" -gt 0 ]; then + echo -e "${YELLOW}缺少钩子用到的工具:${NC}" + printf ' - %s\n' "${missing_tools[@]}" + echo "" + echo "提交对应类型文件前请先装好。" +else + echo -e "${GREEN}检测到钩子工具:${NC}" + echo " - python3: $(python3 --version 2>&1)" + echo " - clang-format: $(clang-format --version 2>&1)" +fi + +echo "" +echo "每次提交前会跑:" +echo " - markdownlint(文章格式,规则 .markdownlint.json)" +echo " - clang-format(C/C++ 源码格式)" +echo " - check-added-large-files / end-of-file-fixer / trailing-whitespace / check-yaml" +echo "" +echo "手动跑全部:" +echo " pre-commit run --all-files" +echo "" +echo "必要时跳过(仅紧急情况):" +echo " git commit --no-verify -m 'message'" diff --git a/site/.vitepress/config/build-info.ts b/site/.vitepress/config/build-info.ts new file mode 100644 index 0000000..f1b4d94 --- /dev/null +++ b/site/.vitepress/config/build-info.ts @@ -0,0 +1,32 @@ +import { execFileSync } from 'node:child_process' + +// VitePress config 在 Node 运行,CI 已 fetch-depth: 0,可零依赖拿到 git 信号。 +// 版本展示的唯一真相源是 git tag(package.json version 是停滞占位值,不可用)。 +// +// date 用 git 提交日期(git log -1 --format=%ci HEAD 取首 10 字符 YYYY-MM-DD),稳定可复现; +// 不用运行时取当前时间的函数——构建脚本环境里不稳定也不可复现。 + +function git(args: string[]): string { + try { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() + } catch { + return '' // 非 git 仓库 / 无 tag → 回退 + } +} + +export interface BuildInfo { + /** git describe 结果,如 v0.5.3 或 v0.5.3-3-gabc1234(-dirty 表示有未提交改动);无 tag 退到 SHA;非 git 回退 dev */ + version: string + /** 7 位短 SHA;非 git 仓库为空字符串 */ + sha: string + /** 最近一次提交日期 YYYY-MM-DD(取自 git %ci);非 git 回退 dev */ + date: string +} + +export function getBuildInfo(): BuildInfo { + return { + version: git(['describe', '--tags', '--always', '--dirty']) || 'dev', + sha: git(['rev-parse', '--short=7', 'HEAD']), + date: git(['log', '-1', '--format=%ci', 'HEAD']).substring(0, 10) || 'dev', + } +} diff --git a/site/.vitepress/config/index.ts b/site/.vitepress/config/index.ts index 7641e9d..7fcf1c8 100644 --- a/site/.vitepress/config/index.ts +++ b/site/.vitepress/config/index.ts @@ -1,71 +1,19 @@ import { defineConfig } from 'vitepress' import { navZh } from './nav' import { buildSidebar } from './sidebar' -import { cppTemplateEscapePlugin } from '../plugins/escape-cpp-templates' -import { mermaidPlugin } from '../plugins/mermaid-plugin' -import { viteCppEscape } from '../plugins/vite-escape-cpp' +import { sharedBase, sharedMarkdown, sharedThemeBase } from './shared' export default defineConfig({ srcDir: '../tutorial', - title: 'AwesomeQt 教程', - description: '系统化的现代 Qt 6 教程 — 从基础入门到源码解析', - lang: 'zh-CN', - base: '/Tutorial_AwesomeQt/', - cleanUrls: true, - lastUpdated: true, + ...sharedBase, - vite: { - build: { - chunkSizeWarningLimit: 5000, - }, - plugins: [viteCppEscape()], - }, - - vue: { - template: { - compilerOptions: { - isCustomElement: (tag: string) => tag.includes('-') || tag.includes('.'), - }, - }, - }, - - head: [ - ['link', { rel: 'icon', href: '/Tutorial_AwesomeQt/favicon.ico' }], - ], - - markdown: { - lineNumbers: true, - theme: { - light: 'github-light', - dark: 'github-dark', - }, - config(md) { - cppTemplateEscapePlugin(md) - md.use(mermaidPlugin) - }, - }, + markdown: sharedMarkdown, themeConfig: { + ...sharedThemeBase, + nav: navZh, sidebar: buildSidebar(), - - search: { - provider: 'local', - }, - - editLink: { - pattern: 'https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/edit/main/tutorial/:path', - text: '在 GitHub 上编辑此页', - }, - - footer: { - message: '基于 VitePress 构建', - copyright: 'Copyright 2025-2026 Charliechen', - }, - - socialLinks: [ - { icon: 'github', link: 'https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt' }, - ], }, }) diff --git a/site/.vitepress/config/shared.ts b/site/.vitepress/config/shared.ts new file mode 100644 index 0000000..16fd2b3 --- /dev/null +++ b/site/.vitepress/config/shared.ts @@ -0,0 +1,90 @@ +// 单一真相源:dev 配置 (config/index.ts) 与生产分卷构建 (scripts/build.ts) 共享这一份。 +// +// 改 markdown 插件 / head / vite / vue / 主题基础项,**只改这一处**,三处自动同步。 +// 历史教训:mermaid 曾因三处分别维护而漏改一处,dev 正常、生产构建废 +// (见 MEMORY build-ts-drifts-from-main-config)。 +// +// 不含 nav / sidebar —— 分卷构建配置不需要这俩,只有 dev 配置和根构建配置各自挂。 + +import type { MarkdownIt } from 'markdown-it' +import { cppTemplateEscapePlugin } from '../plugins/escape-cpp-templates' +import { codeFoldPlugin } from '../plugins/code-fold-plugin' +import { kbdPlugin } from '../plugins/kbd-plugin' +import { mermaidPlugin } from '../plugins/mermaid-plugin' +import { viteCppEscape } from '../plugins/vite-escape-cpp' +import { getBuildInfo } from './build-info' + +/** 站点级基础字段:标题 / 语言 / base / vite / vue / head —— 三处配置完全一致 */ +export const sharedBase = { + title: 'AwesomeQt 教程', + description: '系统化的现代 Qt 6 教程 — 从基础入门到源码解析', + lang: 'zh-CN', + base: '/Tutorial_AwesomeQt/', + cleanUrls: true, + lastUpdated: true, + + vite: { + build: { + chunkSizeWarningLimit: 5000, + }, + plugins: [viteCppEscape()], + }, + + vue: { + template: { + compilerOptions: { + isCustomElement: (tag: string) => tag.includes('-') || tag.includes('.'), + }, + }, + }, + + head: [ + ['link', { rel: 'icon', href: '/Tutorial_AwesomeQt/favicon.ico' }], + // 字号切换首屏防闪烁:Vue 挂载前先从 localStorage 读档位写 data-font-size,默认 normal。 + // 与 theme/components/FontSizeSwitcher.vue 的 STORAGE_KEY('awesomeqt-font-size') 一致。 + [ + 'script', + {}, + `(function(){try{var s=localStorage.getItem('awesomeqt-font-size')||'normal';if(s!=='xxsmall'&&s!=='small'&&s!=='normal'&&s!=='large'&&s!=='xxlarge'){s='normal';}document.documentElement.dataset.fontSize=s;}catch(e){}})()`, + ], + ], +} + +/** markdown 渲染配置:行号 / 主题 / 自定义插件。新增 markdown 插件只在这里 md.use(...) */ +export const sharedMarkdown = { + lineNumbers: true, + theme: { + light: 'github-light', + dark: 'github-dark', + }, + config(md: MarkdownIt) { + cppTemplateEscapePlugin(md) + md.use(mermaidPlugin) + md.use(codeFoldPlugin) // 必须在 mermaid 之后:覆写 fence 要拿到 mermaid 改型后的完整链 + md.use(kbdPlugin) + }, +} + +/** 主题基础项(不含 nav/sidebar,分卷构建不带这俩) */ +export const sharedThemeBase = { + search: { + provider: 'local', + }, + + editLink: { + pattern: 'https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt/edit/main/tutorial/:path', + text: '在 GitHub 上编辑此页', + }, + + footer: { + message: (() => { + const { version, sha, date } = getBuildInfo() + return `AwesomeQt ${version} · ${sha} · ${date}` + })(), + copyright: 'Copyright 2025-2026 Charliechen', + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/Awesome-Embedded-Learning-Studio/Tutorial_AwesomeQt' }, + ], +} diff --git a/site/.vitepress/config/sidebar.ts b/site/.vitepress/config/sidebar.ts index af3768b..584220e 100644 --- a/site/.vitepress/config/sidebar.ts +++ b/site/.vitepress/config/sidebar.ts @@ -43,7 +43,8 @@ function scanDir(dir: string, urlPrefix: string, depth = 0): SidebarItem[] { e !== 'stylesheets' && e !== 'hooks' && e !== 'javascripts' && - e !== 'images' + e !== 'images' && + e !== 'public' ) } catch { return [] } diff --git a/site/.vitepress/plugins/code-fold-plugin.ts b/site/.vitepress/plugins/code-fold-plugin.ts new file mode 100644 index 0000000..e797358 --- /dev/null +++ b/site/.vitepress/plugins/code-fold-plugin.ts @@ -0,0 +1,75 @@ +import type { PluginSimple } from 'markdown-it' +import type MarkdownIt from 'markdown-it' + +/** + * 长代码折叠:把超过阈值行数的代码块包成 + *
<代码块/>
+ * (代码在
外),用原生
做开关、纯 CSS :has() 控展开。 + * + * 为什么是构建期 markdown-it 插件、而不是客户端 JS 增强? + * - FOUC:站点是 SSG,首屏静态 HTML 里代码块就是全展开的;客户端 bundle 异步加载, + * 冷缓存首访「先展开再闪收」必然。构建期产出即折叠态,零闪烁。 + * - 无障碍 / 无 JS:
原生 disclosure,无 JS 也能展开,键盘/AT 语义白送。 + * + * 实现要点: + * - sharedMarkdown.config(md) 在 VitePress 把 Shiki/复制按钮/行号/preWrapper 全部接好 + * 【之后】才执行,故此处捕获的 md.renderer.rules.fence 已是完整链——调用它即得含 + * .copy/.lang/pre.shiki/.line-numbers-wrapper 的整段 HTML。直接整段包
,内部兄弟链不动。 + * - 必须整段包(而非只包
):VitePress 复制按钮靠 button.copy.nextElementSibling 定位 
,
+ *     兄弟链一断复制即失效。
+ *   - 代码放在 
【外面】(与
同级):原生
关闭时不渲染非 summary 内容; + * 放外面则代码始终在 DOM 中,纯 CSS :has(details[open]) 控制 display——零 JS、无 JS 也能展开、 + * @media print 强制显示即完整打印。 + * - code-group 内的 fence 不折叠(tab 本身已是折叠语义)。 + * + * 阈值 30:入门层大量 20-30 行可读中等块不误折,只折真正「一大坨」的长块。改这一处常量即可全局调档。 + * + * AwesomeQt 适配:单语 zh,去掉 tamcpp 的 isEn 双语分支,文案固定中文。 + */ +const FOLD_THRESHOLD = 30 + +export const codeFoldPlugin: PluginSimple = (md: MarkdownIt) => { + // ① core ruler:标记 code-group 内的 fence,折叠时跳过。 + md.core.ruler.push('code_fold_mark_codegroup', (state) => { + let depth = 0 + for (const token of state.tokens) { + if (token.type === 'container_code-group_open') { + depth++ + } else if (token.type === 'container_code-group_close') { + if (depth > 0) depth-- + } else if (depth > 0 && token.type === 'fence') { + if (!token.meta) token.meta = {} + token.meta.inCodeGroup = true + } + } + return true + }) + + // ② 覆写 fence:整段包
。 + const originalFence = md.renderer.rules.fence + if (!originalFence) return + + md.renderer.rules.fence = (tokens, idx, options, env, self) => { + const html = originalFence(tokens, idx, options, env, self) + const token = tokens[idx] + + // code-group 内不折;mermaid 已被 mermaidPlugin 改型为 mermaid_diagram,不进 fence rule。 + if (token.meta && token.meta.inCodeGroup) return html + + const body = token.content.replace(/\n$/, '') + const lineCount = body === '' ? 0 : body.split('\n').length + if (lineCount <= FOLD_THRESHOLD) return html + + // 双 summary(vp-cf-closed / vp-cf-open)配 CSS :has(details[open]) 切文案。 + const closedLabel = `展开代码 (共 ${lineCount} 行)` + const openLabel = '收起代码' + + return ( + `
` + + `
${closedLabel}` + + `${openLabel}
` + + html + + `
` + ) + } +} diff --git a/site/.vitepress/plugins/kbd-plugin.ts b/site/.vitepress/plugins/kbd-plugin.ts new file mode 100644 index 0000000..fa428d8 --- /dev/null +++ b/site/.vitepress/plugins/kbd-plugin.ts @@ -0,0 +1,48 @@ +import type { PluginSimple } from 'markdown-it' +import type MarkdownIt from 'markdown-it' + +/** + * 快捷键渲染:识别 ++...++ 包裹的按键组合,渲染成连写的 标签。 + * ++Ctrl+S++ → Ctrl+S + * ++Ctrl+Shift+P++ → Ctrl+Shift+P + * + * 用 html_inline 版(token.content = parts.map(kbd).join('+')),不用会渲染成单 kbd 含字面 + 的 token-push 版。 + * inline ruler after emphasis;拒绝前导字母数字/下划线,避开 C++ / i++ / operator++ / foo_++。 + */ +export const kbdPlugin: PluginSimple = (md: MarkdownIt) => { + md.inline.ruler.after('emphasis', 'kbd', (state, silent) => { + const start = state.pos + const max = state.posMax + + if (state.src.charCodeAt(start) !== 0x2B /* + */) return false + if (state.src.charCodeAt(start + 1) !== 0x2B /* + */) return false + + // 前导字符是字母数字/下划线则拒绝(C++ / i++ / operator++ / foo_++) + if (start > 0) { + const prev = state.src.charCodeAt(start - 1) + if ( + (prev >= 0x30 && prev <= 0x39) || // 0-9 + (prev >= 0x41 && prev <= 0x5A) || // A-Z + (prev >= 0x61 && prev <= 0x7A) || // a-z + prev === 0x5F // _ + ) return false + } + + let pos = start + 2 + while (pos < max) { + if (state.src.charCodeAt(pos) === 0x2B /* + */ && state.src.charCodeAt(pos + 1) === 0x2B /* + */) { + const content = state.src.slice(start + 2, pos) + if (content.length === 0) return false + if (!silent) { + const parts = content.split('+') + const token = state.push('html_inline', '', 0) + token.content = parts.map(p => `${p.trim()}`).join('+') + } + state.pos = pos + 2 + return true + } + pos++ + } + return false + }) +} diff --git a/site/.vitepress/theme/components/FontSizeSwitcher.vue b/site/.vitepress/theme/components/FontSizeSwitcher.vue new file mode 100644 index 0000000..7fdb0b6 --- /dev/null +++ b/site/.vitepress/theme/components/FontSizeSwitcher.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/site/.vitepress/theme/components/ScreenshotCarousel.vue b/site/.vitepress/theme/components/ScreenshotCarousel.vue new file mode 100644 index 0000000..b3da167 --- /dev/null +++ b/site/.vitepress/theme/components/ScreenshotCarousel.vue @@ -0,0 +1,375 @@ + + + + + diff --git a/site/.vitepress/theme/custom.css b/site/.vitepress/theme/custom.css index c8169a5..c1bae68 100644 --- a/site/.vitepress/theme/custom.css +++ b/site/.vitepress/theme/custom.css @@ -634,4 +634,141 @@ clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; -} \ No newline at end of file +} + +/* ================================================================ + 长代码折叠(.vp-code-fold):构建期 code-fold-plugin 把 >30 行的代码块 + 包成
<代码/>
。 + - 收起 = 只显示 summary 条(代码完全隐藏);展开 = 代码全部显示。二态干净。 + - 代码在
外,靠纯 CSS :has(details[open]) 控制 display;零 JS、无 JS 也能展开。 + - 内部 div.language-* 兄弟链(copy/lang/pre/line-numbers)不动,复制按钮不受影响。 + ================================================================ */ +.vp-doc .vp-code-fold { + margin: 16px 0; + border-radius: 8px; + overflow: hidden; + background: var(--vp-code-block-bg); +} + +.vp-doc .vp-code-fold > details > summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + font-size: 0.9rem; + font-weight: 500; + color: var(--vp-c-text-2); + user-select: none; + background: var(--vp-code-block-bg); + transition: color 0.2s ease; +} + +.vp-doc .vp-code-fold > details > summary:hover { + color: var(--vp-c-text-1); +} + +.vp-doc .vp-code-fold > details > summary::-webkit-details-marker { + display: none; +} + +.vp-doc .vp-code-fold > details > summary::before { + content: '▶'; + font-size: 1rem; + line-height: 1; + color: var(--vp-c-text-3); + transition: transform 0.2s ease, color 0.2s ease; +} + +.vp-doc .vp-code-fold > details > summary:hover::before { + color: var(--vp-c-brand-1); +} + +.vp-doc .vp-code-fold:has(details[open]) > details > summary::before { + transform: rotate(90deg); +} + +.vp-doc .vp-code-fold:not(:has(details[open])) .vp-cf-open { + display: none; +} + +.vp-doc .vp-code-fold:has(details[open]) .vp-cf-closed { + display: none; +} + +.vp-doc .vp-code-fold:has(details[open]) .vp-cf-open { + display: inline; +} + +.vp-doc .vp-code-fold > details > summary em { + font-style: normal; + color: var(--vp-c-text-3); + margin-left: 4px; +} + +.vp-doc .vp-code-fold > div[class*='language-'] { + margin: 0; + border-radius: 0; +} + +.vp-doc .vp-code-fold:not(:has(details[open])) > div[class*='language-'] { + display: none; +} + +@media print { + .vp-doc .vp-code-fold > div[class*='language-'] { + display: block !important; + } +} + +@media (prefers-reduced-motion: reduce) { + .vp-doc .vp-code-fold > details > summary, + .vp-doc .vp-code-fold > details > summary::before { + transition: none; + } +} + +/* ================================================================ + kbd 键盘按键样式(配合 kbd-plugin,++Ctrl+S++ → Ctrl+S) + ================================================================ */ +.vp-doc kbd, +kbd { + display: inline-block; + padding: 0.15em 0.45em; + font-family: var(--vp-font-family-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); + font-size: 0.85em; + line-height: 1.4; + color: var(--vp-c-text-1); + background: var(--vp-c-bg-alt); + border: 1px solid var(--vp-c-divider); + border-radius: 4px; + box-shadow: 0 1px 0 var(--vp-c-divider); + white-space: nowrap; + vertical-align: middle; +} + +/* ================================================================ + 正文字号五档(FontSizeSwitcher 切 documentElement 的 data-font-size,CSS zoom 整页缩放)。 + 默认 normal(zoom 1);往下小/超小,往上大/超大。zoom 连尺寸间距一起按比例缩放(等同浏览器 + Ctrl+/-),避免「只有正文跳动、组件不动」的割裂感。首屏由 shared.ts head 内联脚本提前设档防闪烁。 + ================================================================ */ +html[data-font-size='xxsmall'] { + zoom: 0.85; +} + +html[data-font-size='small'] { + zoom: 0.92; +} + +html[data-font-size='normal'] { + zoom: 1; +} + +html[data-font-size='large'] { + zoom: 1.08; +} + +html[data-font-size='xxlarge'] { + zoom: 1.16; +} diff --git a/site/.vitepress/theme/index.ts b/site/.vitepress/theme/index.ts index 87f6885..9152930 100644 --- a/site/.vitepress/theme/index.ts +++ b/site/.vitepress/theme/index.ts @@ -5,6 +5,8 @@ import HomeRoadmap from './components/HomeRoadmap.vue' import HomeMeta from './components/HomeMeta.vue' import CardGrid from './components/CardGrid.vue' import CardLink from './components/CardLink.vue' +import FontSizeSwitcher from './components/FontSizeSwitcher.vue' +import ScreenshotCarousel from './components/ScreenshotCarousel.vue' import { setupMermaid } from './mermaid-client' import './custom.css' @@ -12,7 +14,11 @@ export default { extends: DefaultTheme, Layout() { return h(DefaultTheme.Layout, null, { + 'home-features-before': () => h(ScreenshotCarousel), 'home-features-after': () => h(HomeRoadmap), + // 字号切换器:桌面顶栏右侧 + 移动端汉堡菜单展开后 + 'nav-bar-content-after': () => h(FontSizeSwitcher), + 'nav-screen-content-after': () => h(FontSizeSwitcher), }) }, setup() { diff --git a/third_party/benchmark b/third_party/benchmark index c47bbfb..192ef10 160000 --- a/third_party/benchmark +++ b/third_party/benchmark @@ -1 +1 @@ -Subproject commit c47bbfbdbbf59d6fbd2df110965374ef3bfe9bd7 +Subproject commit 192ef10025eb2c4cdd392bc502f0c852196baa48 diff --git a/third_party/googletest b/third_party/googletest index dc3c9ed..52eb810 160000 --- a/third_party/googletest +++ b/third_party/googletest @@ -1 +1 @@ -Subproject commit dc3c9eda2f02ba32de9329dd27ace7e527f492dc +Subproject commit 52eb8108c5bdec04579160ae17225d66034bd723 diff --git a/tutorial/advanced/01-qtbase/05-qvariant-metatype-advanced.md b/tutorial/advanced/01-qtbase/05-qvariant-metatype-advanced.md index 3f305ed..7ad4e75 100644 --- a/tutorial/advanced/01-qtbase/05-qvariant-metatype-advanced.md +++ b/tutorial/advanced/01-qtbase/05-qvariant-metatype-advanced.md @@ -100,7 +100,7 @@ Qt 6 提供了一些改进来缓解这个问题。`QVariant::canConvert()` 第二个坑是 QVariant 隐式转换导致的逻辑错误。QVariant 支持 `canConvert` 和 `convert`,它会尝试在多种类型之间做隐式转换。比如你存了一个 QString "42",用 `value()` 取出来会得到 42。这看起来很方便,但如果你的本意是检查数据类型是否正确,隐式转换就会掩盖错误。后果是数据被静默篡改,排查时你看到的是「正确的值」但实际来源是错误的。解决方案是在取值前用 `variant.metaType().id() == qMetaTypeId()` 做精确类型匹配检查,而不是依赖 `canConvert`。 -第三个坑是 QVariant 存储 QObject 指针的陷阱。你可以用 `QVariant::fromValue(objPtr)` 存储 QObject* 到 QVariant 中,但取出时必须用 `value()` 而不是 `value()`——QVariant 不知道 QObject 的继承关系,它只存储了传入时的精确类型。如果你存了 QObject* 但用 QWidget* 去取,类型不匹配,返回 nullptr。后果是看起来对象「丢了」但实际还在 QVariant 里。解决方案是存储时用目标类型的精确指针,或者取出后用 `qobject_cast` 做向下转型。 +第三个坑是 QVariant 存储 QObject 指针的陷阱。你可以用 `QVariant::fromValue(objPtr)` 存储 QObject*到 QVariant 中,但取出时必须用 `value()` 而不是 `value()`——QVariant 不知道 QObject 的继承关系,它只存储了传入时的精确类型。如果你存了 QObject* 但用 QWidget* 去取,类型不匹配,返回 nullptr。后果是看起来对象「丢了」但实际还在 QVariant 里。解决方案是存储时用目标类型的精确指针,或者取出后用 `qobject_cast` 做向下转型。 ## 5. 练习项目 diff --git a/tutorial/advanced/03-qtwidgets/07-main-window-advanced.md b/tutorial/advanced/03-qtwidgets/07-main-window-advanced.md index ffa7499..2e8c54c 100644 --- a/tutorial/advanced/03-qtwidgets/07-main-window-advanced.md +++ b/tutorial/advanced/03-qtwidgets/07-main-window-advanced.md @@ -111,7 +111,7 @@ setCorner(Qt::BottomRightCorner, Qt::RightDockWidgetArea); ## 4. 踩坑预防 -第一个坑是 restoreState 在窗口 show() 之前调用导致布局恢复失败。这个坑在 3.1 节的随堂测验里已经分析过了,这里再强调一下后果:用户精心调整的 Dock 停靠位置、工具栏行号、浮动状态全部丢失,所有 Dock 变成浮动窗口,工具栏回到默认位置。对用户来说等于白配置了,体验极差。解决方案是在构造函数中先 show() 再 restoreState,或者用 QTimer::singleShot(0, this, [this]() { restoreState(...); }) 把 restoreState 推迟到事件循环的下一轮。前一种方案简单直接,后一种方案适合在构造函数中不方便调用 show() 的场景。 +第一个坑是 restoreState 在窗口 show() 之前调用导致布局恢复失败。这个坑在 3.1 节的随堂测验里已经分析过了,这里再强调一下后果:用户精心调整的 Dock 停靠位置、工具栏行号、浮动状态全部丢失,所有 Dock 变成浮动窗口,工具栏回到默认位置。对用户来说等于白配置了,体验极差。解决方案是在构造函数中先 show() 再 restoreState,或者用 `QTimer::singleShot(0, this, [this]() { restoreState(...); })` 把 restoreState 推迟到事件循环的下一轮。前一种方案简单直接,后一种方案适合在构造函数中不方便调用 show() 的场景。 第二个坑是 setCorner 在 Dock 已存在后设置不生效。setCorner 改变的是角落区域的归属规则,这个规则在 Dock 被添加到主窗口时读取一次。如果你先 addDockWidget 再 setCorner,已有的 Dock 已经根据旧的角落归属计算好了自己的可用区域,setCorner 不会触发重新计算。后果是角落区域的行为不符合预期——比如你期望左侧 Dock 延伸到顶部但实际被截断了。解决方案很简单:把所有 setCorner 调用放在第一批 addDockWidget 之前。 diff --git a/tutorial/advanced/03-qtwidgets/11-qwidget-base-advanced.md b/tutorial/advanced/03-qtwidgets/11-qwidget-base-advanced.md index 23612d0..94651e2 100644 --- a/tutorial/advanced/03-qtwidgets/11-qwidget-base-advanced.md +++ b/tutorial/advanced/03-qtwidgets/11-qwidget-base-advanced.md @@ -76,13 +76,13 @@ void mouseMoveEvent(QMouseEvent* event) override ### 3.3 setAttribute 的性能影响——频繁切换属性的开销 -`setAttribute()` 不是零成本的调用。每次修改一个 WA_* 属性,Qt 内部会做几件事:更新 `QWidgetPrivate` 中的属性位掩码,判断属性变更是否需要触发布局或重绘,如果涉及窗口系统层面的变更(比如 WA_TranslucentBackground),还会调用平台插件更新原生窗口的属性。这意味着如果你在每一帧的 paintEvent 或定时器回调中频繁切换 WA_* 属性,性能开销是可观的——不仅仅是 CPU 计算的开销,还可能导致窗口被反复重建。 +`setAttribute()` 不是零成本的调用。每次修改一个 WA_*属性,Qt 内部会做几件事:更新 `QWidgetPrivate` 中的属性位掩码,判断属性变更是否需要触发布局或重绘,如果涉及窗口系统层面的变更(比如 WA_TranslucentBackground),还会调用平台插件更新原生窗口的属性。这意味着如果你在每一帧的 paintEvent 或定时器回调中频繁切换 WA_* 属性,性能开销是可观的——不仅仅是 CPU 计算的开销,还可能导致窗口被反复重建。 一个典型的反面案例是:有人试图通过在 timer 回调中交替设置 `setAttribute(Qt::WA_TransparentForMouseEvents, true)` 和 `setAttribute(Qt::WA_TransparentForMouseEvents, false)` 来实现"穿透模式切换"。每次切换都会触发一次属性更新和重绘通知,在高频定时器下会造成明显的性能问题和界面闪烁。正确的做法是在初始化时一次性设好所有属性,运行时不要修改。如果确实需要动态切换鼠标穿透,考虑用 `setMouseTracking()` 和事件过滤器配合条件判断来实现,而不是反复调 setAttribute。 ### 3.4 WindowFlags 与 WA_* 属性的联动 -WindowFlags 和 WA_* 属性之间不是完全独立的——某些 WindowFlags 会隐式触发 WA_* 属性的变更,反过来某些 WA_* 属性也依赖特定的 WindowFlags 才能生效。 +WindowFlags 和 WA_*属性之间不是完全独立的——某些 WindowFlags 会隐式触发 WA_* 属性的变更,反过来某些 WA_* 属性也依赖特定的 WindowFlags 才能生效。 最典型的联动是 `Qt::FramelessWindowHint` 和 `Qt::WA_TranslucentBackground` 的配合。要在 Windows 上实现真正的半透明窗口,你必须同时设置 FramelessWindowHint 和 WA_TranslucentBackground。原因是 Windows 的 DWM(Desktop Window Manager)对分层窗口(Layered Window)的实现要求窗口必须是 Frameless 的——或者更准确地说,Qt 在 Windows 上通过 WS_EX_LAYERED 扩展样式实现半透明,而这个扩展样式和标准窗口边框有冲突。在 macOS 和 Linux 上这个限制不那么严格,但为了跨平台一致性,还是建议两个都设。 diff --git a/tutorial/advanced/03-qtwidgets/25-qtextbrowser-advanced.md b/tutorial/advanced/03-qtwidgets/25-qtextbrowser-advanced.md index 219b5c0..a4cf5e5 100644 --- a/tutorial/advanced/03-qtwidgets/25-qtextbrowser-advanced.md +++ b/tutorial/advanced/03-qtwidgets/25-qtextbrowser-advanced.md @@ -143,7 +143,7 @@ connect(browser, &QTextBrowser::anchorClicked, 练习项目:增强型文档浏览器。我们要实现一个支持自定义图片资源协议的 QTextBrowser,核心功能是从一个模拟的数据库(用 QMap 代替)加载图片并显示在文档中。 -具体要求是:继承 QTextBrowser 创建 CustomTextBrowser,覆写 loadResource 处理 "imgdb://" 协议的图片请求;准备若干张测试图片存入 QMap 作为模拟数据库;构建一个 HTML 文档,其中用 引用这些图片;实现安全的历史导航(后退、前进按钮在空栈时禁用);所有外部链接通过 anchorClicked 在系统浏览器中打开,内部链接由 QTextBrowser 处理。完成标准是文档中的自定义协议图片能正确显示,历史导航不会崩溃,外部链接不会在 QTextBrowser 内部打开。 +具体要求是:继承 QTextBrowser 创建 CustomTextBrowser,覆写 loadResource 处理 "imgdb://" 协议的图片请求;准备若干张测试图片存入 QMap 作为模拟数据库;构建一个 HTML 文档,其中用 `` 引用这些图片;实现安全的历史导航(后退、前进按钮在空栈时禁用);所有外部链接通过 anchorClicked 在系统浏览器中打开,内部链接由 QTextBrowser 处理。完成标准是文档中的自定义协议图片能正确显示,历史导航不会崩溃,外部链接不会在 QTextBrowser 内部打开。 提示几个关键点:loadResource 的 type 参数为 QTextDocument::ImageResource 时表示图片资源;文档中的 imgdb:// 链接会被传入 loadResource 的 name 参数;setOpenLinks(false) 配合手动 anchorClicked 处理是最可控的方案。 diff --git a/tutorial/advanced/03-qtwidgets/32-qscrollbar-advanced.md b/tutorial/advanced/03-qtwidgets/32-qscrollbar-advanced.md index ba6dc4a..31111c2 100644 --- a/tutorial/advanced/03-qtwidgets/32-qscrollbar-advanced.md +++ b/tutorial/advanced/03-qtwidgets/32-qscrollbar-advanced.md @@ -19,7 +19,7 @@ description: "入门篇我们用 QScrollBar 驱动了一个自定义时间轴控 QScrollBar 的手柄(slider,也叫 thumb)大小不是随便画的——它的大小和内容比例直接相关。QStyle 在计算手柄大小时使用的核心公式是: -``` +```text slider_size = pageStep / (max - min + pageStep) * available_length ``` diff --git a/tutorial/advanced/03-qtwidgets/60-qdialog-advanced.md b/tutorial/advanced/03-qtwidgets/60-qdialog-advanced.md index a184543..f0a6f4b 100644 --- a/tutorial/advanced/03-qtwidgets/60-qdialog-advanced.md +++ b/tutorial/advanced/03-qtwidgets/60-qdialog-advanced.md @@ -135,7 +135,7 @@ connect(dlg, &QDialog::finished, dlg, &QObject::deleteLater); dlg->open(); ``` -open() 还有一个历史悠久的重载版本:open(QObject* receiver, const char* member)。这个版本会在对话框关闭时自动调用 receiver 的 member 槽函数,并且连接是自动断开的(一次性连接)。但在 Qt6 中,我们更推荐用函数指针连接 + lambda 的方式,可读性和类型安全性都更好。 +open() 还有一个历史悠久的重载版本:open(QObject*receiver, const char* member)。这个版本会在对话框关闭时自动调用 receiver 的 member 槽函数,并且连接是自动断开的(一次性连接)。但在 Qt6 中,我们更推荐用函数指针连接 + lambda 的方式,可读性和类型安全性都更好。 ### 3.4 QDialogButtonBox 与 accept/reject 的集成 diff --git a/tutorial/advanced/03-qtwidgets/66-qfiledialog-advanced.md b/tutorial/advanced/03-qtwidgets/66-qfiledialog-advanced.md index 6dfe088..b7883f7 100644 --- a/tutorial/advanced/03-qtwidgets/66-qfiledialog-advanced.md +++ b/tutorial/advanced/03-qtwidgets/66-qfiledialog-advanced.md @@ -48,7 +48,7 @@ if (dlg->exec() == QDialog::Accepted) { 文件类型过滤是 QFileDialog 最常用的功能之一,但它的细节比大多数人想象的要多。 -setNameFilter 接受的格式是 "显示名称 (扩展名列表)",多个过滤器用 ";;" 分隔。这个格式看起来简单,但有几个容易忽略的细节。首先,扩展名列表中的通配符匹配是不区分大小写的——"*.PNG" 和 "*.png" 会匹配到相同的文件。其次,你可以用多个扩展名在一个过滤器里,比如 "图片 (*.png *.jpg *.jpeg *.bmp)"。 +setNameFilter 接受的格式是 "显示名称 (扩展名列表)",多个过滤器用 ";;" 分隔。这个格式看起来简单,但有几个容易忽略的细节。首先,扩展名列表中的通配符匹配是不区分大小写的——"*.PNG" 和 "*.png" 会匹配到相同的文件。其次,你可以用多个扩展名在一个过滤器里,比如 "图片 (*.png*.jpg *.jpeg*.bmp)"。 ```cpp dlg->setNameFilter("源代码 (*.cpp *.h *.hpp);;项目文件 (*.pro *.cmake);;所有文件 (*)"); diff --git a/tutorial/advanced/05-other-modules/22-qtremoteobjects-advanced.md b/tutorial/advanced/05-other-modules/22-qtremoteobjects-advanced.md index 9e13804..da7c344 100644 --- a/tutorial/advanced/05-other-modules/22-qtremoteobjects-advanced.md +++ b/tutorial/advanced/05-other-modules/22-qtremoteobjects-advanced.md @@ -23,7 +23,7 @@ description: "入门篇我们用 Qt Remote Objects 做了进程间通信—— `.rep` 文件是 Qt Remote Objects 的接口定义语言。它声明了远程对象的属性、信号和方法——类似于 IDL(Interface Definition Language)或 gRPC 的 `.proto` 文件。`repc` 编译器根据 `.rep` 文件生成 C++ 头文件,包含 Source 端的基类和 Replica 端的代理类。 -``` +```cpp // sensor.rep — 传感器数据接口定义 class SensorInterface { diff --git a/tutorial/beginner/00-environment-setup/00-qt6-install-beginner.md b/tutorial/beginner/00-environment-setup/00-qt6-install-beginner.md index 1a67173..b7d592a 100644 --- a/tutorial/beginner/00-environment-setup/00-qt6-install-beginner.md +++ b/tutorial/beginner/00-environment-setup/00-qt6-install-beginner.md @@ -31,7 +31,7 @@ Qt 6.9 对 CMake 最低版本要求是 3.26,额,其实是我猜测的,当 我们要用的是 Qt 官方的在线安装器,它是统一入口,不管你是哪个平台都用它。 -官方下载地址:https://www.qt.io/download-qt-installer +官方下载地址: 你可能会看到两个版本:Open Source 和 Commercial。对我们学习和个人项目来说,选 Open Source 就行,它是 LGPL 协议的,商业友好。注册个 Qt 账号就能免费用,不用想太复杂。 diff --git a/tutorial/beginner/00-environment-setup/01-ide-setup-beginner.md b/tutorial/beginner/00-environment-setup/01-ide-setup-beginner.md index be6e072..7e798d3 100644 --- a/tutorial/beginner/00-environment-setup/01-ide-setup-beginner.md +++ b/tutorial/beginner/00-environment-setup/01-ide-setup-beginner.md @@ -150,7 +150,7 @@ CLion 默认对 QML 的支持有限,需要额外配置。安装插件:Settin Qt Creator 首次启动时会自动检测 Qt 安装,如果检测不到,打开 Edit → Preferences → Kits → Qt Versions,手动添加: -``` +```text 名称: Qt 6.9.1 (mingw_64) qmake 路径: C:/Qt/6.9.1/mingw_64/bin/qmake.exe ``` diff --git a/tutorial/beginner/00-environment-setup/02-cmake-first-project-beginner.md b/tutorial/beginner/00-environment-setup/02-cmake-first-project-beginner.md index b2f560c..d17f3a0 100644 --- a/tutorial/beginner/00-environment-setup/02-cmake-first-project-beginner.md +++ b/tutorial/beginner/00-environment-setup/02-cmake-first-project-beginner.md @@ -118,7 +118,7 @@ cmake .. -DCMAKE_PREFIX_PATH=/home/你的用户名/Qt/6.9.1/gcc_64 如果配置成功,你会看到: -``` +```text -- Configuring done -- Generating done -- Build files have been written to: /path/to/HelloQt/build @@ -309,7 +309,7 @@ int main(int argc, char *argv[]) 实际项目中,代码通常会分成多个模块: -``` +```text MyApp/ ├── CMakeLists.txt ├── app/ diff --git a/tutorial/beginner/03-qtwidgets/26-qkeysequenceedit-beginner.md b/tutorial/beginner/03-qtwidgets/26-qkeysequenceedit-beginner.md index 30e2f88..08bba9d 100644 --- a/tutorial/beginner/03-qtwidgets/26-qkeysequenceedit-beginner.md +++ b/tutorial/beginner/03-qtwidgets/26-qkeysequenceedit-beginner.md @@ -36,7 +36,7 @@ QKeySequenceEdit 还会自动处理平台差异。在 Windows 和 Linux 上,Ct 在布局上,QKeySequenceEdit 通常和一个 QLabel 配对使用——Label 说明"这个快捷键是干什么的",后面跟着 QKeySequenceEdit 用于录入。在设置页面中,你可能会看到一排这样的组合: -``` +```text 保存: [Ctrl+S ] 撤销: [Ctrl+Z ] 全选: [Ctrl+A ] diff --git a/tutorial/beginner/03-qtwidgets/32-qscrollbar-beginner.md b/tutorial/beginner/03-qtwidgets/32-qscrollbar-beginner.md index 31ee395..73f53be 100644 --- a/tutorial/beginner/03-qtwidgets/32-qscrollbar-beginner.md +++ b/tutorial/beginner/03-qtwidgets/32-qscrollbar-beginner.md @@ -199,7 +199,7 @@ connect(scrollArea->verticalScrollBar(), &QScrollBar::valueChanged, 我们来做一个综合练习:创建一个"自定义时间轴浏览器"窗口,覆盖 QScrollBar 独立使用的核心用法。窗口中央是一个自定义绘制的 QWidget,显示一条水平时间轴,上面分布着若干事件卡片(用不同颜色的矩形表示,每个卡片有标题文字)。窗口底部是一个水平 QScrollBar,用来控制时间轴的水平偏移。窗口右侧有一个垂直 QScrollBar,用来控制时间轴的垂直缩放(value 越大,卡片高度越高、间距越大)。时间轴的事件数据用一个简单的 QVector 存储(每个事件包含日期偏移、标题、颜色)。滚动条的 range 和 pageStep 应该根据缩放级别动态更新。滚轮操作应该驱动水平滚动条。 -几个提示:缩放级别变化时,内容的逻辑宽度 = 事件数量 * 卡片宽度 * 缩放系数,你需要据此重新计算水平滚动条的 range;垂直滚动条的 value 不直接映射为偏移量,而是映射为缩放系数(比如 1.0 到 3.0);在 paintEvent 中先 translate(-offsetX, 0),然后按逻辑坐标绘制卡片。 +几个提示:缩放级别变化时,内容的逻辑宽度 = 事件数量 *卡片宽度* 缩放系数,你需要据此重新计算水平滚动条的 range;垂直滚动条的 value 不直接映射为偏移量,而是映射为缩放系数(比如 1.0 到 3.0);在 paintEvent 中先 translate(-offsetX, 0),然后按逻辑坐标绘制卡片。 ## 6. 官方文档参考链接 diff --git a/tutorial/beginner/03-qtwidgets/41-qstackedwidget-beginner.md b/tutorial/beginner/03-qtwidgets/41-qstackedwidget-beginner.md index ff17871..849183f 100644 --- a/tutorial/beginner/03-qtwidgets/41-qstackedwidget-beginner.md +++ b/tutorial/beginner/03-qtwidgets/41-qstackedwidget-beginner.md @@ -19,7 +19,7 @@ QStackedWidget 是 Qt 里最"低调"的多页容器。它不像 QTabWidget 那 ### 3.1 addWidget 添加页面与 setCurrentIndex 切换页面 -QStackedWidget 的核心概念极为简单——它维护一个 QWidget 列表,同一时刻只显示其中一个,其余全部隐藏。addWidget(QWidget *page) 把一个 QWidget 添加到列表末尾,insertWidget(int index, QWidget *page) 在指定位置插入,removeWidget(QWidget *page) 从列表中移除(但不 delete)。切换当前显示页面的方法有两个:setCurrentIndex(int index) 按索引切换,setCurrentWidget(QWidget *widget) 按 QWidget 指针切换。 +QStackedWidget 的核心概念极为简单——它维护一个 QWidget 列表,同一时刻只显示其中一个,其余全部隐藏。addWidget(QWidget *page) 把一个 QWidget 添加到列表末尾,insertWidget(int index, QWidget*page) 在指定位置插入,removeWidget(QWidget *page) 从列表中移除(但不 delete)。切换当前显示页面的方法有两个:setCurrentIndex(int index) 按索引切换,setCurrentWidget(QWidget*widget) 按 QWidget 指针切换。 ```cpp auto *stack = new QStackedWidget; diff --git a/tutorial/beginner/03-qtwidgets/42-qsplitter-beginner.md b/tutorial/beginner/03-qtwidgets/42-qsplitter-beginner.md index db125fe..a3bb06f 100644 --- a/tutorial/beginner/03-qtwidgets/42-qsplitter-beginner.md +++ b/tutorial/beginner/03-qtwidgets/42-qsplitter-beginner.md @@ -39,7 +39,7 @@ vSplitter->addWidget(listWidget); vSplitter->addWidget(detailLabel); ``` -往 QSplitter 中添加子控件有两种方式。第一种是用 addWidget(QWidget *widget),控件会被追加到末尾。第二种是用 insertWidget(int index, QWidget *widget),在指定位置插入。如果 QSplitter 已经有了一个子控件 A,你 insertWidget(0, B),B 会出现在 A 前面。和 QStackedWidget 不同,QSplitter 的子控件在添加时不需要预先设定大小——QSplitter 会根据自身的可用空间和各子控件的 sizeHint 来分配初始比例。 +往 QSplitter 中添加子控件有两种方式。第一种是用 addWidget(QWidget *widget),控件会被追加到末尾。第二种是用 insertWidget(int index, QWidget*widget),在指定位置插入。如果 QSplitter 已经有了一个子控件 A,你 insertWidget(0, B),B 会出现在 A 前面。和 QStackedWidget 不同,QSplitter 的子控件在添加时不需要预先设定大小——QSplitter 会根据自身的可用空间和各子控件的 sizeHint 来分配初始比例。 QSplitter 的子控件数量没有硬性上限——你可以往一个水平分割器里塞五六个控件,每个之间都会有一条可拖动的分割线。不过从用户体验角度看,超过三个子控件的分割器操作起来会变得比较局促——分割线太密集,拖动时容易拽错位置。如果你的布局确实需要四五个区域,建议用嵌套分割器来组织:外层分割器把窗口分成两半,每一半内部再用自己的分割器继续细分。 diff --git a/tutorial/beginner/03-qtwidgets/43-qtoolbox-beginner.md b/tutorial/beginner/03-qtwidgets/43-qtoolbox-beginner.md index aa009b5..086d1fb 100644 --- a/tutorial/beginner/03-qtwidgets/43-qtoolbox-beginner.md +++ b/tutorial/beginner/03-qtwidgets/43-qtoolbox-beginner.md @@ -21,7 +21,7 @@ Qt 的 QToolBox 就是这个功能的标准实现。它是一个容器控件, ### 3.1 addItem / insertItem 添加面板 -QToolBox 添加面板的核心方法有两个。addItem(QWidget *widget, const QString &text) 把一个控件作为新面板追加到末尾,text 参数是该面板标题栏上显示的文字。insertItem(int index, QWidget *widget, const QString &text) 在指定位置插入面板。还有一个带图标的重载版本 addItem(QWidget *widget, const QIcon &icon, const QString &text),可以给标题栏加上图标。 +QToolBox 添加面板的核心方法有两个。addItem(QWidget *widget, const QString &text) 把一个控件作为新面板追加到末尾,text 参数是该面板标题栏上显示的文字。insertItem(int index, QWidget*widget, const QString &text) 在指定位置插入面板。还有一个带图标的重载版本 addItem(QWidget *widget, const QIcon &icon, const QString &text),可以给标题栏加上图标。 ```cpp auto *toolbox = new QToolBox; diff --git a/tutorial/beginner/03-qtwidgets/46-qlistwidget-beginner.md b/tutorial/beginner/03-qtwidgets/46-qlistwidget-beginner.md index 4e9df1b..0f984ff 100644 --- a/tutorial/beginner/03-qtwidgets/46-qlistwidget-beginner.md +++ b/tutorial/beginner/03-qtwidgets/46-qlistwidget-beginner.md @@ -107,7 +107,7 @@ for (const auto *item : selected) { 有一个容易忽略的问题:当列表为空时,currentItem() 返回 nullptr,currentRow() 返回 -1。所以在使用返回值之前一定要做判空检查,否则对 nullptr 调用 text() 直接崩溃。 -还有一个相关的信号是 currentItemChanged(QListWidgetItem *current, QListWidgetItem *previous),它在当前条目变化时发射,参数分别是新的当前条目和之前的当前条目。这个信号在实现"选中条目变化时更新右侧详情面板"这种联动逻辑时非常有用。两个参数都可能为 nullptr——current 为 nullptr 表示没有条目被选中(比如列表被清空),previous 为 nullptr 表示之前没有选中任何条目。 +还有一个相关的信号是 currentItemChanged(QListWidgetItem *current, QListWidgetItem*previous),它在当前条目变化时发射,参数分别是新的当前条目和之前的当前条目。这个信号在实现"选中条目变化时更新右侧详情面板"这种联动逻辑时非常有用。两个参数都可能为 nullptr——current 为 nullptr 表示没有条目被选中(比如列表被清空),previous 为 nullptr 表示之前没有选中任何条目。 ### 3.3 QListWidgetItem 图标 / 复选框 / 自定义数据 diff --git a/tutorial/beginner/03-qtwidgets/48-qtreewidget-beginner.md b/tutorial/beginner/03-qtwidgets/48-qtreewidget-beginner.md index de52f34..4562c7f 100644 --- a/tutorial/beginner/03-qtwidgets/48-qtreewidget-beginner.md +++ b/tutorial/beginner/03-qtwidgets/48-qtreewidget-beginner.md @@ -76,7 +76,7 @@ treeWidget->addTopLevelItem(newRoot); insertTopLevelItem(int index, QTreeWidgetItem *item) 和 addTopLevelItem 类似,但可以指定插入位置。addTopLevelItem 总是追加到顶层节点列表的末尾,而 insertTopLevelItem 可以在指定位置插入。index 从 0 开始,如果 index 等于 topLevelItemCount(),效果等同于 addTopLevelItem。 -在子节点层面,QTreeWidgetItem 提供了 addChild(QTreeWidgetItem *child) 和 insertChild(int index, QTreeWidgetItem *child) 两个方法。addChild 追加到子节点列表末尾,insertChild 可以指定插入位置。这两个方法和顶层的 addTopLevelItem/insertTopLevelItem 是完全对称的。 +在子节点层面,QTreeWidgetItem 提供了 addChild(QTreeWidgetItem *child) 和 insertChild(int index, QTreeWidgetItem*child) 两个方法。addChild 追加到子节点列表末尾,insertChild 可以指定插入位置。这两个方法和顶层的 addTopLevelItem/insertTopLevelItem 是完全对称的。 ```cpp auto *parent = treeWidget->currentItem(); @@ -191,7 +191,7 @@ connect(treeWidget, &QTreeWidget::itemClicked, }); ``` -除了这三个信号,QTreeWidget 还有 currentItemChanged(QTreeWidgetItem *current, QTreeWidgetItem *previous)——当前选中节点变化时发射,用法和 QListWidget 的同名信号完全一致。itemDoubleClicked(QTreeWidgetItem *item, int column) 在双击节点时发射。itemChanged(QTreeWidgetItem *item, int column) 在节点数据变化时发射(比如你调用了 setText、setCheckState 等方法),和 QListWidget 的 itemChanged 一样需要注意 blockSignals 防止递归。 +除了这三个信号,QTreeWidget 还有 currentItemChanged(QTreeWidgetItem *current, QTreeWidgetItem*previous)——当前选中节点变化时发射,用法和 QListWidget 的同名信号完全一致。itemDoubleClicked(QTreeWidgetItem *item, int column) 在双击节点时发射。itemChanged(QTreeWidgetItem*item, int column) 在节点数据变化时发射(比如你调用了 setText、setCheckState 等方法),和 QListWidget 的 itemChanged 一样需要注意 blockSignals 防止递归。 ## 4. 踩坑预防 diff --git a/tutorial/beginner/03-qtwidgets/50-qtablewidget-beginner.md b/tutorial/beginner/03-qtwidgets/50-qtablewidget-beginner.md index 00b4aca..b11bf7f 100644 --- a/tutorial/beginner/03-qtwidgets/50-qtablewidget-beginner.md +++ b/tutorial/beginner/03-qtwidgets/50-qtablewidget-beginner.md @@ -184,7 +184,7 @@ connect(tableWidget, &QTableWidget::currentCellChanged, }); ``` -除了这三个核心信号,QTableWidget 还有 cellDoubleClicked(int row, int column)——双击单元格时发射;cellActivated(int row, int column)——在单元格上按回车或双击时发射(取决于激活策略);cellEntered(int row, int column)——鼠标移入单元格时发射(需要开启 MouseTracking)。currentItemChanged(QTableWidgetItem *current, QTableWidgetItem *previous) 也在当前 item 变化时发射,它传的是 item 指针而不是行列号。 +除了这三个核心信号,QTableWidget 还有 cellDoubleClicked(int row, int column)——双击单元格时发射;cellActivated(int row, int column)——在单元格上按回车或双击时发射(取决于激活策略);cellEntered(int row, int column)——鼠标移入单元格时发射(需要开启 MouseTracking)。currentItemChanged(QTableWidgetItem *current, QTableWidgetItem*previous) 也在当前 item 变化时发射,它传的是 item 指针而不是行列号。 ## 4. 踩坑预防 diff --git a/tutorial/beginner/03-qtwidgets/52-qheaderview-beginner.md b/tutorial/beginner/03-qtwidgets/52-qheaderview-beginner.md index 7217a91..f0b802a 100644 --- a/tutorial/beginner/03-qtwidgets/52-qheaderview-beginner.md +++ b/tutorial/beginner/03-qtwidgets/52-qheaderview-beginner.md @@ -264,7 +264,7 @@ auto *customHeader = new ColoredHeaderView(Qt::Horizontal, tableView); tableView->setHorizontalHeader(customHeader); ``` -setHorizontalHeader(QHeaderView *header) 会替换掉 QTableView 默认的水平表头。QTableView 会接管新 header 的所有权,并在析构时自动 delete 它。同理,setVerticalHeader(QHeaderView *header) 替换垂直表头。替换后,之前通过 horizontalHeader() 设置的属性(比如列宽策略、排序指示器)全部丢失——你需要在新 header 上重新设置。 +setHorizontalHeader(QHeaderView *header) 会替换掉 QTableView 默认的水平表头。QTableView 会接管新 header 的所有权,并在析构时自动 delete 它。同理,setVerticalHeader(QHeaderView*header) 替换垂直表头。替换后,之前通过 horizontalHeader() 设置的属性(比如列宽策略、排序指示器)全部丢失——你需要在新 header 上重新设置。 ## 4. 踩坑预防 diff --git a/tutorial/beginner/03-qtwidgets/57-qtoolbar-beginner.md b/tutorial/beginner/03-qtwidgets/57-qtoolbar-beginner.md index a295c82..8adc577 100644 --- a/tutorial/beginner/03-qtwidgets/57-qtoolbar-beginner.md +++ b/tutorial/beginner/03-qtwidgets/57-qtoolbar-beginner.md @@ -200,7 +200,7 @@ viewMenu->addAction(editBar->toggleViewAction()); 第四个坑是 macOS 上 QToolBar 的行为差异。macOS 的工具栏样式由系统统一管理,某些 QToolBar 的外观属性(比如分隔线的绘制方式、按钮的间距)在 macOS 上可能和其他平台不一致。如果你的应用需要跨平台,务必在 macOS 上做额外的视觉测试。 -第五个坑是多个工具栏停靠在同一侧时的顺序问题。当你通过多次 addToolBar 向同一侧添加多个工具栏时,它们的排列顺序默认是添加顺序——先添加的在上面(或左面),后添加的在下面(或右面)。用户可以通过拖拽来调整顺序,但程序化的顺序控制只能通过 insertToolBar(QToolBar *before, QToolBar *toolbar) 来实现——它把 toolbar 插入到 before 前面。 +第五个坑是多个工具栏停靠在同一侧时的顺序问题。当你通过多次 addToolBar 向同一侧添加多个工具栏时,它们的排列顺序默认是添加顺序——先添加的在上面(或左面),后添加的在下面(或右面)。用户可以通过拖拽来调整顺序,但程序化的顺序控制只能通过 insertToolBar(QToolBar *before, QToolBar*toolbar) 来实现——它把 toolbar 插入到 before 前面。 ## 5. 练习项目 diff --git a/tutorial/beginner/03-qtwidgets/59-qdockwidget-beginner.md b/tutorial/beginner/03-qtwidgets/59-qdockwidget-beginner.md index 6cb2105..01040b7 100644 --- a/tutorial/beginner/03-qtwidgets/59-qdockwidget-beginner.md +++ b/tutorial/beginner/03-qtwidgets/59-qdockwidget-beginner.md @@ -172,7 +172,7 @@ tabifyDockWidget(outlineDock, bookmarksDock); outlineDock->raise(); ``` -tabifyDockWidget(QDockWidget *first, QDockWidget *second) 把 second 作为一个新标签页添加到 first 所在的标签组中。如果 first 当前不是标签页形式(它独占一个位置),Qt 会自动把 first 转换成标签页模式,然后把 second 作为第二个标签页添加进去。tabifyDockWidget 可以被多次调用来添加更多标签页。 +tabifyDockWidget(QDockWidget *first, QDockWidget*second) 把 second 作为一个新标签页添加到 first 所在的标签组中。如果 first 当前不是标签页形式(它独占一个位置),Qt 会自动把 first 转换成标签页模式,然后把 second 作为第二个标签页添加进去。tabifyDockWidget 可以被多次调用来添加更多标签页。 raise() 让指定的 Dock 成为当前活动标签页。这在初始化时确定默认显示哪个标签页很有用——如果你不调用 raise(),最后一个被 tabifyDockWidget 的 Dock 会自动成为活动标签页。 diff --git a/tutorial/beginner/03-qtwidgets/69-qwizard-beginner.md b/tutorial/beginner/03-qtwidgets/69-qwizard-beginner.md index 2b3e95e..ce29875 100644 --- a/tutorial/beginner/03-qtwidgets/69-qwizard-beginner.md +++ b/tutorial/beginner/03-qtwidgets/69-qwizard-beginner.md @@ -216,7 +216,7 @@ private: 注意看 "name*" 这个字段名末尾的星号。在 QWizard 的约定中,字段名以星号结尾表示这是一个 mandatory(必填)字段。对于 mandatory 字段,QWizard 会监控它的值——当值为空时,"下一步"按钮保持灰色不可点击;当值非空时,"下一步"按钮变为可用。这个约定省去了手动重写 isComplete 的麻烦。字段名在存储时星号会被去掉,所以 field("name") 就能读到对应的值,不需要带星号。 -registerField 的完整签名是 registerField(const QString &name, QWidget *widget, const char *property = nullptr, const char *changedSignal = nullptr)。property 指定要绑定到控件的哪个 Qt 属性。对于 QLineEdit,默认属性就是 "text";对于 QComboBox,默认属性是 "currentText";对于 QCheckBox,默认属性是 "checked"。所以大部分情况下不需要显式指定 property 和 changedSignal——QWizard 会根据控件类型自动选择合理的默认值。但如果你绑定的控件不是常见类型,或者想绑定一个非默认属性(比如 QSpinBox 的 value 而不是 text),就需要显式指定: +registerField 的完整签名是 registerField(const QString &name, QWidget *widget, const char*property = nullptr, const char *changedSignal = nullptr)。property 指定要绑定到控件的哪个 Qt 属性。对于 QLineEdit,默认属性就是 "text";对于 QComboBox,默认属性是 "currentText";对于 QCheckBox,默认属性是 "checked"。所以大部分情况下不需要显式指定 property 和 changedSignal——QWizard 会根据控件类型自动选择合理的默认值。但如果你绑定的控件不是常见类型,或者想绑定一个非默认属性(比如 QSpinBox 的 value 而不是 text),就需要显式指定: ```cpp // QSpinBox 的 value 属性,通过 valueChanged 信号跟踪变化 diff --git a/tutorial/beginner/05-other-modules/11-qtnfc-beginner.md b/tutorial/beginner/05-other-modules/11-qtnfc-beginner.md index d16ae27..bb619a6 100644 --- a/tutorial/beginner/05-other-modules/11-qtnfc-beginner.md +++ b/tutorial/beginner/05-other-modules/11-qtnfc-beginner.md @@ -78,7 +78,7 @@ uriRecord.setUri(QUrl("https://doc.qt.io")); message.append(uriRecord); ``` -QNdefNfcTextRecord 用于存储文本数据,支持多语言(通过 locale 字段)和两种编码(UTF-8 和 UTF-16)。QNdefNfcUriRecord 用于存储 URI,NFC Forum 为 URI 定义了缩写前缀——比如 0x00 表示无前缀,0x01 表示 "http://www.",0x04 表示 "https://",这样可以节省标签存储空间。Qt 自动处理这些前缀。 +QNdefNfcTextRecord 用于存储文本数据,支持多语言(通过 locale 字段)和两种编码(UTF-8 和 UTF-16)。QNdefNfcUriRecord 用于存储 URI,NFC Forum 为 URI 定义了缩写前缀——比如 0x00 表示无前缀,0x01 表示 " 表示 "https://",这样可以节省标签存储空间。Qt 自动处理这些前缀。 写入 NDEF 消息到标签: diff --git a/tutorial/beginner/05-other-modules/22-qtremoteobjects-beginner.md b/tutorial/beginner/05-other-modules/22-qtremoteobjects-beginner.md index 1cc8944..72b1738 100644 --- a/tutorial/beginner/05-other-modules/22-qtremoteobjects-beginner.md +++ b/tutorial/beginner/05-other-modules/22-qtremoteobjects-beginner.md @@ -33,7 +33,7 @@ REPC(Remote Objects Protocol Compiler)文件是 Qt Remote Objects 的核心 下面是一个简单的 REPC 文件,定义了一个"传感器数据"远程对象: -``` +```cpp class SensorData { PROP(double temperature READONLY); @@ -166,7 +166,7 @@ target_link_libraries(${PROJECT_NAME} sensordata.rep 文件: -``` +```cpp class SensorData { PROP(double temperature READONLY); @@ -306,7 +306,7 @@ int main(int argc, char *argv[]) 运行程序后你会看到 Source 端每秒更新一次温度和湿度数据,Replica 端实时接收到温度变化的通知。大约 3 秒后 Replica 端发送 reset 请求,Source 端收到后重置所有数据。控制台输出大致如下: -``` +```text [Source] 更新: temp=24.3 hum=62.1 count=1 [Replica] 温度变化: 24.3 [Source] 更新: temp=29.1 hum=55.8 count=2 diff --git a/tutorial/beginner/06-qml/01-qml-syntax-basics-beginner.md b/tutorial/beginner/06-qml/01-qml-syntax-basics-beginner.md index 79b9bbe..cb6577a 100644 --- a/tutorial/beginner/06-qml/01-qml-syntax-basics-beginner.md +++ b/tutorial/beginner/06-qml/01-qml-syntax-basics-beginner.md @@ -481,7 +481,7 @@ int main(int argc, char *argv[]) 项目结构: -``` +```text 01-qml-syntax-basics-beginner/ CMakeLists.txt main.cpp diff --git a/tutorial/beginner/06-qml/02-property-binding-beginner.md b/tutorial/beginner/06-qml/02-property-binding-beginner.md index 8eddea4..7194991 100644 --- a/tutorial/beginner/06-qml/02-property-binding-beginner.md +++ b/tutorial/beginner/06-qml/02-property-binding-beginner.md @@ -414,7 +414,7 @@ Item { 项目结构: -``` +```text 02-property-binding-beginner/ CMakeLists.txt main.cpp diff --git a/tutorial/beginner/06-qml/03-qtquick-controls-beginner.md b/tutorial/beginner/06-qml/03-qtquick-controls-beginner.md index 525e903..bc9e403 100644 --- a/tutorial/beginner/06-qml/03-qtquick-controls-beginner.md +++ b/tutorial/beginner/06-qml/03-qtquick-controls-beginner.md @@ -727,7 +727,7 @@ ApplicationWindow { 项目结构: -``` +```text 03-qtquick-controls-beginner/ CMakeLists.txt main.cpp diff --git a/tutorial/beginner/index.md b/tutorial/beginner/index.md index 71e4d0e..6759317 100644 --- a/tutorial/beginner/index.md +++ b/tutorial/beginner/index.md @@ -20,4 +20,5 @@ description: "从零开始学习 Qt 6,覆盖核心模块的全部基础知识 ## 学习建议 + 建议按照模块顺序学习,先完成 00 环境搭建,再按 01→02→03→04 的顺序推进。05 其他扩展模块可以按需学习,06 QML 建议在学完 QtWidgets 后再开始。 diff --git a/tutorial/engineering/instances/widget/status-led/handbook/01-paint-and-status.md b/tutorial/engineering/instances/widget/status-led/handbook/01-paint-and-status.md index bcf5c46..1850b90 100644 --- a/tutorial/engineering/instances/widget/status-led/handbook/01-paint-and-status.md +++ b/tutorial/engineering/instances/widget/status-led/handbook/01-paint-and-status.md @@ -10,9 +10,11 @@ description: "重写 paintEvent 用 QRadialGradient 画高光圆盘,加 Status ## Step 1:画一个带高光的静态圆盘 ### 目标 + 屏幕上出现一个**绿色圆点**,带径向渐变高光(中心亮、边缘暗),有立体感,不是纯色平圆。 ### 提示 + - 继承 `QWidget`,重写 `protected void paintEvent(QPaintEvent*)` - `QPainter painter(this);` 开头,`painter.setRenderHint(QPainter::Antialiasing)` 抗锯齿 - 填充用 `QRadialGradient`,三档色:中心 `c.lighter(160)`、`0.6` 处原色 `c`、边缘 `c.darker(150)` @@ -20,11 +22,13 @@ description: "重写 paintEvent 用 QRadialGradient 画高光圆盘,加 Status - `drawEllipse(rect().center(), r, r)` 画圆 ### 检查点 + 跑出来是**有高光、边缘渐暗**的绿圆,缩放窗口圆点自适应 = 绘制对了。 > QPainter 不熟?先读 [QPainter 绘图基础](../../../../../beginner/02-qtgui/01-qpainter-basic-beginner.md) 和 [自定义绘制 Widget 基础](../../../../../beginner/03-qtwidgets/05-custom-widget-paint-beginner.md)。 ### 对照答案 + - 径向渐变三档色:`src/status_led.cpp:225-228` - 半径 clamp 兜底:`src/status_led.cpp:223` @@ -33,20 +37,24 @@ description: "重写 paintEvent 用 QRadialGradient 画高光圆盘,加 Status ## Step 2:Status 枚举 + 切色(先突变) ### 目标 + 定义 `enum class Status { NORMAL, WARNING, ERROR, OFFLINE }`,加 `setStatus(Status)`,调一下颜色就变。**这步先做突变**(直接换色,不要动画),过渡留到 step 4。 ### 提示 + - 枚举放在类里,紧跟 `Q_ENUM(Status)`——为后面 Q_PROPERTY 铺路(moc 要认得这个枚举) - 写私有 `statusColor(Status) const` 返回各状态代表色(绿 / 琥珀 / 红 / 灰) - `setStatus` 里改成员 `status_`、`update()`(异步请求重绘) - `paintEvent` 里用 `statusColor(status_)` 取当前色画 ### 检查点 + `setStatus(WARNING)` 后圆点变琥珀,`setStatus(ERROR)` 变红 = 切换对了(先不管有没有过渡动画)。 > 信号槽 / update 刷新机制不熟?[信号与槽](../../../../../beginner/01-qtbase/02-signal-slot-beginner.md)、[QWidget 基类](../../../../../beginner/03-qtwidgets/11-qwidget-base-beginner.md)。 ### 对照答案 + - statusColor 四态代表色:`src/status_led.cpp:24` - Q_ENUM 声明:`include/status_led.h:35-36` diff --git a/tutorial/engineering/instances/widget/status-led/handbook/02-color-transition.md b/tutorial/engineering/instances/widget/status-led/handbook/02-color-transition.md index a7c1963..91d51cf 100644 --- a/tutorial/engineering/instances/widget/status-led/handbook/02-color-transition.md +++ b/tutorial/engineering/instances/widget/status-led/handbook/02-color-transition.md @@ -12,19 +12,23 @@ description: "把突变色升级成 300ms 丝滑过渡:Q_PROPERTY 暴露 color ## Step 3:把 status 升级为 Q_PROPERTY ### 目标 + 给类加一行 `Q_PROPERTY(Status status READ status WRITE setStatus NOTIFY statusChanged)`,补上 `statusChanged` 信号。功能上和 step 2 一样,但 status 现在是「Qt 认识的属性」——能被元系统枚举、被 Designer 编辑、被动画按名字驱动。 ### 提示 + - Q_PROPERTY 三件:READ 一个 getter、WRITE 一个 setter、NOTIFY 一个信号 - setStatus 末尾 `emit statusChanged(s)` - 注意:**这步 WRITE 还指 setStatus**(业务入口)。step 4 加颜色属性时,color 的 WRITE 要指向一个**纯赋值的回调**——别搞混(见 [troubleshooting](./troubleshooting.md) 的「递归栈溢出」) ### 检查点 + 编译过(moc 不报错)+ `metaObject()->propertyCount()` 能数到 status = Q_PROPERTY 生效了。 > Q_PROPERTY 机制不熟?[QObject 与元对象系统](../../../../../beginner/01-qtbase/01-qobject-meta-system-beginner.md)、进阶 [属性系统深度拆解](../../../../../advanced/01-qtbase/01-qobject-property-system-advanced.md)。 ### 对照答案 + - Q_PROPERTY(status) 声明:`include/status_led.h:28` - statusChanged 信号:`include/status_led.h` signals 区 @@ -33,9 +37,11 @@ description: "把突变色升级成 300ms 丝滑过渡:Q_PROPERTY 暴露 color ## Step 4:颜色丝滑过渡(QPropertyAnimation 驱动 QColor) ### 目标 + 加 `Q_PROPERTY(QColor color ...)`,setStatus 时启动一个 `QPropertyAnimation` 把 color 从当前值过渡到目标状态色。切换时颜色 300ms 平滑渐变,不是突变。 ### 提示(按顺序) + 1. **新增成员 `QColor current_color_`**——这才是 paintEvent 实际画的色(不再直取 `statusColor(status_)`) 2. **新增 `setAnimatedColor(const QColor&)` 作为 color 的 WRITE 回调**:纯赋值 `current_color_ = c` + `emit colorChanged` + `update()`。这是动画每帧调的——**只赋值,不启动画** 3. **改 setStatus**:不再直接换色,而是 @@ -47,16 +53,19 @@ description: "把突变色升级成 300ms 丝滑过渡:Q_PROPERTY 暴露 color 5. paintEvent 改成画 `current_color_` ### 关键认知 + - **Qt 内置 QColor 插值**(RGB 线性),所以 `QPropertyAnimation(this, "color")` 能直接对颜色做动画,不用手写 lerp - **WRITE 指 setAnimatedColor(纯赋值)而非 setStatus(会启动画)**——否则动画驱动 setStatus → setStatus 又启动画 → 无限递归栈溢出 - **从 current_color_ 接力**而非从目标色重启——快速连切时颜色连续过渡,不跳变 ### 检查点 + 切换状态时颜色 **300ms 渐变过渡**(不是突变)= 动画对了。快速连点 Cycle 不崩、过渡不跳变 = 接力逻辑对了。 > 动画框架不熟?[属性动画框架基础](../../../../../beginner/03-qtwidgets/09-animation-framework-beginner.md)、进阶 [动画框架进阶](../../../../../advanced/03-qtwidgets/09-animation-advanced.md)。 ### 对照答案 + - color_anim_ 配置(duration/easing):`src/status_led.cpp:58-59` - setStatus 启动过渡(stop/setStart/setEnd/start):`src/status_led.cpp:98-101` - setAnimatedColor 回调(赋值+emit+update):`src/status_led.cpp:115-117` diff --git a/tutorial/engineering/instances/widget/status-led/handbook/03-blink-and-polish.md b/tutorial/engineering/instances/widget/status-led/handbook/03-blink-and-polish.md index bf44219..abaad20 100644 --- a/tutorial/engineering/instances/widget/status-led/handbook/03-blink-and-polish.md +++ b/tutorial/engineering/instances/widget/status-led/handbook/03-blink-and-polish.md @@ -10,15 +10,18 @@ description: "QVariantAnimation 做正弦呼吸、QTimer 做 OnOff 明灭,过 ## Step 5:BlinkMode(None / OnOff / Breathing) ### 目标 + 加 `enum class BlinkMode { None, OnOff, Breathing }`。OnOff 是老式生硬明灭(QTimer 翻转),Breathing 是正弦呼吸(QVariantAnimation 无限循环驱动一个 0..1 亮度因子)。 ### 提示 **OnOff(简单)**: + - `QTimer` 500ms,timeout 翻转一个 `onoff_visible_` 布尔 + `update()` - paintEvent 里 `if (!onoff_visible_) c = c.darker(400)` **Breathing(核心)**: + - 用 `QVariantAnimation`,`setLoopCount(-1)` 无限循环 - 配成 0→1→0:`setStartValue(0.0); setEndValue(0.0); setKeyValueAt(0.5, 1.0);` - `setEasingCurve(InOutSine)`——天然正弦感 @@ -26,6 +29,7 @@ description: "QVariantAnimation 做正弦呼吸、QTimer 做 OnOff 明灭,过 - paintEvent 里在 `base.darker(280)`(暗)↔ `base.lighter(140)`(亮)间按 factor 做 r/g/b 线性插值 ### 关键认知——为什么呼吸和过渡不打架 + - 过渡写 `current_color_`,呼吸写 `breathing_factor_`,**两个独立变量** - paintEvent 入口 `applyDisplayTransform(current_color_)` 才合成:先用过渡色做 base,再叠呼吸亮度 - 所以「边过渡颜色边呼吸」天然并行,不用为「过渡中要不要暂停呼吸」设计状态机 @@ -33,6 +37,7 @@ description: "QVariantAnimation 做正弦呼吸、QTimer 做 OnOff 明灭,过 抽个私有 `applyDisplayTransform(const QColor& base)` 专门干合成,paintEvent 只管画它的返回值。 ### 检查点 + - OnOff:500ms 一明一灭(生硬) - Breathing:亮度正弦式起伏(InOutSine 节奏,不是线性) - 同时开 Breathing 并切状态:颜色过渡 + 呼吸并行、不乱 = 解耦对了 @@ -40,6 +45,7 @@ description: "QVariantAnimation 做正弦呼吸、QTimer 做 OnOff 明灭,过 > 定时器不熟?[定时器](../../../../../beginner/01-qtbase/11-timer-beginner.md)、进阶 [定时器进阶](../../../../../advanced/01-qtbase/11-qtimer-advanced.md)。自定义控件进阶?[自定义 Widget 进阶](../../../../../advanced/03-qtwidgets/05-custom-widget-advanced.md)。 ### 对照答案 + - breathing 动画配置(0→1→0 + InOutSine + 无限循环):`src/status_led.cpp:65-70` - breathing 回调写 factor:`src/status_led.cpp:73-74` - setBlinkMode 分发(停所有 → 按模式启):`src/status_led.cpp:127` @@ -50,16 +56,20 @@ description: "QVariantAnimation 做正弦呼吸、QTimer 做 OnOff 明灭,过 ## Step 6:minimumSizeHint 收尾 ### 目标 + 实现 `minimumSizeHint()`,让布局系统知道这个控件最小能缩到多小。缩窗时 LED 不会被裁切到看不见。 ### 提示 + - 返回合理最小尺寸,比如 `std::max(8, led_size_ / 2)` - 配合已有 `sizeHint()`(返回 `led_size_`),布局系统就能在 sizeHint 和 minimumSizeHint 之间缩放 ### 检查点 + 把 LED 放进布局、缩窗口到很小:LED 缩到最小尺寸但不消失 / 不裁切成怪形状 = minimumSizeHint 生效了。 ### 对照答案 + - minimumSizeHint:`src/status_led.cpp:185-186` --- diff --git a/tutorial/expert/01-qtbase/01-cow-implicit-sharing-expert.md b/tutorial/expert/01-qtbase/01-cow-implicit-sharing-expert.md index 7b308f2..aa84538 100644 --- a/tutorial/expert/01-qtbase/01-cow-implicit-sharing-expert.md +++ b/tutorial/expert/01-qtbase/01-cow-implicit-sharing-expert.md @@ -54,6 +54,7 @@ COW 要回答的核心问题是:「这份数据现在有几个人在用?」 Qt 的做法是把引用计数操作委托给 `QBasicAtomicInteger`,底层就是 `std::atomic`。我们来看 `ref()` 的实现: > `qt_src/qt6.9.1/qtbase/src/corelib/thread/qatomic_cxx11.h:259` +> > ```cpp > return _q_value.fetch_add(1, std::memory_order_acq_rel) != T(-1); > ``` @@ -63,6 +64,7 @@ Qt 的做法是把引用计数操作委托给 `QBasicAtomicInteger`,底层就 再看 `deref()`: > `qt_src/qt6.9.1/qtbase/src/corelib/thread/qatomic_cxx11.h:266` +> > ```cpp > return _q_value.fetch_sub(1, std::memory_order_acq_rel) != T(1); > ``` @@ -74,6 +76,7 @@ Qt 的做法是把引用计数操作委托给 `QBasicAtomicInteger`,底层就 Qt 还在 `QBasicAtomicInt` 之上包了一层 `QtPrivate::RefCount`,加了「静态对象」的概念: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qrefcount.h:18-23` +> > ```cpp > inline bool ref() noexcept { > int count = atomic.loadRelaxed(); @@ -86,6 +89,7 @@ Qt 还在 `QBasicAtomicInt` 之上包了一层 `QtPrivate::RefCount`,加了「 RefCount::ref() 先用 loadRelaxed() 读一下当前值,如果是 -1(静态对象)就跳过递增直接返回 true。注意它始终返回 true,不管实际有没有递增。这是一个设计选择——调用者不需要知道是否是静态对象,只需要知道「ref 成功了,你不用管释放」。 > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qrefcount.h:25-30` +> > ```cpp > inline bool deref() noexcept { > int count = atomic.loadRelaxed(); @@ -108,6 +112,7 @@ deref() 对静态对象直接返回 true(「不用释放」),非静态对 这个头部叫 `QArrayData`,只有三个字段: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydata.h:42-44` +> > ```cpp > QBasicAtomicInt ref_; > ArrayOptions flags; @@ -130,6 +135,7 @@ flowchart TD 新分配的数据块,ref_ 初始化为 1: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydata.cpp:141` +> > ```cpp > header->ref_.storeRelaxed(1); > ``` @@ -139,6 +145,7 @@ flowchart TD 接下来是两个关键判断函数: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydata.h:69-80` +> > ```cpp > bool isShared() const noexcept > { @@ -163,6 +170,7 @@ QArrayData 只是一个头部,它本身不管生命周期。真正管理「什 它持有三个成员: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydatapointer.h:516-518` +> > ```cpp > Data *d; > T *ptr; @@ -196,6 +204,7 @@ QList list2 = list; // 发生了什么? ``` > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydatapointer.h:37-40` +> > ```cpp > QArrayDataPointer(const QArrayDataPointer &other) noexcept > : d(other.d), ptr(other.ptr), size(other.size) @@ -217,6 +226,7 @@ list2[0] = 99; // list2 触发 detach 当非 const 的 `operator[]` 被调用时,它会间接触发 `detach()`: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydatapointer.h:142-145` +> > ```cpp > void detach(QArrayDataPointer *old = nullptr) > { @@ -228,6 +238,7 @@ list2[0] = 99; // list2 触发 detach 先检查 `needsDetach()`——还记得吗,条件是 `!d || d->needsDetach()`。此时 ref_=2 > 1,返回 true,需要深拷贝。然后调用 `reallocateAndGrow()`: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydatapointer.h:218-250` +> > ```cpp > Q_NEVER_INLINE void reallocateAndGrow(QArrayData::GrowthPosition where, qsizetype n, > QArrayDataPointer *old = nullptr) @@ -258,6 +269,7 @@ list2[0] = 99; // list2 触发 detach **第四步:析构** > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydatapointer.h:106-111` +> > ```cpp > ~QArrayDataPointer() > { @@ -273,6 +285,7 @@ list2[0] = 99; // list2 触发 detach 赋值运算符也值得一提——Qt 用的是 copy-and-swap 惯用法: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydatapointer.h:69-73` +> > ```cpp > QArrayDataPointer &operator=(const QArrayDataPointer &other) noexcept > { @@ -305,6 +318,7 @@ flowchart LR 我们先看数据基类: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qshareddata.h:22-25` +> > ```cpp > mutable QAtomicInt ref; > // ... @@ -317,6 +331,7 @@ flowchart LR 再看智能指针。拷贝构造和析构是理解这条路线的关键: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qshareddata.h:66-67` +> > ```cpp > QSharedDataPointer(const QSharedDataPointer &o) noexcept : d(o.d) > { if (d) d->ref.ref(); } @@ -325,6 +340,7 @@ flowchart LR 浅拷贝 d 指针 + ref()。和 QArrayDataPointer 的拷贝构造完全对称。 > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qshareddata.h:57` +> > ```cpp > ~QSharedDataPointer() { if (d && !d->ref.deref()) delete d.get(); } > ``` @@ -334,6 +350,7 @@ flowchart LR 然后是 detach——QSharedDataPointer 的 detach 逻辑比 QArrayDataPointer 简单得多,因为没有「连续数组的 prepend/append 优化」需要处理: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qshareddata.h:41` +> > ```cpp > void detach() { if (d && d->ref.loadRelaxed() != 1) detach_helper(); } > ``` @@ -341,6 +358,7 @@ flowchart LR 条件是 `d != nullptr` 且 `ref != 1`。注意这里的判断是 `!= 1` 而不是 `> 1`——和 QArrayData 的 `needsDetach()`(`> 1`)不同。这意味着 ref==0 时也会触发 detach_helper。ref==0 出现在什么场景?当你用 `QAdoptSharedDataTag` 构造了一个没有递增引用计数的指针,然后对它做非 const 访问时。这种情况下 ref 是 0,detach_helper 会创建一个独立副本。 > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qshareddata.h:243-250` +> > ```cpp > Q_OUTOFLINE_TEMPLATE void QSharedDataPointer::detach_helper() > { @@ -357,6 +375,7 @@ flowchart LR 最后说一下 `QExplicitlySharedDataPointer`——它是 QSharedDataPointer 的「手动挡」版本: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qshareddata.h:131-139` +> > ```cpp > T &operator*() const { return *(d.get()); } > T *operator->() noexcept { return d.get(); } @@ -440,4 +459,3 @@ p1->value = 42; // 修改了共享数据 [Qt 文档 · QExplicitlySharedDataPointer](https://doc.qt.io/qt-6/qexplicitlyshareddatapointer.html) -- 显式共享智能指针(手动 detach) 到这里,Qt COW 机制的核心原理就拆完了。我们从最底层的 `fetch_add` 一路走到了 detach_helper 的 clone-deref-reset,看到了 Qt 为内置容器和用户自定义类设计的两条路线。下一篇我们会把这些机制放到具体的容器(QString、QByteArray、QList)里看——它们各自在哪些操作上触发 detach、const 和非 const 访问的实战差异、以及 QVarLengthArray 这种「不用 COW」的容器适合什么场景。 - diff --git a/tutorial/expert/01-qtbase/02-cow-container-practice-expert.md b/tutorial/expert/01-qtbase/02-cow-container-practice-expert.md index 267775c..17c1594 100644 --- a/tutorial/expert/01-qtbase/02-cow-container-practice-expert.md +++ b/tutorial/expert/01-qtbase/02-cow-container-practice-expert.md @@ -73,6 +73,7 @@ QString 是三个容器中最值得细看的,因为它有自己的 `reallocDat 先看拷贝构造——确认是 O(1) 的浅拷贝: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qstring.h:1340-1341` +> > ```cpp > QString::QString(const QString &other) noexcept : d(other.d) > { } @@ -83,6 +84,7 @@ QString 是三个容器中最值得细看的,因为它有自己的 `reallocDat 然后看 detach 的入口。非 const 的 `data()` 是最常见的触发点: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qstring.h:1326-1330` +> > ```cpp > QChar *QString::data() > { @@ -95,6 +97,7 @@ QString 是三个容器中最值得细看的,因为它有自己的 `reallocDat 第一行就调 `detach()`。如果 `d.needsDetach()` 返回 true(ref_ > 1),就进入 `reallocData()`: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qstring.cpp:2781-2802` +> > ```cpp > void QString::reallocData(qsizetype alloc, QArrayData::AllocationOption option) > { @@ -121,6 +124,7 @@ QString 是三个容器中最值得细看的,因为它有自己的 `reallocDat 非 const 的 `operator[]` 不直接调 detach(),而是通过 `data()` 间接触发: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qstring.h:1431-1432` +> > ```cpp > QChar &QString::operator[](qsizetype i) > { verify(i, 1); return data()[i]; } @@ -131,6 +135,7 @@ QString 是三个容器中最值得细看的,因为它有自己的 `reallocDat 非 const 的 `begin()` 和 `end()` 同样: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qstring.h:1435-1444` +> > ```cpp > QString::iterator QString::begin() > { detach(); return reinterpret_cast(d.data()); } @@ -143,6 +148,7 @@ QString 是三个容器中最值得细看的,因为它有自己的 `reallocDat 最后提一个细节——默认构造的 QString 不分配任何内存: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qstring.h:1410` +> > ```cpp > constexpr QString::QString() noexcept {} > ``` @@ -156,6 +162,7 @@ QByteArray 的 COW 实现跟 QString 是同一套模式,只把 QChar 换成了 拷贝构造: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qbytearray.h:634-635` +> > ```cpp > inline QByteArray::QByteArray(const QByteArray &a) noexcept : d(a.d) > {} @@ -166,6 +173,7 @@ QByteArray 的 COW 实现跟 QString 是同一套模式,只把 QChar 换成了 非 const data() 触发 detach: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qbytearray.h:616-620` +> > ```cpp > inline char *QByteArray::data() > { @@ -178,6 +186,7 @@ QByteArray 的 COW 实现跟 QString 是同一套模式,只把 QChar 换成了 const 版本不触发: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qbytearray.h:622-625` +> > ```cpp > inline const char *QByteArray::data() const noexcept > { @@ -189,6 +198,7 @@ const 版本不触发: `constData()` 就是 `data() const` 的别名: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qbytearray.h:124` +> > ```cpp > const char *constData() const noexcept { return data(); } > ``` @@ -204,6 +214,7 @@ QList 和 QString 有一个重要区别:QList 直接用 QArrayDataPointer 的 **detach 入口**——QList 的 `detach()` 就是一个直接委托: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qlist.h:457` +> > ```cpp > void detach() { d.detach(); } > ``` @@ -213,6 +224,7 @@ QList 和 QString 有一个重要区别:QList 直接用 QArrayDataPointer 的 **非 const begin()/end()**——和 QString 一样直接调 detach(): > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qlist.h:656-657` +> > ```cpp > iterator begin() { detach(); return iterator(d->begin()); } > iterator end() { detach(); return iterator(d->end()); } @@ -221,6 +233,7 @@ QList 和 QString 有一个重要区别:QList 直接用 QArrayDataPointer 的 const 版本不触发: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qlist.h:659-660` +> > ```cpp > const_iterator begin() const noexcept { return const_iterator(d->constBegin()); } > const_iterator end() const noexcept { return const_iterator(d->constEnd()); } @@ -231,6 +244,7 @@ const 版本不触发: **operator[]**——跟 QString 一样通过 data() 间接触发: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qlist.h:482-488` +> > ```cpp > reference operator[](qsizetype i) > { @@ -244,6 +258,7 @@ const 版本不触发: 注意源码注释:`// don't detach() here, we detach in data below`。Qt 的开发者故意不在 operator[] 里直接调 detach(),而是让 data() 去调——因为 data() 已经做了这件事,没必要重复。const 版本走 `at()`,完全安全: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qlist.h:477-479` +> > ```cpp > const_reference at(qsizetype i) const noexcept > { /* bounds check */ return d->data()[i]; } @@ -252,6 +267,7 @@ const 版本不触发: **replace() 的安全 detach**——QList 有一个特殊模式值得注意: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qlist.h:574-580` +> > ```cpp > void replace(qsizetype i, parameter_type t) > { @@ -267,6 +283,7 @@ const 版本不触发: **emplace 的快路径**——QList 的 `emplaceBack()` 有一个不需要 detach 的优化路径: > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydataops.h:142-167` +> > ```cpp > void emplace(qsizetype i, Args &&... args) > { @@ -362,6 +379,7 @@ for (auto it = list.cbegin(); it != list.cend(); ++it) { /* 纯读,零开销 * Qt 容器有一个特殊的构造方式:`fromRawData()`。它让容器指向一块外部内存,不拥有也不管理它的生命周期。 > `qt_src/qt6.9.1/qtbase/src/corelib/tools/qarraydatapointer.h:63` +> > ```cpp > static QArrayDataPointer fromRawData(const T *rawData, qsizetype length) noexcept > { @@ -392,12 +410,13 @@ QVarLengthArray 的策略是:在栈上预分配一块固定大小的缓冲区 第一个坑是 range-based for 循环的隐藏 detach。这个坑在篇 1 踩坑预防里提过,但这里从源码层面彻底解释清楚。当你写 `for (auto& c : str)` 时,编译器展开后调用的是非 const 的 `str.begin()` 和 `str.end()`。我们看到 QString 的实现: > `qt_src/qt6.9.1/qtbase/src/corelib/text/qstring.h:1435` +> > ```cpp > QString::iterator QString::begin() > { detach(); return reinterpret_cast(d.data()); } > ``` -`detach()` 在循环开始前被调用一次。如果 str 被共享(ref_ > 1),这一次调用就会触发 `memcpy` 拷贝整个字符串。循环本身不会再触发额外的 detach(因为 detach 之后 ref_ 变成 1),但如果你有多个地方对同一个共享字符串做 range-based for 循环(每个都触发一次 detach),性能就会在不知不觉中塌陷。后果是:程序变慢但不是崩溃,很难定位——因为每处代码「只多做了一次深拷贝」,看起来都合情合理。解法:任何不需要修改的遍历都用 `const auto&` 或 `std::as_const()`。 +`detach()` 在循环开始前被调用一次。如果 str 被共享(ref_> 1),这一次调用就会触发 `memcpy` 拷贝整个字符串。循环本身不会再触发额外的 detach(因为 detach 之后 ref_ 变成 1),但如果你有多个地方对同一个共享字符串做 range-based for 循环(每个都触发一次 detach),性能就会在不知不觉中塌陷。后果是:程序变慢但不是崩溃,很难定位——因为每处代码「只多做了一次深拷贝」,看起来都合情合理。解法:任何不需要修改的遍历都用 `const auto&` 或 `std::as_const()`。 第二个坑是 detach 后迭代器失效。篇 1 也提过,这里补充一个实战场景: @@ -407,7 +426,7 @@ auto it = list.begin(); // it 指向 list 的数据区 QList copy = list; // ref_ = 2 copy.push_back(4); // copy detach → 新内存 list.push_back(5); // list 也 detach → 又一块新内存 -*it = 99; // it 指向原始数据区,ref_=? +*it = 99; // it 指向原始数据区,ref_=? ``` 两次 detach 之后,原始数据块的 ref_ 从 2 → 1(copy detach 后)→ 0(list detach 后),被释放。`it` 变成悬垂指针,`*it = 99` 是未定义行为。在实际代码中这种 bug 更隐蔽——你可能在一个函数里持有迭代器,然后在另一个线程或者回调里对同一个容器的另一个引用做了修改。后果:segfault,或者读到垃圾数据,或者「在我机器上没问题」的幽灵 bug。解法:持有迭代器期间,确保不会有其他路径触发 detach。如果必须同时遍历和修改,先把容器拷贝一份独立副本。 diff --git a/tutorial/expert/code-index/qtbase/qarraydatapointer.md b/tutorial/expert/code-index/qtbase/qarraydatapointer.md index e9781ae..526f241 100644 --- a/tutorial/expert/code-index/qtbase/qarraydatapointer.md +++ b/tutorial/expert/code-index/qtbase/qarraydatapointer.md @@ -13,7 +13,7 @@ description: QArrayDataPointer 的三成员结构、完整生命周期(拷贝 | 论点 | 行号 | 原文摘要 | 解读 | |---|---|---|---| -| Data* d / T* ptr / qsizetype size | :516-518 | `Data *d; T *ptr; qsizetype size;` | d 指向 QArrayData 头部,ptr 指向实际数据首元素(prepend 时可能不紧跟头部),size 是有效元素数。 | +| Data*d / T* ptr / qsizetype size | :516-518 | `Data *d; T *ptr; qsizetype size;` | d 指向 QArrayData 头部,ptr 指向实际数据首元素(prepend 时可能不紧跟头部),size 是有效元素数。 | ## 拷贝构造(O(1) 浅拷贝) diff --git a/tutorial/public/carousel/expert.webp b/tutorial/public/carousel/expert.webp new file mode 100644 index 0000000..dc68b41 Binary files /dev/null and b/tutorial/public/carousel/expert.webp differ diff --git a/tutorial/public/carousel/status-led.webp b/tutorial/public/carousel/status-led.webp new file mode 100644 index 0000000..35ab454 Binary files /dev/null and b/tutorial/public/carousel/status-led.webp differ diff --git a/tutorial/public/carousel/toggle-switch.webp b/tutorial/public/carousel/toggle-switch.webp new file mode 100644 index 0000000..d3e97ab Binary files /dev/null and b/tutorial/public/carousel/toggle-switch.webp differ diff --git a/tutorial/public/carousel/tutorial.webp b/tutorial/public/carousel/tutorial.webp new file mode 100644 index 0000000..6240a0a Binary files /dev/null and b/tutorial/public/carousel/tutorial.webp differ diff --git a/tutorial/public/favicon.ico b/tutorial/public/favicon.ico new file mode 100644 index 0000000..36b7cd8 Binary files /dev/null and b/tutorial/public/favicon.ico differ diff --git a/tutorial/public/robots.txt b/tutorial/public/robots.txt new file mode 100644 index 0000000..42f8935 --- /dev/null +++ b/tutorial/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://awesome-embedded-learning-studio.github.io/Tutorial_AwesomeQt/sitemap.xml