From a9fe789a84cad2b61e153d344b82eb3a5aa900b2 Mon Sep 17 00:00:00 2001 From: lsamc <2322176165@qq.com> Date: Wed, 29 Jul 2026 16:41:40 +0800 Subject: [PATCH 1/2] fix: restore coupled v3.1 editions and CI --- .github/workflows/build.yml | 335 ++++++++++++++++++------ .github/workflows/ci.yml | 66 +++++ .gitignore | 67 ++--- APPLY.md | 31 --- CHANGELOG.md | 29 +- LICENSE | 201 ++++++++++++++ README.md | 126 ++++----- core.py | 508 +++++++++++++++++++++++------------- main.py | 5 +- pyproject.toml | 8 +- requirements-dev.txt | 2 + requirements.txt | 3 + tests/conftest.py | 6 +- tests/test_core.py | 322 +++++++++++++++-------- ui/__init__.py | 1 + ui/icons/chevron.svg | 3 + ui/icons/chevron_light.svg | 3 + ui/icons/file.svg | 8 + ui/icons/file_light.svg | 8 + ui/icons/folder.svg | 7 + ui/icons/folder_light.svg | 7 + ui/icons/moon.svg | 5 + ui/icons/sun.svg | 11 + ui/icons/undo.svg | 4 + ui/icons/undo_light.svg | 4 + ui/main_window.py | 295 +++++++++++---------- ui/styles.py | 227 ++++++++++++++++ ui/widgets.py | 111 ++++++++ version.py | 4 +- 29 files changed, 1732 insertions(+), 675 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 APPLY.md create mode 100644 LICENSE create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 ui/__init__.py create mode 100644 ui/icons/chevron.svg create mode 100644 ui/icons/chevron_light.svg create mode 100644 ui/icons/file.svg create mode 100644 ui/icons/file_light.svg create mode 100644 ui/icons/folder.svg create mode 100644 ui/icons/folder_light.svg create mode 100644 ui/icons/moon.svg create mode 100644 ui/icons/sun.svg create mode 100644 ui/icons/undo.svg create mode 100644 ui/icons/undo_light.svg create mode 100644 ui/styles.py create mode 100644 ui/widgets.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 67ba284..3d2e099 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,85 +1,250 @@ -name: Build Executables - -on: - push: - tags: - - 'v*' - workflow_dispatch: - -jobs: - build: - strategy: - matrix: - include: - - os: ubuntu-latest - name: Linux - build_name: 错题集生成器-linux - final_name: 错题集生成器-linux-x86_64.AppImage - - os: windows-latest - name: Windows - build_name: 错题集生成器-windows - final_name: 错题集生成器-windows-x86_64.exe - - runs-on: ${{ matrix.os }} - name: Build for ${{ matrix.name }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Build with PyInstaller (Linux) - if: matrix.os == 'ubuntu-latest' - run: | - pyinstaller --onefile --windowed --name="${{ matrix.build_name }}" --add-data="ui/icons:ui/icons" main.py - mv "dist/${{ matrix.build_name }}" "dist/${{ matrix.final_name }}" - - - name: Build with PyInstaller (Windows) - if: matrix.os == 'windows-latest' - run: | - pyinstaller --onefile --windowed --name="${{ matrix.build_name }}" --add-data="ui/icons;ui/icons" main.py - mv "dist/${{ matrix.build_name }}.exe" "dist/${{ matrix.final_name }}" - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.final_name }} - path: dist/${{ matrix.final_name }} - - release: - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - - steps: - - name: Download Linux artifact - uses: actions/download-artifact@v4 - with: - name: 错题集生成器-linux-x86_64.AppImage - path: ./artifacts - - - name: Download Windows artifact - uses: actions/download-artifact@v4 - with: - name: 错题集生成器-windows-x86_64.exe - path: ./artifacts - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - files: ./artifacts/* - draft: false - prerelease: false - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +name: Build and Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +concurrency: + group: build-${{ github.ref }} + cancel-in-progress: false + +jobs: + test: + name: Test before packaging + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: | + pyproject.toml + requirements*.txt + + - name: Install project and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Verify project and tag versions + run: | + python - <<'PY' + import os + import tomllib + from pathlib import Path + from version import APP_VERSION + + with Path("pyproject.toml").open("rb") as file: + project_version = tomllib.load(file)["project"]["version"] + + if project_version != APP_VERSION: + raise SystemExit( + f"Version mismatch: version.py={APP_VERSION}, " + f"pyproject.toml={project_version}" + ) + + if os.environ.get("GITHUB_REF_TYPE") == "tag": + tag_version = os.environ["GITHUB_REF_NAME"].removeprefix("v") + if tag_version != APP_VERSION: + raise SystemExit( + f"Tag mismatch: tag={tag_version}, project={APP_VERSION}" + ) + print(f"Packaging version {APP_VERSION}") + PY + + - name: Compile Python sources + run: python -m compileall -q core.py main.py version.py ui tests + + - name: Run tests + run: python -m pytest -q + + python-package: + name: Build Python package + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build frontend + run: | + python -m pip install --upgrade pip + python -m pip install build + + - name: Build wheel and source distribution + run: python -m build + + - name: Validate wheel contents + run: | + python - <<'PY' + import glob + import zipfile + + wheels = glob.glob("dist/*.whl") + if len(wheels) != 1: + raise SystemExit(f"Expected one wheel, found: {wheels}") + + required_suffixes = ("core.py", "main.py", "version.py", "ui/main_window.py") + with zipfile.ZipFile(wheels[0]) as archive: + names = archive.namelist() + + missing = [ + suffix + for suffix in required_suffixes + if not any(name.endswith(suffix) for name in names) + ] + if missing: + raise SystemExit(f"Wheel is missing runtime files: {missing}") + print(f"Validated {wheels[0]}") + PY + + - name: Upload Python package artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package + path: | + dist/*.whl + dist/*.tar.gz + if-no-files-found: error + + executable: + name: Build ${{ matrix.display-name }} executable + needs: test + runs-on: ${{ matrix.os }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + display-name: Windows + artifact-name: windows-x86_64 + - os: ubuntu-latest + display-name: Linux + artifact-name: linux-x86_64 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: | + pyproject.toml + requirements*.txt + + - name: Install project and PyInstaller + run: | + python -m pip install --upgrade pip + python -m pip install . pyinstaller + + - name: Build Windows executable + if: runner.os == 'Windows' + shell: pwsh + run: | + pyinstaller ` + --noconfirm ` + --clean ` + --onefile ` + --windowed ` + --name "错题集生成器-windows-x86_64" ` + --add-data "ui/icons;ui/icons" ` + main.py + + $artifact = "dist/错题集生成器-windows-x86_64.exe" + if (-not (Test-Path $artifact)) { + throw "Windows executable was not created: $artifact" + } + if ((Get-Item $artifact).Length -lt 1000000) { + throw "Windows executable is unexpectedly small" + } + $hash = (Get-FileHash $artifact -Algorithm SHA256).Hash.ToLower() + "$hash 错题集生成器-windows-x86_64.exe" | + Set-Content -Encoding ascii "$artifact.sha256" + + - name: Build Linux executable + if: runner.os == 'Linux' + shell: bash + run: | + pyinstaller \ + --noconfirm \ + --clean \ + --onefile \ + --windowed \ + --name "错题集生成器-linux-x86_64" \ + --add-data "ui/icons:ui/icons" \ + main.py + + test -s "dist/错题集生成器-linux-x86_64" + test "$(stat -c%s 'dist/错题集生成器-linux-x86_64')" -ge 1000000 + sha256sum "dist/错题集生成器-linux-x86_64" > \ + "dist/错题集生成器-linux-x86_64.sha256" + tar -C dist -czf "dist/错题集生成器-linux-x86_64.tar.gz" \ + "错题集生成器-linux-x86_64" \ + "错题集生成器-linux-x86_64.sha256" + + - name: Upload Windows artifact + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact-name }} + path: | + dist/错题集生成器-windows-x86_64.exe + dist/错题集生成器-windows-x86_64.exe.sha256 + if-no-files-found: error + + - name: Upload Linux artifact + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact-name }} + path: dist/错题集生成器-linux-x86_64.tar.gz + if-no-files-found: error + + release: + name: Publish GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + needs: [python-package, executable] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: release-assets + merge-multiple: true + + - name: List release assets + run: find release-assets -maxdepth 2 -type f -print + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + files: release-assets/* + generate_release_notes: true + draft: false + prerelease: false + fail_on_unmatched_files: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2c0753d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Python ${{ matrix.python-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: | + pyproject.toml + requirements*.txt + + - name: Install project and test dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Check version metadata + run: | + python - <<'PY' + import tomllib + from pathlib import Path + from version import APP_VERSION + + with Path("pyproject.toml").open("rb") as file: + project_version = tomllib.load(file)["project"]["version"] + + if project_version != APP_VERSION: + raise SystemExit( + f"Version mismatch: version.py={APP_VERSION}, " + f"pyproject.toml={project_version}" + ) + print(f"Version metadata is consistent: {APP_VERSION}") + PY + + - name: Compile Python sources + run: python -m compileall -q core.py main.py version.py ui tests + + - name: Run tests + run: python -m pytest -q diff --git a/.gitignore b/.gitignore index fb8c605..cb1d2ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,35 @@ -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -.venv/ -venv/ -ENV/ - -# Distribution / packaging -build/ -dist/ -*.egg-info/ -*.spec - -# IDE -.idea/ -.vscode/ -*.swp -*.swo - -# Project specific -config.ini -学生/ -backup/ -log/ -记录.docx - -# OS -.DS_Store -Thumbs.db +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ + +# Distribution / packaging +build/ +dist/ +*.egg-info/ +*.spec + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Project specific +config.ini +学生/ +backup/ +log/ +记录.docx + +# OS +.DS_Store +Thumbs.db +.pytest_cache/ +.coverage +htmlcov/ diff --git a/APPLY.md b/APPLY.md deleted file mode 100644 index 0b1d06d..0000000 --- a/APPLY.md +++ /dev/null @@ -1,31 +0,0 @@ -# 应用 v3.1 修改 - -本压缩包只包含新增或需要替换的文件,不包含仓库原有图标、截图和许可证等未改动资源。 - -在仓库根目录执行: - -```bash -# 建议先创建分支 -git switch -c feat/v3.1-coupled-student-teacher-output - -# 将压缩包解压后,覆盖到仓库根目录 -# Windows 可直接在文件管理器中合并覆盖;Linux/macOS 示例: -unzip -o problems-folder-generator-v3.1-changes.zip -d . - -python -m compileall -q core.py main.py version.py ui -pip install pytest pillow openpyxl python-docx -pytest -q - -git add core.py main.py version.py ui/main_window.py README.md CHANGELOG.md \ - pyproject.toml tests .github/workflows - -git commit -m "feat: generate coupled student and teacher versions" -git push -u origin feat/v3.1-coupled-student-teacher-output -``` - -输出结构: - -```text -学生/<姓名>/<科目>学生版/第X周错题集.docx -学生/<姓名>/<科目>教师版/第X周错题集.docx -``` diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b7488..d9560ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,21 @@ # Changelog -## 3.1.0 - -- 新增学生版与教师版两套图片目录输入。 -- 一次读取反馈表,同时生成每名学生的两版错题集。 -- 输出目录改为 `[科目]学生版` 与 `[科目]教师版`,便于按学生整体打包。 -- 校验两套图片的题号集合必须一一对应。 -- `全对`、`未交`、`未带`和普通错题号同步应用于两版。 -- 备份与恢复覆盖完整学生目录中的两版文件。 -- 配置文件分别记忆两套图片目录,并兼容迁移 v3.0 的单目录配置。 -- 新增核心回归测试和 GitHub Actions 测试工作流。 +## 3.1.0 - 2026-07-29 + +### Added + +- 学生版和教师版两套图片目录输入。 +- 一次读取反馈表,同时为每名学生生成两版错题集。 +- 输出目录采用 `[科目]学生版` 与 `[科目]教师版`,便于按学生整体打包。 +- 两套图片题号集合一致性和重复题号校验。 +- push/PR 多版本测试 CI。 +- Windows、Linux、wheel、sdist 和 GitHub Release 自动构建。 +- 双图片隔离、特殊状态、缺图、追加和备份恢复回归测试。 + +### Changed + +- `全对`、`未交`、`未带`同步应用到两版。 +- 备份与恢复覆盖完整 `学生/` 家长交付目录。 +- v3.0 单目录配置自动迁移为学生版目录。 +- 文档保存先写临时文件,再替换正式文件。 +- 版本号统一由 `version.py` 与 `pyproject.toml` 管理。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 014c140..779fdff 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,17 @@ # 错题集生成器 v3.1 -一款面向教育培训场景的桌面工具,可根据错题反馈表,为每位学生同时生成**学生版**和**教师版**两份错题集。 +面向教育培训场景的桌面工具。根据一份 Excel 错题反馈表和两套一一对应的题目图片,一次为每名学生生成学生版与教师版错题集。 -## v3.1 核心变化 +## v3.1 的核心变化 -v3.1 恢复并整合了旧版的实际工作流:过去需要将科目分别填写为“物理学生版”和“物理教师版”,切换两套图片并运行两次;现在只需选择两套一一对应的图片文件夹,点击一次即可完成两版生成。 +旧版需要分别运行两次,并把科目填写为“物理学生版”和“物理教师版”。v3.1 将这一流程整合为一次操作: -每名学生的输出结构如下: +- 选择学生版图片文件夹; +- 选择教师版图片文件夹; +- 读取一次错题反馈表; +- 每位学生同时得到两份结构相同、图片来源不同的文档。 + +输出结构: ```text 学生/ @@ -17,43 +22,24 @@ v3.1 恢复并整合了旧版的实际工作流:过去需要将科目分别填 └── 第3周错题集.docx ``` -因此可以直接打包学生姓名目录发给家长。 - -## 功能特性 - -- **双版本同步生成**:同一份反馈表一次生成学生版和教师版 -- **两套图片严格对应**:启动生成前校验两边题号集合是否一致 -- **同结构不同图片**:两版使用相同学生、错题号、日期和排版,只替换图片来源 -- **多科目支持**:数学、物理、化学、生物 -- **特殊状态同步**:`全对`、`未交`、`未带`同时应用于两版 -- **备份恢复**:每次操作前备份完整 `学生` 目录,可同时恢复两版文件 -- **配置记忆**:保存两套图片目录、反馈表、班级、日期和周数 -- **操作日志**:记录两套输入目录和每份文档的生成结果 - -## 快速开始 +因此可以直接压缩整个学生姓名目录发给家长。 -### 环境要求 +## 功能 -- Python 3.10+ -- Windows / Linux / macOS - -### 安装与运行 - -```bash -git clone https://github.com/Leonardlsa/problems-folder-generator.git -cd problems-folder-generator -python -m venv .venv -source .venv/bin/activate # Linux/macOS -# .venv\Scripts\activate # Windows -pip install -r requirements.txt -python main.py -``` +- 两套图片按题号严格对应,生成前自动校验; +- 支持 PNG、JPG、JPEG,文件名使用 `1.png`、`01.png` 等数字格式; +- 同一目录中重复题号会被拒绝,避免误用图片; +- 支持普通错题号、`全对`、`未交`、`未带`; +- 同一周后续日期会追加到已有文档; +- 每次生成前备份完整 `学生/` 目录,可同时恢复两版; +- 自动迁移 v3.0 的单图片目录配置和旧备份; +- 深色/浅色主题、配置记忆和生成日志。 -## 使用说明 +## 输入格式 -### 1. 准备两套题目图片 +### 两套图片 -学生版与教师版图片必须使用相同题号,扩展名可以不同: +两套目录必须具有相同题号,但图片内容可以不同: ```text 学生版图片/ 教师版图片/ @@ -62,60 +48,61 @@ python main.py └── 03.png └── 03.png ``` -文件名支持 `1.png`、`01.png`、`01.jpg`、`01.jpeg` 等数字格式。若同一目录内同时出现 `1.png` 和 `01.jpg`,会被视为重复题号并停止生成。 +### 错题反馈表 -### 2. 准备错题反馈表 - -使用 `.xlsx` 文件,第一列为姓名,第二列为错题号: +使用 `.xlsx` 文件,第一列为姓名,第二列为错题信息: | 姓名 | 错题号 | -|------|--------| +|---|---| | 张三 | 1,3,5 | | 李四 | 全对 | | 王五 | 未交 | -错题号支持: +错题号支持中英文逗号或空格分隔。`未交`、`未带`会在两版中插入各自目录的全部题目图片。 -- 数字列表:`1,3,5`、`1,3,5` 或空格分隔 -- `全对` -- `未交` / `未带`:两版均插入各自目录中的全部题目图片 +## 安装与运行 -### 3. 一次生成两版 +要求 Python 3.10+。 -1. 选择科目并填写班级 -2. 输入日期(MMDD)和周数 -3. 分别选择学生版图片目录、教师版图片目录 -4. 选择错题反馈表 -5. 点击 **GO!** +```bash +git clone https://github.com/Leonardlsa/problems-folder-generator.git +cd problems-folder-generator +python -m venv .venv -程序将输出: +# Linux / macOS +source .venv/bin/activate -```text -学生/[姓名]/[科目]学生版/第X周错题集.docx -学生/[姓名]/[科目]教师版/第X周错题集.docx -``` +# Windows PowerShell +# .venv\Scripts\Activate.ps1 -若同一周文档已经存在,新日期内容会继续追加到原文档。 +pip install -r requirements.txt +python main.py +``` ## 测试 ```bash -pip install -e '.[dev]' +pip install -e ".[dev]" pytest -q ``` -测试覆盖两版图片隔离、特殊状态、题号对应校验、缺图取消、历史追加和完整目录备份。 +测试覆盖两套图片隔离、题号对应校验、特殊状态、缺图取消、周内追加、双版本备份恢复和 v3.0 旧备份兼容。 -## 构建可执行文件 +## CI 与发布 -```bash -pip install pyinstaller +- 每次 push 到 `master` 或创建 PR 时,CI 会在 Python 3.10、3.11、3.12 上运行编译检查和测试。 +- 推送 `v*` 标签时,GitHub Actions 会先测试,再构建: + - Windows x86_64 单文件 EXE; + - Linux x86_64 可执行文件压缩包; + - Python wheel 和 source distribution; + - SHA-256 校验文件; + - GitHub Release。 -# Windows -pyinstaller --onefile --windowed --name="错题集生成器" --add-data="ui/icons;ui/icons" main.py +发布 v3.1: -# Linux -pyinstaller --onefile --windowed --name="cuotiji-generator" --add-data="ui/icons:ui/icons" main.py +```bash +git tag -a v3.1.0 -m "Problems Folder Generator v3.1.0" +git push origin v3.1.0 ``` ## 项目结构 @@ -127,9 +114,10 @@ PFG/ ├── version.py ├── ui/ ├── tests/ +├── .github/workflows/ ├── requirements.txt -├── pyproject.toml -└── LICENSE +├── requirements-dev.txt +└── pyproject.toml ``` ## 许可证 @@ -138,4 +126,4 @@ PFG/ ## 致谢 -项目初版由 **Leonard** 于 2022 年在 **武汉天任教育** 开发,**Lrj** 参与命令行初版开发。v3.1 将旧版通过“科目学生版/教师版”区分两套材料的方式整合为一次生成流程。 +项目初版由 **Leonard** 于 2022 年在 **武汉天任教育** 开发,**Lrj** 参与命令行初版开发。v3.1 恢复并整合了旧版通过“科目学生版/科目教师版”区分两套材料的实际工作流。 diff --git a/core.py b/core.py index 2c61896..521c39f 100644 --- a/core.py +++ b/core.py @@ -1,47 +1,60 @@ -"""核心生成逻辑 - 错题集生成器 v3.1。""" +"""Core generation logic for 错题集生成器 v3.1.""" from __future__ import annotations import os import re import shutil +import tempfile +from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import Callable, Protocol +from typing import Protocol import openpyxl from docx import Document +from docx.document import Document as DocumentType from docx.enum.text import WD_PARAGRAPH_ALIGNMENT from docx.shared import Cm from openpyxl.utils.exceptions import InvalidFileException -from version import __version__ +from version import APP_VERSION -APP_VERSION = __version__ SPLIT_PATTERN = r"[,,\s]+" IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg"} -VERSION_SUFFIXES = (("学生版", "student"), ("教师版", "teacher")) class GenerateError(Exception): - """生成过程中的可预期错误。""" + """Raised when generation cannot continue safely.""" -class LogTarget(Protocol): +class GenerateWidget(Protocol): + """Minimal UI surface required by the core generator.""" + textBrowser: object + def confirm_missing_images( + self, missing_by_student: dict[str, list[int]] + ) -> bool: ... + @dataclass(frozen=True) -class Student: +class StudentRecord: + """Normalized feedback for one student.""" + name: str - problem_text: str + raw_feedback: str + problem_numbers: tuple[int, ...] + status: str -def _log_func(widget: LogTarget) -> Callable[[str], None]: - try: - return widget.textBrowser.append - except AttributeError as exc: - raise GenerateError("内部错误:日志组件不可用") from exc +@dataclass(frozen=True) +class GenerationResult: + """Paths written by one successful coupled generation.""" + + student_version_paths: tuple[str, ...] + teacher_version_paths: tuple[str, ...] + record_path: str def _validate_excel(file_path: str) -> None: @@ -52,27 +65,22 @@ def _validate_excel(file_path: str) -> None: raise GenerateError(f"不支持的文件格式: {path.suffix},请使用 .xlsx 文件") -def _image_number(file_name: str) -> int | None: - stem = Path(file_name).stem - if not stem.isdigit(): - return None - number = int(stem) - return number if number > 0 else None - - -def _validate_folder(folder_path: str, label: str) -> dict[int, str]: - """验证截图目录并返回题号到图片路径的映射。""" +def _discover_images(folder_path: str, label: str) -> dict[int, str]: + """Return a strict problem-number-to-image mapping for one edition.""" folder = Path(folder_path) if not folder.is_dir(): - raise GenerateError(f"{label}截图文件夹不存在: {folder_path}") + raise GenerateError(f"{label}图片文件夹不存在: {folder_path}") images: dict[int, str] = {} duplicates: list[int] = [] - for entry in folder.iterdir(): + for entry in sorted(folder.iterdir(), key=lambda item: item.name.lower()): if not entry.is_file() or entry.suffix.lower() not in IMAGE_EXTENSIONS: continue - number = _image_number(entry.name) - if number is None: + if not entry.stem.isdigit(): + continue + + number = int(entry.stem) + if number <= 0: continue if number in images: duplicates.append(number) @@ -80,12 +88,15 @@ def _validate_folder(folder_path: str, label: str) -> dict[int, str]: images[number] = str(entry) if duplicates: - duplicate_text = ", ".join(str(number) for number in sorted(set(duplicates))) - raise GenerateError(f"{label}截图文件夹存在重复题号: {duplicate_text}") + duplicate_text = "、".join(map(str, sorted(set(duplicates)))) + raise GenerateError( + f"{label}图片文件夹存在重复题号: {duplicate_text}。" + "请确保每道题只有一张图片。" + ) if not images: raise GenerateError( - f"{label}截图文件夹中没有找到有效题目图片" - "(请使用 01.png、02.jpg 等数字文件名)" + f"{label}图片文件夹中没有有效题目图片。" + "请使用 01.png、02.jpg 等数字文件名。" ) return dict(sorted(images.items())) @@ -99,35 +110,41 @@ def _validate_corresponding_images( return details: list[str] = [] - only_student = sorted(student_numbers - teacher_numbers) - only_teacher = sorted(teacher_numbers - student_numbers) - if only_student: - details.append("仅学生版有: " + ", ".join(map(str, only_student))) - if only_teacher: - details.append("仅教师版有: " + ", ".join(map(str, only_teacher))) + student_only = sorted(student_numbers - teacher_numbers) + teacher_only = sorted(teacher_numbers - student_numbers) + if student_only: + details.append("仅学生版有: " + "、".join(map(str, student_only))) + if teacher_only: + details.append("仅教师版有: " + "、".join(map(str, teacher_only))) raise GenerateError("学生版与教师版图片题号不对应;" + ";".join(details)) -def _parse_problem_numbers(problem_text: str) -> list[int]: - if not problem_text or problem_text.strip() in ("", "None"): +def _parse_problem_numbers(value: str) -> list[int]: + """Parse a feedback cell into unique positive problem numbers.""" + if not value or value.strip() in ("", "None"): return [] numbers: list[int] = [] - for part in re.split(SPLIT_PATTERN, problem_text.strip()): + seen: set[int] = set() + for part in re.split(SPLIT_PATTERN, value.strip()): if not part: continue try: number = int(float(part)) except ValueError: continue - if number > 0 and number not in numbers: + if number > 0 and number not in seen: numbers.append(number) + seen.add(number) return numbers -def _read_students(feedback_file: str) -> list[Student]: +def _read_feedback( + file_path: str, all_problem_numbers: tuple[int, ...] +) -> list[StudentRecord]: + """Read and normalize the first worksheet of the feedback workbook.""" try: - workbook = openpyxl.load_workbook(feedback_file, read_only=True, data_only=True) + workbook = openpyxl.load_workbook(file_path, read_only=True, data_only=True) except InvalidFileException as exc: raise GenerateError(f"无法打开 Excel 文件: {exc}") from exc except Exception as exc: @@ -141,132 +158,222 @@ def _read_students(feedback_file: str) -> list[Student]: if len(rows) < 2: raise GenerateError("错题反馈表为空或只有表头") - students: list[Student] = [] + records: list[StudentRecord] = [] + seen_names: set[str] = set() + duplicate_names: list[str] = [] + for row in rows[1:]: if not row or row[0] is None or not str(row[0]).strip(): continue - name = str(row[0]).strip() - problem_text = str(row[1]).strip() if len(row) > 1 and row[1] is not None else "" - students.append(Student(name=name, problem_text=problem_text)) - if not students: + name = str(row[0]).strip() + if name in seen_names: + duplicate_names.append(name) + continue + seen_names.add(name) + + raw = str(row[1]).strip() if len(row) > 1 and row[1] is not None else "" + if raw in ("未带", "未交"): + status = "unsubmitted" + numbers = all_problem_numbers + elif raw == "全对": + status = "all_correct" + numbers = () + elif raw in ("", "None", "无"): + status = "empty" + numbers = () + else: + parsed = tuple(_parse_problem_numbers(raw)) + status = "errors" if parsed else "invalid" + numbers = parsed + + records.append(StudentRecord(name, raw, tuple(numbers), status)) + + if duplicate_names: + names = "、".join(dict.fromkeys(duplicate_names)) + raise GenerateError(f"错题反馈表中存在重复姓名: {names}") + if not records: raise GenerateError("错题反馈表中没有找到有效的学生数据") - return students + return records -def _backup_students(student_dir: Path, backup_dir: Path, log: Callable[[str], None]) -> None: - if backup_dir.exists(): - shutil.rmtree(backup_dir) - if student_dir.exists(): - shutil.copytree(student_dir, backup_dir) - log("已创建学生目录备份") - else: - log("学生文件夹不存在,跳过备份") - - -def _new_student_doc(classes: str, name: str, version_subject: str, week: str) -> Document: - doc = Document() - section = doc.sections[0] +def _new_document(header_text: str) -> DocumentType: + document = Document() + section = document.sections[0] section.top_margin = Cm(1.27) section.bottom_margin = Cm(1.27) section.left_margin = Cm(1.27) section.right_margin = Cm(1.27) - header = section.header.paragraphs[0] - header.text = f"{classes} {name} {version_subject} 第{week}周" + header.text = header_text header.alignment = WD_PARAGRAPH_ALIGNMENT.RIGHT - return doc + return document -def _load_or_create_doc( - path: Path, classes: str, name: str, version_subject: str, week: str -) -> Document: +def _load_document( + path: Path, + classes: str, + name: str, + edition_subject: str, + weeknumber: str, +) -> DocumentType: if path.exists(): - return Document(str(path)) - return _new_student_doc(classes, name, version_subject, week) + return Document(path) + return _new_document( + f"{classes} {name} {edition_subject} 第{weeknumber}周" + ) + +def _format_missing_message(missing_by_student: dict[str, list[int]]) -> str: + lines = ["以下错题在学生版和教师版图片中均不存在:"] + for name, numbers in list(missing_by_student.items())[:10]: + lines.append(f"{name}: {', '.join(map(str, numbers))}") + if len(missing_by_student) > 10: + lines.append(f"……另有 {len(missing_by_student) - 10} 名学生") + lines.append("\n是否忽略缺失图片并继续生成两版文档?") + return "\n".join(lines) -def _confirm_missing(widget: LogTarget, name: str, version_subject: str, missing: list[int]) -> bool: - """询问是否忽略缺图;无 UI 确认能力时默认终止,避免静默漏题。""" + +def _confirm_missing_images( + widget: GenerateWidget, missing_by_student: dict[str, list[int]] +) -> None: + if not missing_by_student: + return callback = getattr(widget, "confirm_missing_images", None) - if callback is None: - return False - return bool(callback(name, version_subject, missing)) + if not callable(callback) or not callback(missing_by_student): + raise GenerateError("用户取消:反馈表中包含缺少图片的题号") def _append_student_content( - doc: Document, - student: Student, + document: DocumentType, + record: StudentRecord, + image_paths: dict[int, str], date: str, - version_subject: str, - images: dict[int, str], - widget: LogTarget, + edition_subject: str, log: Callable[[str], None], - missing_confirmed: bool = False, ) -> None: - doc.add_heading(date, level=1) - problem_text = student.problem_text.strip() + document.add_heading(date, level=1) - if problem_text in ("未带", "未交"): - log(f"{student.name} {date}日 {version_subject} {problem_text}") - for image_path in images.values(): - doc.add_picture(image_path, width=Cm(18)) + if record.status == "all_correct": + document.add_paragraph("全对") + log(f"{record.name} {date}日 {edition_subject} 全对") return - if problem_text == "全对": - log(f"{student.name} {date}日 {version_subject} 全对") - doc.add_paragraph("全对") + if record.status == "empty": + document.add_paragraph("(无错题信息)") + log(f"警告:{record.name} 的错题信息为空") return - if problem_text in ("", "None", "无"): - log(f"警告:{student.name} 的错题信息为空,跳过") - doc.add_paragraph("(无错题信息)") - return - - problem_numbers = _parse_problem_numbers(problem_text) - if not problem_numbers: - log(f"警告:{student.name} 的错题号无法解析: {problem_text}") - doc.add_paragraph(f"(无法解析: {problem_text})") - return - - missing = [number for number in problem_numbers if number not in images] - if missing: - missing_text = ", ".join(map(str, missing)) + if record.status == "invalid": + document.add_paragraph(f"(无法解析: {record.raw_feedback})") log( - f"警告:{student.name} 的 {version_subject} " - f"缺少题目图片: {missing_text}" + f"警告:{record.name} " + f"的错题号无法解析: {record.raw_feedback}" ) - if not missing_confirmed and not _confirm_missing(widget, student.name, version_subject, missing): - raise GenerateError(f"已取消:{student.name} 的 {version_subject} 缺少图片 {missing_text}") + return - for number in problem_numbers: - image_path = images.get(number) + for number in record.problem_numbers: + image_path = image_paths.get(number) if image_path: - doc.add_picture(image_path, width=Cm(18)) + document.add_picture(image_path, width=Cm(18)) - log(f"{student.name} {date}日 {version_subject} 错题集已完成") + missing = [number for number in record.problem_numbers if number not in image_paths] + if missing: + document.add_paragraph(f"(缺少题目图片:{', '.join(map(str, missing))})") + if record.status == "unsubmitted": + log(f"{record.name} {date}日 {edition_subject} {record.raw_feedback}") + else: + log(f"{record.name} {date}日 {edition_subject} 错题集已完成") -def _save_record( - date: str, - classes: str, - subject: str, - week: str, - feedback_file: str, + +def _build_record_document( + path: Path, student_images_dir: str, teacher_images_dir: str, -) -> None: - record_path = Path("记录.docx") - record = Document(str(record_path)) if record_path.exists() else Document() + feedback_file: str, + classes: str, + subject: str, + weeknumber: str, + date: str, +) -> DocumentType: + record = Document(path) if path.exists() else Document() record.add_heading(date, level=1) - record.add_paragraph(f"学生版截图文件夹: {student_images_dir}") - record.add_paragraph(f"教师版截图文件夹: {teacher_images_dir}") + record.add_paragraph(f"学生版图片文件夹: {student_images_dir}") + record.add_paragraph(f"教师版图片文件夹: {teacher_images_dir}") record.add_paragraph(f"反馈表: {feedback_file}") record.add_paragraph(f"科目: {subject}(学生版 + 教师版)") record.add_paragraph(f"班级: {classes}") - record.add_paragraph(f"周数: {week}") + record.add_paragraph(f"周数: {weeknumber}") record.add_paragraph("") - record.save(str(record_path)) + return record + + +def _save_documents_atomically( + documents: list[tuple[Path, DocumentType]] +) -> None: + """Write every document to a temp file before replacing any target.""" + temporary_files: list[tuple[Path, Path]] = [] + try: + for final_path, document in documents: + final_path.parent.mkdir(parents=True, exist_ok=True) + handle, temporary_name = tempfile.mkstemp( + prefix=f".{final_path.name}.", + suffix=".tmp", + dir=final_path.parent, + ) + os.close(handle) + temporary_path = Path(temporary_name) + document.save(temporary_path) + temporary_files.append((temporary_path, final_path)) + + for temporary_path, final_path in temporary_files: + os.replace(temporary_path, final_path) + except Exception: + for temporary_path, _ in temporary_files: + temporary_path.unlink(missing_ok=True) + raise + + +def _backup_outputs( + student_root: Path, backup_dir: Path, log: Callable[[str], None] +) -> None: + """Back up the complete per-student delivery tree as one snapshot.""" + if backup_dir.exists(): + shutil.rmtree(backup_dir) + if not student_root.exists(): + log("学生目录不存在,跳过备份") + return + + backup_dir.mkdir(parents=True) + shutil.copytree(student_root, backup_dir / "学生") + log("已备份完整学生目录(含学生版和教师版)") + + +def restore_outputs( + base_dir: str | Path = ".", backup_dir: str | Path = "backup" +) -> tuple[str, ...]: + """Restore the complete per-student tree, including v3 legacy backups.""" + base_path = Path(base_dir) + backup_path = Path(backup_dir) + if not backup_path.exists(): + raise GenerateError("备份不存在") + + student_path = base_path / "学生" + if student_path.exists(): + shutil.rmtree(student_path) + + structured_source = backup_path / "学生" + if structured_source.exists(): + shutil.copytree(structured_source, student_path) + restored = ("学生(含学生版和教师版)",) + else: + # v3.0 stored the contents of 学生 directly in backup/. + shutil.copytree(backup_path, student_path) + restored = ("学生(旧版备份)",) + + shutil.rmtree(backup_path) + return restored def generate( @@ -277,82 +384,109 @@ def generate( subject: str, weeknumber: str, date: str, - widget: LogTarget, -) -> None: - """一次生成每名学生的学生版和教师版错题集。""" - log = _log_func(widget) + widget: GenerateWidget, +) -> GenerationResult: + """Generate two image-backed editions for every student in one operation.""" + log = widget.textBrowser.append + log("正在验证输入文件...") _validate_excel(feedback_file) - student_images = _validate_folder(student_images_dir, "学生版") - teacher_images = _validate_folder(teacher_images_dir, "教师版") + student_images = _discover_images(student_images_dir, "学生版") + teacher_images = _discover_images(teacher_images_dir, "教师版") _validate_corresponding_images(student_images, teacher_images) + all_problem_numbers = tuple(student_images) log(f"找到 {len(student_images)} 组一一对应的题目图片") log("正在读取错题反馈表...") - students = _read_students(feedback_file) - log(f"共 {len(students)} 名学生;每人生成学生版和教师版各一份") - - student_root = Path("学生") - backup_root = Path("backup") - _backup_students(student_root, backup_root, log) - - version_inputs = ( + records = _read_feedback(feedback_file, all_problem_numbers) + log(f"共 {len(records)} 名学生;每人生成学生版和教师版各一份") + + missing_by_student = { + record.name: [ + number + for number in record.problem_numbers + if number not in student_images + ] + for record in records + if any(number not in student_images for number in record.problem_numbers) + } + _confirm_missing_images(widget, missing_by_student) + + base_dir = Path(".") + student_root = base_dir / "学生" + record_path = base_dir / "记录.docx" + edition_inputs = ( ("学生版", f"{subject}学生版", student_images), ("教师版", f"{subject}教师版", teacher_images), ) - docs_to_save: list[tuple[Path, Document, str, str]] = [] - - for student in students: - problem_text = student.problem_text.strip() - missing_confirmed = False - if problem_text not in ("", "None", "无", "全对", "未带", "未交"): - requested_numbers = _parse_problem_numbers(problem_text) - missing_numbers = [number for number in requested_numbers if number not in student_images] - if missing_numbers: - missing_text = ", ".join(map(str, missing_numbers)) - log( - f"警告:{student.name} 的学生版和教师版 " - f"均缺少题目图片: {missing_text}" - ) - combined_subject = f"{subject}学生版和教师版" - if not _confirm_missing(widget, student.name, combined_subject, missing_numbers): - raise GenerateError( - f"已取消:{student.name} 的学生版和教师版缺少图片 {missing_text}" - ) - missing_confirmed = True - - for version_label, version_subject, images in version_inputs: - output_dir = student_root / student.name / version_subject - output_path = output_dir / f"第{weeknumber}周错题集.docx" - output_dir.mkdir(parents=True, exist_ok=True) - doc = _load_or_create_doc( - output_path, classes, student.name, version_subject, weeknumber + + documents: list[tuple[Path, DocumentType]] = [] + student_version_paths: list[Path] = [] + teacher_version_paths: list[Path] = [] + + for record in records: + for edition_label, edition_subject, image_paths in edition_inputs: + output_path = ( + student_root + / record.name + / edition_subject + / f"第{weeknumber}周错题集.docx" + ) + document = _load_document( + output_path, + classes, + record.name, + edition_subject, + weeknumber, ) _append_student_content( - doc, - student, + document, + record, + image_paths, date, - version_subject, - images, - widget, + edition_subject, log, - missing_confirmed=missing_confirmed, ) - docs_to_save.append((output_path, doc, student.name, version_label)) - - log("开始保存学生版和教师版错题集...") - for output_path, doc, student_name, version_label in docs_to_save: - doc.save(str(output_path)) - log(f"{student_name} 的{version_label}错题集已保存") - - _save_record( - date, + documents.append((output_path, document)) + if edition_label == "学生版": + student_version_paths.append(output_path) + else: + teacher_version_paths.append(output_path) + + record_document = _build_record_document( + record_path, + student_images_dir, + teacher_images_dir, + feedback_file, classes, subject, weeknumber, - feedback_file, - student_images_dir, - teacher_images_dir, + date, + ) + documents.append((record_path, record_document)) + + _backup_outputs(student_root, base_dir / "backup", log) + + log("开始同时保存学生版和教师版...") + try: + _save_documents_atomically(documents) + except Exception as exc: + raise GenerateError(f"保存文档失败: {exc}") from exc + + for record, student_path, teacher_path in zip( + records, student_version_paths, teacher_version_paths + ): + log(f"{record.name} 的学生版已保存: {student_path}") + log(f"{record.name} 的教师版已保存: {teacher_path}") + + log( + f"本次处理学生人数:{len(records)};" + f"生成文档数:{len(student_version_paths) + len(teacher_version_paths)}" + ) + log("学生版和教师版生成完成!") + + return GenerationResult( + tuple(str(path) for path in student_version_paths), + tuple(str(path) for path in teacher_version_paths), + str(record_path), ) - log(f"本次处理学生人数:{len(students)},生成文档数:{len(docs_to_save)}") - log("学生版和教师版均已生成完成!") diff --git a/main.py b/main.py index b638bbf..7096dc8 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""错题集生成器入口。""" +"""错题集生成器 v3.1 - 入口文件""" import sys @@ -8,7 +8,8 @@ from ui.main_window import MainWindow -def main() -> None: +def main(): + """Application entry point.""" app = QApplication(sys.argv) window = MainWindow() window.show() diff --git a/pyproject.toml b/pyproject.toml index fac8604..987aa1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "cuotiji-generator" version = "3.1.0" -description = "错题集生成器 - 一次生成学生版和教师版错题集" +description = "错题集生成器 - 一次生成每名学生的学生版和教师版" readme = "README.md" license = "Apache-2.0" authors = [ @@ -21,7 +21,6 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0", - "Pillow>=10.0", ] [project.scripts] @@ -37,3 +36,8 @@ packages = ["ui"] "core.py" = "core.py" "main.py" = "main.py" "version.py" = "version.py" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-q" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a266747 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest>=8.0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d459bb3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +PySide6>=6.5.0 +openpyxl>=3.1.0 +python-docx>=1.0.0 diff --git a/tests/conftest.py b/tests/conftest.py index 5801bea..f96b4d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,8 @@ +from __future__ import annotations + import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/test_core.py b/tests/test_core.py index 17f6e2f..d1ab5e6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,88 +1,120 @@ from __future__ import annotations import hashlib +import struct import zipfile +import zlib from pathlib import Path import openpyxl import pytest -from PIL import Image from docx import Document -from core import GenerateError, generate - - -class _Log: - def __init__(self) -> None: - self.lines: list[str] = [] - - def append(self, text: str) -> None: - self.lines.append(text) - - -class _Widget: - def __init__(self, confirm: bool = False) -> None: - self.textBrowser = _Log() - self.confirm = confirm - self.confirmations: list[tuple[str, str, list[int]]] = [] - - def confirm_missing_images(self, name: str, version_subject: str, missing: list[int]) -> bool: - self.confirmations.append((name, version_subject, missing)) - return self.confirm - - -def _image(path: Path, rgb: tuple[int, int, int]) -> None: - Image.new("RGB", (120, 80), rgb).save(path) +import core + + +class LogSink: + def __init__(self, confirm_missing: bool = False) -> None: + self.messages: list[str] = [] + self.textBrowser = self + self.confirm_missing = confirm_missing + self.confirmations: list[dict[str, list[int]]] = [] + + def append(self, message: str) -> None: + self.messages.append(message) + + def confirm_missing_images( + self, missing_by_student: dict[str, list[int]] + ) -> bool: + self.confirmations.append(missing_by_student) + return self.confirm_missing + + +def _png_chunk(kind: bytes, data: bytes) -> bytes: + checksum = zlib.crc32(kind + data) & 0xFFFFFFFF + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum) + + +def write_png( + path: Path, + color: tuple[int, int, int], + width: int = 120, + height: int = 60, +) -> None: + signature = b"\x89PNG\r\n\x1a\n" + ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) + row = b"\x00" + bytes(color) * width + image = zlib.compress(row * height) + path.write_bytes( + signature + + _png_chunk(b"IHDR", ihdr) + + _png_chunk(b"IDAT", image) + + _png_chunk(b"IEND", b"") + ) -def _feedback(path: Path, rows: list[tuple[str, str]]) -> None: +def write_feedback(path: Path, rows: list[tuple[str, object]]) -> None: workbook = openpyxl.Workbook() sheet = workbook.active sheet.append(["姓名", "错题号"]) for row in rows: - sheet.append(row) + sheet.append(list(row)) workbook.save(path) -def _media_hashes(docx_path: Path) -> set[str]: +def document_text(path: Path) -> str: + return "\n".join(paragraph.text for paragraph in Document(path).paragraphs) + + +def media_hashes(path: Path) -> set[str]: hashes: set[str] = set() - with zipfile.ZipFile(docx_path) as archive: + with zipfile.ZipFile(path) as archive: for name in archive.namelist(): if name.startswith("word/media/"): hashes.add(hashlib.sha256(archive.read(name)).hexdigest()) return hashes -def _file_hash(path: Path) -> str: +def file_hash(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() -def test_generate_coupled_student_and_teacher_versions(tmp_path: Path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - student_images = tmp_path / "student-images" - teacher_images = tmp_path / "teacher-images" +def prepare_two_editions(root: Path) -> tuple[Path, Path]: + student_images = root / "student-images" + teacher_images = root / "teacher-images" student_images.mkdir() teacher_images.mkdir() - - _image(student_images / "01.png", (255, 0, 0)) - _image(student_images / "02.png", (0, 255, 0)) - _image(teacher_images / "01.png", (0, 0, 255)) - _image(teacher_images / "02.png", (255, 255, 0)) + student_colors = ((255, 0, 0), (0, 255, 0), (0, 0, 255)) + teacher_colors = ((255, 255, 0), (255, 0, 255), (0, 255, 255)) + for number, (student_color, teacher_color) in enumerate( + zip(student_colors, teacher_colors), start=1 + ): + write_png(student_images / f"{number:02d}.png", student_color) + write_png(teacher_images / f"{number:02d}.png", teacher_color) + return student_images, teacher_images + + +def test_generate_coupled_student_and_teacher_versions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + student_images, teacher_images = prepare_two_editions(tmp_path) feedback = tmp_path / "feedback.xlsx" - _feedback(feedback, [("张三", "1"), ("李四", "2")]) + write_feedback(feedback, [("张三", "1,3"), ("李四", "2")]) + monkeypatch.chdir(tmp_path) - widget = _Widget() - generate( + result = core.generate( str(student_images), str(teacher_images), str(feedback), - "高一一班", + "高一1班", "物理", "03", "0729", - widget, + LogSink(), ) + assert len(result.student_version_paths) == 2 + assert len(result.teacher_version_paths) == 2 zhang_student = tmp_path / "学生/张三/物理学生版/第03周错题集.docx" zhang_teacher = tmp_path / "学生/张三/物理教师版/第03周错题集.docx" li_student = tmp_path / "学生/李四/物理学生版/第03周错题集.docx" @@ -90,86 +122,166 @@ def test_generate_coupled_student_and_teacher_versions(tmp_path: Path, monkeypat for path in (zhang_student, zhang_teacher, li_student, li_teacher): assert path.is_file() - assert _media_hashes(zhang_student) == {_file_hash(student_images / "01.png")} - assert _media_hashes(zhang_teacher) == {_file_hash(teacher_images / "01.png")} - assert _media_hashes(li_student) == {_file_hash(student_images / "02.png")} - assert _media_hashes(li_teacher) == {_file_hash(teacher_images / "02.png")} + assert media_hashes(zhang_student) == { + file_hash(student_images / "01.png"), + file_hash(student_images / "03.png"), + } + assert media_hashes(zhang_teacher) == { + file_hash(teacher_images / "01.png"), + file_hash(teacher_images / "03.png"), + } + assert media_hashes(li_student) == {file_hash(student_images / "02.png")} + assert media_hashes(li_teacher) == {file_hash(teacher_images / "02.png")} assert "物理学生版" in Document(zhang_student).sections[0].header.paragraphs[0].text assert "物理教师版" in Document(zhang_teacher).sections[0].header.paragraphs[0].text + assert not (tmp_path / "教师").exists() -def test_all_correct_and_unsubmitted_are_mirrored(tmp_path: Path, monkeypatch) -> None: +def test_special_states_are_mirrored_to_both_editions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + student_images, teacher_images = prepare_two_editions(tmp_path) + feedback = tmp_path / "feedback.xlsx" + write_feedback(feedback, [("甲", "全对"), ("乙", "未交")]) monkeypatch.chdir(tmp_path) - student_images = tmp_path / "s" - teacher_images = tmp_path / "t" - student_images.mkdir() - teacher_images.mkdir() - for number in (1, 2): - _image(student_images / f"{number:02d}.png", (number * 40, 0, 0)) - _image(teacher_images / f"{number:02d}.png", (0, number * 40, 0)) - feedback = tmp_path / "feedback.xlsx" - _feedback(feedback, [("甲", "全对"), ("乙", "未交")]) - generate(str(student_images), str(teacher_images), str(feedback), "一班", "数学", "1", "0729", _Widget()) + core.generate( + str(student_images), + str(teacher_images), + str(feedback), + "一班", + "数学", + "1", + "0729", + LogSink(), + ) - for suffix in ("学生版", "教师版"): - all_correct = Document(tmp_path / f"学生/甲/数学{suffix}/第1周错题集.docx") - assert any(paragraph.text == "全对" for paragraph in all_correct.paragraphs) - unsubmitted = tmp_path / f"学生/乙/数学{suffix}/第1周错题集.docx" - assert len(_media_hashes(unsubmitted)) == 2 + for edition in ("学生版", "教师版"): + all_correct = tmp_path / f"学生/甲/数学{edition}/第1周错题集.docx" + unsubmitted = tmp_path / f"学生/乙/数学{edition}/第1周错题集.docx" + assert "全对" in document_text(all_correct) + assert len(media_hashes(unsubmitted)) == 3 -def test_image_sets_must_have_matching_problem_numbers(tmp_path: Path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - student_images = tmp_path / "s" - teacher_images = tmp_path / "t" - student_images.mkdir() - teacher_images.mkdir() - _image(student_images / "01.png", (1, 2, 3)) - _image(student_images / "02.png", (4, 5, 6)) - _image(teacher_images / "01.png", (7, 8, 9)) +def test_image_sets_must_have_matching_problem_numbers( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + student_images, teacher_images = prepare_two_editions(tmp_path) + (teacher_images / "03.png").unlink() feedback = tmp_path / "feedback.xlsx" - _feedback(feedback, [("甲", "1")]) + write_feedback(feedback, [("甲", "1")]) + monkeypatch.chdir(tmp_path) - with pytest.raises(GenerateError, match="图片题号不对应"): - generate(str(student_images), str(teacher_images), str(feedback), "一班", "物理", "1", "0729", _Widget()) + with pytest.raises(core.GenerateError, match="图片题号不对应"): + core.generate( + str(student_images), + str(teacher_images), + str(feedback), + "一班", + "物理", + "1", + "0729", + LogSink(), + ) + assert not (tmp_path / "学生").exists() -def test_missing_requested_problem_can_be_cancelled_before_saving(tmp_path: Path, monkeypatch) -> None: - monkeypatch.chdir(tmp_path) - student_images = tmp_path / "s" - teacher_images = tmp_path / "t" - student_images.mkdir() - teacher_images.mkdir() - _image(student_images / "01.png", (1, 2, 3)) - _image(teacher_images / "01.png", (4, 5, 6)) - feedback = tmp_path / "feedback.xlsx" - _feedback(feedback, [("甲", "2")]) - with pytest.raises(GenerateError, match="缺少图片 2"): - generate(str(student_images), str(teacher_images), str(feedback), "一班", "物理", "1", "0729", _Widget()) +def test_duplicate_problem_images_are_rejected(tmp_path: Path) -> None: + images = tmp_path / "images" + images.mkdir() + write_png(images / "1.png", (255, 0, 0)) + write_png(images / "01.png", (0, 255, 0)) - assert not list((tmp_path / "学生").rglob("*.docx")) + with pytest.raises(core.GenerateError, match="重复题号: 1"): + core._discover_images(str(images), "学生版") -def test_existing_week_document_is_appended_and_previous_tree_is_backed_up(tmp_path: Path, monkeypatch) -> None: +def test_missing_requested_problem_cancel_writes_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + student_images, teacher_images = prepare_two_editions(tmp_path) + feedback = tmp_path / "feedback.xlsx" + write_feedback(feedback, [("甲", "4")]) monkeypatch.chdir(tmp_path) - student_images = tmp_path / "s" - teacher_images = tmp_path / "t" - student_images.mkdir() - teacher_images.mkdir() - _image(student_images / "01.png", (1, 2, 3)) - _image(teacher_images / "01.png", (4, 5, 6)) + sink = LogSink(confirm_missing=False) + + with pytest.raises(core.GenerateError, match="用户取消"): + core.generate( + str(student_images), + str(teacher_images), + str(feedback), + "一班", + "物理", + "1", + "0729", + sink, + ) + + assert sink.confirmations == [{"甲": [4]}] + assert not (tmp_path / "学生").exists() + assert not (tmp_path / "记录.docx").exists() + + +def test_second_date_appends_and_backup_restores_both_editions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + student_images, teacher_images = prepare_two_editions(tmp_path) feedback = tmp_path / "feedback.xlsx" - _feedback(feedback, [("甲", "全对")]) + write_feedback(feedback, [("甲", "全对")]) + monkeypatch.chdir(tmp_path) + args = ( + str(student_images), + str(teacher_images), + str(feedback), + "一班", + "物理", + "1", + ) + + core.generate(*args, "0728", LogSink()) + core.generate(*args, "0729", LogSink()) + + student_path = tmp_path / "学生/甲/物理学生版/第1周错题集.docx" + teacher_path = tmp_path / "学生/甲/物理教师版/第1周错题集.docx" + assert "0728" in document_text(student_path) + assert "0729" in document_text(student_path) + assert "0728" in document_text(teacher_path) + assert "0729" in document_text(teacher_path) + assert (tmp_path / "backup/学生/甲/物理学生版/第1周错题集.docx").is_file() + assert (tmp_path / "backup/学生/甲/物理教师版/第1周错题集.docx").is_file() + + restored = core.restore_outputs() + assert restored == ("学生(含学生版和教师版)",) + assert "0728" in document_text(student_path) + assert "0729" not in document_text(student_path) + assert "0728" in document_text(teacher_path) + assert "0729" not in document_text(teacher_path) + + +def test_restore_accepts_v3_legacy_student_backup(tmp_path: Path) -> None: + backup = tmp_path / "backup" / "张三" / "物理" + backup.mkdir(parents=True) + (backup / "legacy.txt").write_text("legacy", encoding="utf-8") + current = tmp_path / "学生" / "old" + current.mkdir(parents=True) - args = (str(student_images), str(teacher_images), str(feedback), "一班", "物理", "1") - generate(*args, "0728", _Widget()) - generate(*args, "0729", _Widget()) + restored = core.restore_outputs(tmp_path, tmp_path / "backup") + + assert restored == ("学生(旧版备份)",) + assert ( + tmp_path / "学生" / "张三" / "物理" / "legacy.txt" + ).read_text(encoding="utf-8") == "legacy" + + +def test_problem_number_parser_accepts_mixed_separators_and_deduplicates() -> None: + assert core._parse_problem_numbers("1,2, 3 2 4.0") == [1, 2, 3, 4] + + +def test_duplicate_student_names_are_rejected(tmp_path: Path) -> None: + feedback = tmp_path / "feedback.xlsx" + write_feedback(feedback, [("张三", "1"), ("张三", "2")]) - path = tmp_path / "学生/甲/物理学生版/第1周错题集.docx" - headings = [paragraph.text for paragraph in Document(path).paragraphs if paragraph.style.name.startswith("Heading")] - assert headings == ["0728", "0729"] - assert (tmp_path / "backup/甲/物理学生版/第1周错题集.docx").is_file() - assert (tmp_path / "backup/甲/物理教师版/第1周错题集.docx").is_file() + with pytest.raises(core.GenerateError, match="重复姓名: 张三"): + core._read_feedback(str(feedback), (1, 2)) diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..1e4bb56 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# UI module for 错题集生成器 v3.1 diff --git a/ui/icons/chevron.svg b/ui/icons/chevron.svg new file mode 100644 index 0000000..2a8836b --- /dev/null +++ b/ui/icons/chevron.svg @@ -0,0 +1,3 @@ + diff --git a/ui/icons/chevron_light.svg b/ui/icons/chevron_light.svg new file mode 100644 index 0000000..56c774c --- /dev/null +++ b/ui/icons/chevron_light.svg @@ -0,0 +1,3 @@ + diff --git a/ui/icons/file.svg b/ui/icons/file.svg new file mode 100644 index 0000000..4282d41 --- /dev/null +++ b/ui/icons/file.svg @@ -0,0 +1,8 @@ + diff --git a/ui/icons/file_light.svg b/ui/icons/file_light.svg new file mode 100644 index 0000000..94faa91 --- /dev/null +++ b/ui/icons/file_light.svg @@ -0,0 +1,8 @@ + diff --git a/ui/icons/folder.svg b/ui/icons/folder.svg new file mode 100644 index 0000000..a569176 --- /dev/null +++ b/ui/icons/folder.svg @@ -0,0 +1,7 @@ + diff --git a/ui/icons/folder_light.svg b/ui/icons/folder_light.svg new file mode 100644 index 0000000..6fa2778 --- /dev/null +++ b/ui/icons/folder_light.svg @@ -0,0 +1,7 @@ + diff --git a/ui/icons/moon.svg b/ui/icons/moon.svg new file mode 100644 index 0000000..b62e9a7 --- /dev/null +++ b/ui/icons/moon.svg @@ -0,0 +1,5 @@ + diff --git a/ui/icons/sun.svg b/ui/icons/sun.svg new file mode 100644 index 0000000..fc41ded --- /dev/null +++ b/ui/icons/sun.svg @@ -0,0 +1,11 @@ + diff --git a/ui/icons/undo.svg b/ui/icons/undo.svg new file mode 100644 index 0000000..2bed436 --- /dev/null +++ b/ui/icons/undo.svg @@ -0,0 +1,4 @@ + diff --git a/ui/icons/undo_light.svg b/ui/icons/undo_light.svg new file mode 100644 index 0000000..b561b1e --- /dev/null +++ b/ui/icons/undo_light.svg @@ -0,0 +1,4 @@ + diff --git a/ui/main_window.py b/ui/main_window.py index 9df25ba..f0d0a4d 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -5,7 +5,6 @@ import configparser import os import re -import shutil from datetime import datetime from PySide6.QtCore import QSize, Qt @@ -24,7 +23,7 @@ QWidget, ) -from version import __version__ +from version import APP_VERSION from .styles import get_dark_theme, get_light_theme from .widgets import DigitInput, DigitInputGroup @@ -34,16 +33,16 @@ class MainWindow(QMainWindow): - """错题集生成器主窗口。""" + """Main application window.""" def __init__(self) -> None: super().__init__() - self.setWindowTitle(f"错题集生成器 v{__version__}") - self.setFixedSize(580, 640) + self.setWindowTitle(f"错题集生成器 v{APP_VERSION}") + self.setFixedSize(600, 640) self._is_dark = True - self.student_shots_dir = "" - self.teacher_shots_dir = "" + self.student_images_dir = "" + self.teacher_images_dir = "" self.feedback_file = "" self._log_buffer: list[str] = [] @@ -58,13 +57,12 @@ def __init__(self) -> None: def _setup_ui(self) -> None: central = QWidget() self.setCentralWidget(central) - main_layout = QVBoxLayout(central) main_layout.setContentsMargins(30, 20, 30, 30) main_layout.setSpacing(0) header_layout = QHBoxLayout() - title_label = QLabel(f"错题集生成器 v{__version__}") + title_label = QLabel(f"错题集生成器 v{APP_VERSION}") title_label.setObjectName("titleLabel") header_layout.addWidget(title_label) header_layout.addStretch() @@ -154,14 +152,14 @@ def _setup_ui(self) -> None: form_layout, row, "学生版图片:", - "student", + self._select_student_folder, ) row += 1 self.teacher_folder_input, self.teacher_folder_btn = self._add_folder_row( form_layout, row, "教师版图片:", - "teacher", + self._select_teacher_folder, ) row += 1 @@ -181,19 +179,10 @@ def _setup_ui(self) -> None: self.file_btn.setIconSize(QSize(24, 24)) self.file_btn.clicked.connect(self._select_file) form_layout.addWidget(self.file_btn, row, 2) - main_layout.addLayout(form_layout) - main_layout.addSpacing(12) - hint = QLabel( - "一次生成两份:学生/<姓名>/<科目>学生版 与 <科目>教师版," - "便于直接打包学生姓名目录发给家长。" - ) - hint.setWordWrap(True) - hint.setObjectName("authorLabel") - main_layout.addWidget(hint) + main_layout.addSpacing(25) main_layout.addStretch() - button_layout = QHBoxLayout() button_layout.setSpacing(40) button_layout.addStretch() @@ -213,7 +202,7 @@ def _add_folder_row( layout: QGridLayout, row: int, label_text: str, - kind: str, + callback, ) -> tuple[QLineEdit, QPushButton]: label = QLabel(label_text) layout.addWidget( @@ -226,35 +215,39 @@ def _add_folder_row( line_edit.setReadOnly(True) line_edit.setPlaceholderText("点击右侧按钮选择文件夹") layout.addWidget(line_edit, row, 1) - button = QPushButton() button.setObjectName("browseButton") button.setIconSize(QSize(24, 24)) - button.clicked.connect(lambda: self._select_folder(kind)) + button.clicked.connect(callback) layout.addWidget(button, row, 2) return line_edit, button def _setup_navigation(self) -> None: - self.class_input.returnPressed.connect(lambda: self.date_inputs[0].setFocus()) - self.date_inputs[-1].digitEntered.connect(lambda: self.week_inputs[0].setFocus()) + self.class_input.returnPressed.connect( + lambda: self.date_inputs[0].setFocus() + ) + self.date_inputs[-1].digitEntered.connect( + lambda: self.week_inputs[0].setFocus() + ) def _apply_theme(self) -> None: if self._is_dark: self.setStyleSheet(get_dark_theme()) self.theme_btn.setIcon(QIcon(os.path.join(_ICON_DIR, "moon.svg"))) self.restore_btn.setIcon(QIcon(os.path.join(_ICON_DIR, "undo.svg"))) - folder_icon = os.path.join(_ICON_DIR, "folder.svg") - file_icon = os.path.join(_ICON_DIR, "file.svg") + folder_icon = QIcon(os.path.join(_ICON_DIR, "folder.svg")) + self.file_btn.setIcon(QIcon(os.path.join(_ICON_DIR, "file.svg"))) else: self.setStyleSheet(get_light_theme()) self.theme_btn.setIcon(QIcon(os.path.join(_ICON_DIR, "sun.svg"))) - self.restore_btn.setIcon(QIcon(os.path.join(_ICON_DIR, "undo_light.svg"))) - folder_icon = os.path.join(_ICON_DIR, "folder_light.svg") - file_icon = os.path.join(_ICON_DIR, "file_light.svg") + self.restore_btn.setIcon( + QIcon(os.path.join(_ICON_DIR, "undo_light.svg")) + ) + folder_icon = QIcon(os.path.join(_ICON_DIR, "folder_light.svg")) + self.file_btn.setIcon(QIcon(os.path.join(_ICON_DIR, "file_light.svg"))) - for button in (self.student_folder_btn, self.teacher_folder_btn): - button.setIcon(QIcon(folder_icon)) - self.file_btn.setIcon(QIcon(file_icon)) + self.student_folder_btn.setIcon(folder_icon) + self.teacher_folder_btn.setIcon(folder_icon) self.theme_btn.setIconSize(QSize(20, 20)) self.restore_btn.setIconSize(QSize(20, 20)) self.date_group.set_dark_mode(self._is_dark) @@ -270,36 +263,34 @@ def _toggle_theme(self) -> None: def _load_saved_config(self) -> None: if self.config.has_section("info"): - self.class_input.setText(self.config.get("info", "class", fallback="")) - self.date_group.set_value(self.config.get("info", "date", fallback="")) - self.week_group.set_value(self.config.get("info", "week", fallback="")) - - subject = self.config.get("info", "subject", fallback="") + section = self.config["info"] + self.class_input.setText(section.get("class", "")) + self.date_group.set_value(section.get("date", "")) + self.week_group.set_value(section.get("week", "")) + subject = section.get("subject", "") index = self.subject_combo.findText(subject) if index >= 0: self.subject_combo.setCurrentIndex(index) - # v3.0 的 folder 自动迁移为学生版目录,避免升级后完全丢失配置。 - student_folder = self.config.get( - "info", - "student_folder", - fallback=self.config.get("info", "folder", fallback=""), - ) - teacher_folder = self.config.get("info", "teacher_folder", fallback="") + # v3.0 used one `folder` key. Migrate it to the student edition. + student_folder = section.get("student_folder", section.get("folder", "")) + teacher_folder = section.get("teacher_folder", "") if os.path.isdir(student_folder): - self.student_shots_dir = student_folder + self.student_images_dir = student_folder self.student_folder_input.setText(student_folder) if os.path.isdir(teacher_folder): - self.teacher_shots_dir = teacher_folder + self.teacher_images_dir = teacher_folder self.teacher_folder_input.setText(teacher_folder) - file_path = self.config.get("info", "file", fallback="") + file_path = section.get("file", "") if os.path.isfile(file_path): self.feedback_file = file_path self.file_input.setText(file_path) if self.config.has_section("ui"): - self._is_dark = self.config.getboolean("ui", "dark_mode", fallback=True) + self._is_dark = self.config.getboolean( + "ui", "dark_mode", fallback=True + ) self._apply_theme() def _save_config(self) -> None: @@ -309,48 +300,34 @@ def _save_config(self) -> None: self.config.set("info", "date", self.date_group.get_value()) self.config.set("info", "week", self.week_group.get_value()) self.config.set("info", "subject", self.subject_combo.currentText()) - self.config.set("info", "student_folder", self.student_shots_dir) - self.config.set("info", "teacher_folder", self.teacher_shots_dir) + self.config.set("info", "student_folder", self.student_images_dir) + self.config.set("info", "teacher_folder", self.teacher_images_dir) self.config.set("info", "file", self.feedback_file) self._write_config() def _write_config(self) -> None: - with open("config.ini", "w", encoding="utf-8") as config_file: - self.config.write(config_file) + with open("config.ini", "w", encoding="utf-8") as file: + self.config.write(file) + + def _select_student_folder(self) -> None: + folder = self._select_folder("选择学生版图片文件夹", self.student_images_dir) + if folder: + self.student_images_dir = folder + self.student_folder_input.setText(folder) + + def _select_teacher_folder(self) -> None: + folder = self._select_folder("选择教师版图片文件夹", self.teacher_images_dir) + if folder: + self.teacher_images_dir = folder + self.teacher_folder_input.setText(folder) - def _select_folder(self, kind: str) -> None: - current = self.student_shots_dir if kind == "student" else self.teacher_shots_dir - title = "选择学生版题目图片文件夹" if kind == "student" else "选择教师版题目图片文件夹" - folder = QFileDialog.getExistingDirectory( + def _select_folder(self, title: str, start_dir: str) -> str: + return QFileDialog.getExistingDirectory( self, title, - current, + start_dir, options=QFileDialog.Option.ShowDirsOnly, ) - if not folder: - return - - self._rename_single_digit_files(folder) - if kind == "student": - self.student_shots_dir = folder - self.student_folder_input.setText(folder) - else: - self.teacher_shots_dir = folder - self.teacher_folder_input.setText(folder) - - @staticmethod - def _rename_single_digit_files(folder: str) -> None: - for file_name in os.listdir(folder): - path = os.path.join(folder, file_name) - if not os.path.isfile(path): - continue - stem, extension = os.path.splitext(file_name) - if extension.lower() not in (".png", ".jpg", ".jpeg"): - continue - if stem.isdigit() and len(stem) == 1: - destination = os.path.join(folder, f"0{stem}{extension}") - if not os.path.exists(destination): - os.rename(path, destination) def _select_file(self) -> None: start_dir = os.path.dirname(self.feedback_file) if self.feedback_file else "" @@ -366,12 +343,12 @@ def _select_file(self) -> None: self._auto_fill_from_filename(file_path) def _auto_fill_from_filename(self, file_path: str) -> None: - file_name = os.path.basename(file_path) - date_match = re.search(r"(\d{4})", file_name) + filename = os.path.basename(file_path) + date_match = re.search(r"(\d{4})", filename) if date_match: self.date_group.set_value(date_match.group(1)) for subject in ("数学", "物理", "化学", "生物"): - if subject in file_name: + if subject in filename: self.subject_combo.setCurrentText(subject) break @@ -383,15 +360,13 @@ def _reset(self) -> None: self.student_folder_input.clear() self.teacher_folder_input.clear() self.file_input.clear() - self.student_shots_dir = "" - self.teacher_shots_dir = "" + self.student_images_dir = "" + self.teacher_images_dir = "" self.feedback_file = "" self._load_saved_config() def _restore_backup(self) -> None: - backup_dir = os.path.join(".", "backup") - student_dir = os.path.join(".", "学生") - if not os.path.exists(backup_dir): + if not os.path.exists("backup"): QMessageBox.warning( self, "警告", @@ -402,8 +377,8 @@ def _restore_backup(self) -> None: reply = QMessageBox.question( self, "确认恢复", - "确定要恢复到上次操作之前的状态吗?\n" - "当前学生目录中的学生版和教师版文件都将被覆盖。", + "确定要恢复到上次生成之前的状态吗?\n" + "当前学生目录中的学生版和教师版都会被还原。", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) @@ -411,32 +386,56 @@ def _restore_backup(self) -> None: return try: - if os.path.exists(student_dir): - shutil.rmtree(student_dir) - shutil.copytree(backup_dir, student_dir) - shutil.rmtree(backup_dir) - QMessageBox.information(self, "成功", "学生版和教师版备份均已恢复!") + from core import restore_outputs + + restored = restore_outputs() + QMessageBox.information( + self, "成功", f"备份已恢复:{'、'.join(restored)}" + ) except Exception as exc: QMessageBox.critical(self, "错误", f"恢复失败: {exc}") def _validate_inputs(self) -> bool: checks = ( - (bool(self.class_input.text().strip()), "请输入班级!", self.class_input), - (self.date_group.is_complete(), "请输入完整的日期 (MMDD)!", self.date_inputs[0]), - (self.week_group.is_complete(), "请输入周数!", self.week_inputs[0]), - (bool(self.student_shots_dir), "请选择学生版图片文件夹!", None), - (os.path.isdir(self.student_shots_dir), "学生版图片文件夹不存在!", None), - (bool(self.teacher_shots_dir), "请选择教师版图片文件夹!", None), - (os.path.isdir(self.teacher_shots_dir), "教师版图片文件夹不存在!", None), - (bool(self.feedback_file), "请选择错题反馈表!", None), - (os.path.isfile(self.feedback_file), "错题反馈表文件不存在!", None), + ( + bool(self.class_input.text().strip()), + "请输入班级!", + self.class_input, + ), + ( + self.date_group.is_complete(), + "请输入完整的日期 (MMDD)!", + self.date_inputs[0], + ), + ( + self.week_group.is_complete(), + "请输入周数!", + self.week_inputs[0], + ), + ) + for valid, message, widget in checks: + if not valid: + QMessageBox.warning(self, "警告", message) + widget.setFocus() + return False + + folders = ( + (self.student_images_dir, "请选择学生版图片文件夹!"), + (self.teacher_images_dir, "请选择教师版图片文件夹!"), ) - for valid, message, focus_widget in checks: - if valid: - continue - QMessageBox.warning(self, "警告", message) - if focus_widget is not None: - focus_widget.setFocus() + for folder, missing_message in folders: + if not folder: + QMessageBox.warning(self, "警告", missing_message) + return False + if not os.path.isdir(folder): + QMessageBox.warning(self, "警告", f"图片文件夹不存在: {folder}") + return False + + if not self.feedback_file: + QMessageBox.warning(self, "警告", "请选择错题反馈表!") + return False + if not os.path.isfile(self.feedback_file): + QMessageBox.warning(self, "警告", "错题反馈表文件不存在!") return False return True @@ -451,32 +450,32 @@ def _save_log(self) -> None: os.makedirs(_LOG_DIR, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") log_file = os.path.join(_LOG_DIR, f"generate_{timestamp}.log") - with open(log_file, "w", encoding="utf-8") as output: - output.write(f"错题集生成器 v{__version__} - 生成日志\n") - output.write(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") - output.write(f"班级: {self.class_input.text()}\n") - output.write(f"科目: {self.subject_combo.currentText()}\n") - output.write(f"日期: {self.date_group.get_value()}\n") - output.write(f"周数: {self.week_group.get_value()}\n") - output.write(f"学生版图片: {self.student_shots_dir}\n") - output.write(f"教师版图片: {self.teacher_shots_dir}\n") - output.write("-" * 50 + "\n\n") - output.write("\n".join(self._log_buffer)) + with open(log_file, "w", encoding="utf-8") as file: + file.write(f"错题集生成器 v{APP_VERSION} - 生成日志\n") + file.write(f"时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + file.write(f"班级: {self.class_input.text()}\n") + file.write(f"科目: {self.subject_combo.currentText()}\n") + file.write(f"日期: {self.date_group.get_value()}\n") + file.write(f"周数: {self.week_group.get_value()}\n") + file.write(f"学生版图片: {self.student_images_dir}\n") + file.write(f"教师版图片: {self.teacher_images_dir}\n") + file.write("-" * 50 + "\n\n") + file.write("\n".join(self._log_buffer)) self._log_buffer.clear() def _start_generate(self) -> None: if not self._validate_inputs(): return + self._save_config() self._log_buffer.clear() - try: from core import generate adapter = TextBrowserAdapter(self) generate( - self.student_shots_dir, - self.teacher_shots_dir, + self.student_images_dir, + self.teacher_images_dir, self.feedback_file, self.class_input.text().strip(), self.subject_combo.currentText(), @@ -488,7 +487,7 @@ def _start_generate(self) -> None: QMessageBox.information( self, "完成", - "学生版和教师版错题集均已生成!", + "每名学生的学生版和教师版已同时生成!", ) except Exception as exc: self._log(f"错误: {exc}") @@ -498,26 +497,27 @@ def _start_generate(self) -> None: def _show_help(self) -> None: help_text = f"""
输出结构:
-学生/张三/物理学生版/第X周错题集.docx
-学生/张三/物理教师版/第X周错题集.docx
因此可以直接打包“张三”目录发给家长。
-恢复备份:左上角 ↩ 会同时恢复两版文件。
+一次生成两套文档:
+学生/张三/物理学生版/第3周错题集.docx +学生/张三/物理教师版/第3周错题集.docx+
两版的学生、错题号、日期和排版相同,仅插入图片来源不同;可直接打包学生姓名目录发给家长。
+恢复备份:点击左上角↩按钮,恢复上次生成前的完整学生目录。
关于:v{__version__} 恢复并整合了旧版通过“科目学生版/教师版”目录区分两套材料的工作流。
+关于:初版由 Leonard 于 2022 年 @武汉天任教育 开发,Lrj 参与命令行初版开发。
+v{APP_VERSION} 将旧版分别运行“科目学生版/科目教师版”的流程整合为一次生成。
项目地址: github.com/Leonardlsa/problems-folder-generator
""" QMessageBox.information(self, "使用帮助", help_text) class TextBrowserAdapter: - """将核心逻辑需要的日志和确认接口适配到主窗口。""" + """Adapter that forwards core logs and confirmations to the main window.""" def __init__(self, window: MainWindow) -> None: self.window = window @@ -527,18 +527,15 @@ def append(self, text: str) -> None: self.window._log(text) def confirm_missing_images( - self, - name: str, - version_subject: str, - missing: list[int], + self, missing_by_student: dict[str, list[int]] ) -> bool: - missing_text = ", ".join(map(str, missing)) - reply = QMessageBox.warning( + from core import _format_missing_message + + result = QMessageBox.warning( self.window, "缺少图片", - f"{name} 的 {version_subject} 缺少题目 {missing_text} 的图片。\n" - "是否忽略缺失图片并继续生成两版文档?", + _format_missing_message(missing_by_student), QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) - return reply == QMessageBox.StandardButton.Yes + return result == QMessageBox.StandardButton.Yes diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..c4fe300 --- /dev/null +++ b/ui/styles.py @@ -0,0 +1,227 @@ +"""QSS styles for 错题集生成器 v3.1 - Dark and Light themes""" + +import os + +# Get the icons directory path +_ICON_DIR = os.path.join(os.path.dirname(__file__), "icons") +_CHEVRON_DARK_PATH = os.path.join(_ICON_DIR, "chevron.svg").replace("\\", "/") + + +def _base_theme(bg_color: str, fg_color: str, border_color: str, + accent_color: str, placeholder_color: str, hover_bg: str, + chevron_path: str) -> str: + """Generate theme QSS with given colors.""" + return f""" +QMainWindow {{ + background-color: {bg_color}; +}} + +QWidget {{ + background-color: {bg_color}; + color: {fg_color}; + font-family: "Microsoft YaHei UI", "Noto Sans CJK SC", sans-serif; +}} + +QLabel {{ + color: {fg_color}; + font-size: 14px; + background-color: transparent; +}} + +QLabel#titleLabel {{ + font-size: 24px; + font-weight: bold; +}} + +QLabel#authorLabel {{ + font-size: 12px; + color: {placeholder_color}; +}} + +QLineEdit {{ + background-color: transparent; + border: 2px solid {border_color}; + border-radius: 8px; + color: {fg_color}; + padding: 10px 14px; + font-size: 14px; + min-height: 22px; +}} + +QLineEdit:focus {{ + border-color: {accent_color}; +}} + +QLineEdit:read-only {{ + color: {placeholder_color}; +}} + +QComboBox {{ + background-color: transparent; + border: 2px solid {border_color}; + border-radius: 8px; + color: {fg_color}; + padding: 10px 14px; + font-size: 14px; + min-height: 22px; +}} + +QComboBox:focus {{ + border-color: {accent_color}; +}} + +QComboBox::drop-down {{ + border: none; + width: 36px; + padding-right: 8px; +}} + +QComboBox::down-arrow {{ + image: url({chevron_path}); + width: 18px; + height: 18px; +}} + +QComboBox QAbstractItemView {{ + background-color: {bg_color}; + border: 2px solid {border_color}; + border-radius: 4px; + color: {fg_color}; + selection-background-color: {accent_color}; + selection-color: #ffffff; + outline: none; +}} + +QComboBox QAbstractItemView::item {{ + padding: 10px 14px; + min-height: 28px; +}} + +QComboBox QAbstractItemView::item:hover {{ + background-color: {hover_bg}; +}} + +QPushButton {{ + background-color: transparent; + border: 2px solid {border_color}; + border-radius: 8px; + color: {fg_color}; + padding: 12px 24px; + font-size: 14px; + min-width: 80px; +}} + +QPushButton:hover {{ + background-color: {hover_bg}; +}} + +QPushButton:pressed {{ + background-color: {hover_bg}; +}} + +QPushButton#goButton {{ + font-size: 16px; + font-weight: bold; + min-width: 120px; +}} + +QPushButton#resetButton {{ + font-size: 16px; + min-width: 120px; +}} + +QPushButton#infoButton, QPushButton#themeButton {{ + border-radius: 17px; + min-width: 34px; + max-width: 34px; + min-height: 34px; + max-height: 34px; + padding: 0px; + font-size: 18px; + font-weight: bold; +}} + +QPushButton#browseButton {{ + border-radius: 8px; + min-width: 44px; + max-width: 44px; + min-height: 44px; + max-height: 44px; + padding: 0px; +}} + +QMessageBox {{ + background-color: {bg_color}; +}} + +QMessageBox QLabel {{ + color: {fg_color}; +}} + +QMessageBox QPushButton {{ + min-width: 80px; +}} +""" + + +def get_dark_theme() -> str: + """Return the dark theme QSS.""" + return _base_theme( + bg_color="#1a1a1a", + fg_color="#ffffff", + border_color="#ffffff", + accent_color="#4a9eff", + placeholder_color="#888888", + hover_bg="rgba(255, 255, 255, 0.1)", + chevron_path=_CHEVRON_DARK_PATH + ) + + +def get_light_theme() -> str: + """Return the light theme QSS.""" + # Create light chevron path + chevron_light = os.path.join(_ICON_DIR, "chevron_light.svg").replace("\\", "/") + return _base_theme( + bg_color="#f5f5f5", + fg_color="#1a1a1a", + border_color="#1a1a1a", + accent_color="#0066cc", + placeholder_color="#666666", + hover_bg="rgba(0, 0, 0, 0.08)", + chevron_path=chevron_light + ) + + +# For backward compatibility +DARK_THEME = get_dark_theme() +LIGHT_THEME = get_light_theme() + + +# Style for digit input boxes +def get_digit_style(fg_color: str, accent_color: str) -> str: + return f""" +QLineEdit {{ + background-color: transparent; + border: none; + border-bottom: 2px solid {fg_color}; + border-radius: 0px; + color: {fg_color}; + padding: 4px; + font-size: 18px; + min-width: 24px; + max-width: 24px; + min-height: 24px; + text-align: center; +}} + +QLineEdit:focus {{ + border-bottom-color: {accent_color}; +}} +""" + + +DIGIT_INPUT_STYLE_DARK = get_digit_style("#ffffff", "#4a9eff") +DIGIT_INPUT_STYLE_LIGHT = get_digit_style("#1a1a1a", "#0066cc") + +# Default +DIGIT_INPUT_STYLE = DIGIT_INPUT_STYLE_DARK diff --git a/ui/widgets.py b/ui/widgets.py new file mode 100644 index 0000000..57a3a69 --- /dev/null +++ b/ui/widgets.py @@ -0,0 +1,111 @@ +"""Custom widgets for 错题集生成器 v3.1""" + +from PySide6.QtWidgets import QLineEdit +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QKeyEvent + +from .styles import DIGIT_INPUT_STYLE_DARK, DIGIT_INPUT_STYLE_LIGHT + + +class DigitInput(QLineEdit): + """Single digit input box with auto-advance functionality.""" + + # Signal emitted when user presses backspace on empty input + backspaceOnEmpty = Signal() + # Signal emitted when a digit is entered (for auto-advance) + digitEntered = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self.setMaxLength(1) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._is_dark = True + self.setStyleSheet(DIGIT_INPUT_STYLE_DARK) + + # Connect text changed to handle auto-advance + self.textChanged.connect(self._on_text_changed) + + def set_dark_mode(self, is_dark: bool): + """Set the theme mode.""" + self._is_dark = is_dark + if is_dark: + self.setStyleSheet(DIGIT_INPUT_STYLE_DARK) + else: + self.setStyleSheet(DIGIT_INPUT_STYLE_LIGHT) + + def _on_text_changed(self, text: str): + """Handle text change - emit signal when digit entered.""" + if text and text.isdigit(): + self.digitEntered.emit() + + def keyPressEvent(self, event: QKeyEvent): + """Handle key press events.""" + key = event.key() + + # Handle backspace on empty input + if key == Qt.Key.Key_Backspace and not self.text(): + self.backspaceOnEmpty.emit() + return + + # Only allow digits + if event.text() and not event.text().isdigit(): + if key not in (Qt.Key.Key_Backspace, Qt.Key.Key_Delete, + Qt.Key.Key_Left, Qt.Key.Key_Right, + Qt.Key.Key_Tab, Qt.Key.Key_Backtab): + return + + super().keyPressEvent(event) + + +class DigitInputGroup: + """Manages a group of DigitInput widgets with auto-navigation.""" + + def __init__(self, inputs: list[DigitInput]): + self.inputs = inputs + self._setup_navigation() + + def _setup_navigation(self): + """Connect signals for auto-navigation between inputs.""" + for i, inp in enumerate(self.inputs): + # Auto-advance to next input + if i < len(self.inputs) - 1: + inp.digitEntered.connect( + lambda idx=i: self.inputs[idx + 1].setFocus() + ) + + # Backspace to previous input + if i > 0: + inp.backspaceOnEmpty.connect( + lambda idx=i: self._focus_and_select(idx - 1) + ) + + def _focus_and_select(self, index: int): + """Focus input at index and select its content.""" + self.inputs[index].setFocus() + self.inputs[index].selectAll() + + def get_value(self) -> str: + """Get concatenated value from all inputs.""" + return ''.join(inp.text() for inp in self.inputs) + + def set_value(self, value: str): + """Set value across all inputs.""" + for i, inp in enumerate(self.inputs): + if i < len(value): + inp.setText(value[i]) + else: + inp.clear() + + def clear(self): + """Clear all inputs.""" + for inp in self.inputs: + inp.clear() + + def is_complete(self) -> bool: + """Check if all inputs have values.""" + return all(inp.text() for inp in self.inputs) + + def set_dark_mode(self, is_dark: bool): + """Set theme mode for all inputs.""" + for inp in self.inputs: + inp.set_dark_mode(is_dark) diff --git a/version.py b/version.py index ea2636d..0c1bfa4 100644 --- a/version.py +++ b/version.py @@ -1,3 +1,3 @@ -"""Application version.""" +"""Application version metadata.""" -__version__ = "3.1.0" +APP_VERSION = "3.1.0" From ad306c51499487f4072ed24ce2ee6a0445e640ff Mon Sep 17 00:00:00 2001 From: lsamc <2322176165@qq.com> Date: Wed, 29 Jul 2026 16:44:56 +0800 Subject: [PATCH 2/2] ci: support Python 3.10 version checks --- .github/workflows/build.yml | 5 ++++- .github/workflows/ci.yml | 5 ++++- pyproject.toml | 1 + requirements-dev.txt | 1 + 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d2e099..49d6edf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -39,7 +39,10 @@ jobs: run: | python - <<'PY' import os - import tomllib + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib from pathlib import Path from version import APP_VERSION diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c0753d..09b9eb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,10 @@ jobs: - name: Check version metadata run: | python - <<'PY' - import tomllib + try: + import tomllib + except ModuleNotFoundError: + import tomli as tomllib from pathlib import Path from version import APP_VERSION diff --git a/pyproject.toml b/pyproject.toml index 987aa1e..9250e35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "pytest>=8.0", + "tomli>=2.0; python_version < '3.11'", ] [project.scripts] diff --git a/requirements-dev.txt b/requirements-dev.txt index a266747..89ad494 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,2 +1,3 @@ -r requirements.txt pytest>=8.0 +tomli>=2.0; python_version < "3.11"